diff --git a/.github/workflows/ship.yml b/.github/workflows/ship.yml index b2c6d27..17f26aa 100644 --- a/.github/workflows/ship.yml +++ b/.github/workflows/ship.yml @@ -4,10 +4,6 @@ on: release: types: [published] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 @@ -22,7 +18,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false ref: ${{ github.event.release.tag_name }} @@ -47,7 +43,7 @@ jobs: sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Install pinned Rust toolchain uses: ./.github/actions/setup-rust @@ -55,7 +51,7 @@ jobs: components: clippy, rustfmt - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('package.json', 'bun.lock') }} @@ -63,13 +59,23 @@ jobs: ${{ runner.os }}-bun- - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: . -> target - name: Install frontend dependencies run: bun install --frozen-lockfile + - name: Validate release tag and channel + env: + CHANNEL: ${{ github.event.release.prerelease && 'beta' || 'stable' }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + cargo run --quiet -p updater-release -- validate-event \ + --package-json apps/desktop/package.json \ + --tag "${TAG_NAME}" \ + --channel "${CHANNEL}" + - name: Run release verification gate run: make verify @@ -80,6 +86,9 @@ jobs: strategy: fail-fast: false + # tauri-action merges each platform into the same GitHub latest.json asset. + # Serialize those mutations until the manifest is aggregated in one job. + max-parallel: 1 matrix: include: - platform: 'macos-26' @@ -93,14 +102,15 @@ jobs: target: '' runs-on: ${{ matrix.platform }} + timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false ref: ${{ needs.validate-release.outputs.commit }} - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Install pinned Rust toolchain uses: ./.github/actions/setup-rust @@ -108,12 +118,12 @@ jobs: targets: ${{ matrix.platform == 'macos-26' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: . -> target - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('package.json', 'bun.lock') }} @@ -131,7 +141,7 @@ jobs: - name: Build and upload release artifacts id: tauri_build - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -145,12 +155,42 @@ jobs: includeUpdaterJson: true args: ${{ matrix.args }} - publish-manifest: - needs: publish-tauri + prepare-manifest: + needs: [validate-release, publish-tauri] runs-on: ubuntu-latest + timeout-minutes: 90 + outputs: + evidence-artifact: ${{ steps.evidence-name.outputs.artifact }} permissions: contents: read steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ needs.validate-release.outputs.commit }} + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Install pinned Rust toolchain + uses: ./.github/actions/setup-rust + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: . -> target + + - name: Cache Bun dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + - name: Resolve release metadata id: meta shell: bash @@ -163,34 +203,117 @@ jobs: else CHANNEL="stable" fi - echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" - name: Download updater manifest env: - VERSION: ${{ steps.meta.outputs.version }} TAG_NAME: ${{ steps.meta.outputs.tag_name }} REPO: ${{ github.repository }} - shell: bash run: | set -euo pipefail mkdir -p release-assets - curl -fsSL \ + --proto '=https' \ + --proto-redir '=https' \ + --retry 3 \ "https://github.com/${REPO}/releases/download/${TAG_NAME}/latest.json" \ -o release-assets/latest.json - jq -e --arg v "$VERSION" '.version == $v' release-assets/latest.json >/dev/null - jq -e '.platforms | type == "object" and length > 0' release-assets/latest.json >/dev/null - jq -e '.platforms | to_entries | all(.value.signature | type == "string" and length > 0)' release-assets/latest.json >/dev/null - jq -e '.platforms | to_entries | all(.value.url | test("https://github.com/.+/releases/download/.+/"))' release-assets/latest.json >/dev/null + - name: Validate manifest and signed artifacts + env: + CHANNEL: ${{ steps.meta.outputs.channel }} + TAG_NAME: ${{ steps.meta.outputs.tag_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + : > release-assets/empty-history.txt + : > release-assets/empty-revocations.txt + cargo run --quiet -p updater-release -- validate-release \ + --manifest release-assets/latest.json \ + --config apps/desktop/src-tauri/tauri.conf.json \ + --package-json apps/desktop/package.json \ + --repository "${REPO}" \ + --tag "${TAG_NAME}" \ + --channel "${CHANNEL}" \ + --history release-assets/empty-history.txt \ + --revocations release-assets/empty-revocations.txt \ + --download-directory release-assets/artifacts \ + --report release-assets/validation-report.json + + - name: Sign candidate manifest + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + bun --cwd=apps/desktop run tauri signer sign ../../release-assets/latest.json + cargo run --quiet -p updater-release -- verify-signature \ + --file release-assets/latest.json \ + --signature release-assets/latest.json.sig \ + --config apps/desktop/src-tauri/tauri.conf.json + + cp target/debug/updater-release release-assets/updater-release + cp apps/desktop/src-tauri/tauri.conf.json release-assets/tauri.conf.json + cp apps/desktop/package.json release-assets/package.json + + - name: Name validated manifest evidence + id: evidence-name + run: echo "artifact=updater-manifest-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Transfer validated manifest evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.evidence-name.outputs.artifact }} + path: | + release-assets/latest.json + release-assets/latest.json.sig + release-assets/validation-report.json + release-assets/updater-release + release-assets/tauri.conf.json + release-assets/package.json + if-no-files-found: error + retention-days: 7 + + publish-manifest: + needs: [validate-release, prepare-manifest] + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: updater-feed-${{ github.event.release.prerelease && 'beta' || 'stable' }} + queue: max + cancel-in-progress: false + permissions: + contents: read + steps: + - name: Download validated manifest evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.prepare-manifest.outputs.evidence-artifact }} + path: release-assets - ls -lah release-assets + - name: Restore validator executable permission + run: chmod 0700 release-assets/updater-release - - name: Upload manifest to R2 + - name: Resolve release metadata + id: meta shell: bash + run: | + set -euo pipefail + TAG_NAME="${{ github.event.release.tag_name }}" + VERSION="${TAG_NAME#v}" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + CHANNEL="beta" + else + CHANNEL="stable" + fi + echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" + + - name: Snapshot updater feed state + id: state env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} @@ -202,15 +325,222 @@ jobs: run: | set -euo pipefail ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + VERSION_KEY="${CHANNEL}/versions/${VERSION}/latest.json" + SIGNATURE_KEY="${VERSION_KEY}.sig" + LATEST_KEY="${CHANNEL}/latest.json" - aws s3 cp \ - release-assets/latest.json \ - "s3://${R2_BUCKET}/${CHANNEL}/latest.json" \ + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${CHANNEL}/" \ --endpoint-url "${ENDPOINT}" \ - --cache-control "no-store" + > release-assets/r2-objects.json + + : > release-assets/history.txt + : > release-assets/revocations.txt + jq -r '.Contents[]?.Key' release-assets/r2-objects.json | + while IFS= read -r key; do + case "${key}" in + "${CHANNEL}"/versions/*/latest.json) + historical="${key#${CHANNEL}/versions/}" + historical="${historical%/latest.json}" + if [[ "${historical}" == */* || -z "${historical}" ]]; then + echo "::error title=Invalid updater history key::${key}" + exit 1 + fi + printf '%s\n' "${historical}" >> release-assets/history.txt + ;; + "${CHANNEL}"/revocations/*.json) + revoked="${key#${CHANNEL}/revocations/}" + revoked="${revoked%.json}" + if [[ "${revoked}" == */* || -z "${revoked}" ]]; then + echo "::error title=Invalid updater revocation key::${key}" + exit 1 + fi + printf '%s\n' "${revoked}" >> release-assets/revocations.txt + ;; + esac + done + LC_ALL=C sort -u -o release-assets/history.txt release-assets/history.txt + LC_ALL=C sort -u -o release-assets/revocations.txt release-assets/revocations.txt + + if jq -e --arg key "${VERSION_KEY}" '.Contents[]?.Key | select(. == $key)' release-assets/r2-objects.json >/dev/null; then + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${VERSION_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + release-assets/existing-version.json >/dev/null + cmp --silent release-assets/latest.json release-assets/existing-version.json || { + echo "::error title=Immutable updater version conflict::${VERSION_KEY} already contains different bytes." + exit 1 + } + grep -Fvx "${VERSION}" release-assets/history.txt > release-assets/history.next || true + mv release-assets/history.next release-assets/history.txt + echo "version_exists=true" >> "$GITHUB_OUTPUT" + else + echo "version_exists=false" >> "$GITHUB_OUTPUT" + fi - aws s3 cp \ - release-assets/latest.json \ - "s3://${R2_BUCKET}/${CHANNEL}/versions/${VERSION}/latest.json" \ + if jq -e --arg key "${SIGNATURE_KEY}" '.Contents[]?.Key | select(. == $key)' release-assets/r2-objects.json >/dev/null; then + if [[ ! -f release-assets/existing-version.json ]]; then + echo "::error title=Orphaned updater signature::${SIGNATURE_KEY} exists without ${VERSION_KEY}." + exit 1 + fi + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${SIGNATURE_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + release-assets/latest.json.sig >/dev/null + echo "signature_exists=true" >> "$GITHUB_OUTPUT" + else + echo "signature_exists=false" >> "$GITHUB_OUTPUT" + fi + + if jq -e --arg key "${LATEST_KEY}" '.Contents[]?.Key | select(. == $key)' release-assets/r2-objects.json >/dev/null; then + current_metadata="$(aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${LATEST_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + release-assets/current-latest.json)" + current_etag="$(jq -er '.ETag | select(type == "string" and length > 0)' <<<"${current_metadata}")" + echo "latest_exists=true" >> "$GITHUB_OUTPUT" + echo "latest_etag=${current_etag}" >> "$GITHUB_OUTPUT" + else + echo "latest_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Revalidate promotion state and manifest sidecar + env: + CHANNEL: ${{ steps.meta.outputs.channel }} + TAG_NAME: ${{ steps.meta.outputs.tag_name }} + REPO: ${{ github.repository }} + LATEST_EXISTS: ${{ steps.state.outputs.latest_exists }} + run: | + set -euo pipefail + current_args=() + if [[ "${LATEST_EXISTS}" == "true" ]]; then + current_args+=(--current-manifest release-assets/current-latest.json) + fi + release-assets/updater-release validate-promotion \ + --manifest release-assets/latest.json \ + --manifest-signature release-assets/latest.json.sig \ + --config release-assets/tauri.conf.json \ + --package-json release-assets/package.json \ + --repository "${REPO}" \ + --tag "${TAG_NAME}" \ + --channel "${CHANNEL}" \ + --history release-assets/history.txt \ + --revocations release-assets/revocations.txt \ + "${current_args[@]}" + + - name: Publish immutable history before the channel pointer + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + CHANNEL: ${{ steps.meta.outputs.channel }} + VERSION: ${{ steps.meta.outputs.version }} + VERSION_EXISTS: ${{ steps.state.outputs.version_exists }} + SIGNATURE_EXISTS: ${{ steps.state.outputs.signature_exists }} + LATEST_EXISTS: ${{ steps.state.outputs.latest_exists }} + LATEST_ETAG: ${{ steps.state.outputs.latest_etag }} + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + VERSION_KEY="${CHANNEL}/versions/${VERSION}/latest.json" + SIGNATURE_KEY="${VERSION_KEY}.sig" + LATEST_KEY="${CHANNEL}/latest.json" + REVOCATION_KEY="${CHANNEL}/revocations/${VERSION}.json" + + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${REVOCATION_KEY}" \ --endpoint-url "${ENDPOINT}" \ - --cache-control "public, max-age=300" + > release-assets/pre-promotion-objects.json + if jq -e --arg key "${REVOCATION_KEY}" '.Contents[]?.Key | select(. == $key)' release-assets/pre-promotion-objects.json >/dev/null; then + echo "::error title=Revoked updater release::${VERSION} has a durable rollout revocation marker and cannot be promoted again." + exit 1 + fi + + if [[ "${VERSION_EXISTS}" != "true" ]]; then + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${VERSION_KEY}" \ + --body release-assets/latest.json \ + --content-type application/json \ + --cache-control 'public,max-age=31536000,immutable' \ + --if-none-match '*' \ + --endpoint-url "${ENDPOINT}" >/dev/null + fi + if [[ "${SIGNATURE_EXISTS}" != "true" ]]; then + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${SIGNATURE_KEY}" \ + --body release-assets/latest.json.sig \ + --content-type text/plain \ + --cache-control 'public,max-age=31536000,immutable' \ + --if-none-match '*' \ + --endpoint-url "${ENDPOINT}" >/dev/null + fi + + aws s3api get-object --bucket "${R2_BUCKET}" --key "${VERSION_KEY}" --endpoint-url "${ENDPOINT}" release-assets/readback-version.json >/dev/null + aws s3api get-object --bucket "${R2_BUCKET}" --key "${SIGNATURE_KEY}" --endpoint-url "${ENDPOINT}" release-assets/readback-version.json.sig >/dev/null + cmp --silent release-assets/latest.json release-assets/readback-version.json + release-assets/updater-release verify-signature \ + --file release-assets/readback-version.json \ + --signature release-assets/readback-version.json.sig \ + --config release-assets/tauri.conf.json + readback_headers="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${VERSION_KEY}" --endpoint-url "${ENDPOINT}")" + signature_headers="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${SIGNATURE_KEY}" --endpoint-url "${ENDPOINT}")" + jq -e '.ContentType == "application/json" and .CacheControl == "public,max-age=31536000,immutable"' <<<"${readback_headers}" >/dev/null + jq -e '.ContentType == "text/plain" and .CacheControl == "public,max-age=31536000,immutable"' <<<"${signature_headers}" >/dev/null + + latest_args=(--bucket "${R2_BUCKET}" --key "${LATEST_KEY}" --body release-assets/latest.json --content-type application/json --cache-control 'no-store,must-revalidate' --endpoint-url "${ENDPOINT}") + if [[ "${LATEST_EXISTS}" == "true" ]]; then + latest_args+=(--if-match "${LATEST_ETAG}") + else + latest_args+=(--if-none-match '*') + fi + aws s3api put-object "${latest_args[@]}" >/dev/null + aws s3api get-object --bucket "${R2_BUCKET}" --key "${LATEST_KEY}" --endpoint-url "${ENDPOINT}" release-assets/readback-latest.json >/dev/null + cmp --silent release-assets/latest.json release-assets/readback-latest.json + latest_headers="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${LATEST_KEY}" --endpoint-url "${ENDPOINT}")" + jq -e '.ContentType == "application/json" and .CacheControl == "no-store,must-revalidate"' <<<"${latest_headers}" >/dev/null + + client_url="https://release.gitru.app/${CHANNEL}/latest.json" + client_converged=false + for attempt in {1..12}; do + if curl --fail --silent --show-error --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 30 \ + --dump-header release-assets/client-headers.txt --output release-assets/client-latest.json "${client_url}"; then + content_type="$(awk 'BEGIN { IGNORECASE=1 } /^content-type:/ { sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0 } END { print value }' release-assets/client-headers.txt)" + cache_control="$(awk 'BEGIN { IGNORECASE=1 } /^cache-control:/ { sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0 } END { print value }' release-assets/client-headers.txt)" + normalized_cache="${cache_control//[[:space:]]/}" + normalized_cache="${normalized_cache,,}" + if cmp --silent release-assets/latest.json release-assets/client-latest.json \ + && [[ "${content_type,,}" == application/json* ]] \ + && [[ ",${normalized_cache}," == *,no-store,* ]] \ + && [[ ",${normalized_cache}," == *,must-revalidate,* ]]; then + client_converged=true + break + fi + fi + echo "Waiting for ${client_url} to converge (attempt ${attempt}/12)..." + sleep 5 + done + if [[ "${client_converged}" != "true" ]]; then + echo "::error title=Updater client endpoint did not converge::${client_url} did not serve the promoted bytes and no-store policy." + exit 1 + fi + + - name: Record updater integrity evidence + run: | + { + echo '## Updater integrity verification' + echo + jq -r '"- Channel: `\(.channel)`\n- Version: `\(.version)`\n- Manifest SHA-256: `\(.manifest_sha256)`\n- Unique signed artifacts: `\(.artifacts | length)`"' release-assets/validation-report.json + echo + echo '| Targets | Bytes | SHA-256 |' + echo '| --- | ---: | --- |' + jq -r '.artifacts[] | "| \(.targets | join(", ")) | \(.bytes) | `\(.sha256)` |"' release-assets/validation-report.json + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/updater-feed-control.yml b/.github/workflows/updater-feed-control.yml new file mode 100644 index 0000000..0bbbbc5 --- /dev/null +++ b/.github/workflows/updater-feed-control.yml @@ -0,0 +1,432 @@ +name: updater feed control + +on: + workflow_dispatch: + inputs: + channel: + description: Feed to stop + required: true + type: choice + options: + - stable + - beta + expected_current_version: + description: Version currently served by latest.json (compare-and-swap guard) + required: true + type: string + fallback_version: + description: Older known-good immutable version to serve to clients that have not updated + required: true + type: string + fallback_manifest_policy: + description: Require its audit sidecar, or explicitly allow a pre-sidecar historical manifest + required: true + type: choice + options: + - REQUIRE_SIDECAR + - ALLOW_LEGACY_WITHOUT_SIDECAR + reason: + description: Incident or reason for stopping rollout + required: true + type: string + confirmation: + description: Use corrupt mode only when current latest.json fails strict validation + required: true + type: choice + options: + - STOP_ROLLOUT + - STOP_CORRUPT_ROLLOUT + +concurrency: + group: updater-feed-${{ inputs.channel }} + queue: max + cancel-in-progress: false + +permissions: + contents: read + +jobs: + stop-rollout: + name: Stop ${{ inputs.channel }} rollout + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: updater-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + uses: ./.github/actions/setup-rust + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: . -> target + + - name: Validate operator inputs + env: + CURRENT_VERSION: ${{ inputs.expected_current_version }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + CONFIRMATION: ${{ inputs.confirmation }} + FALLBACK_MANIFEST_POLICY: ${{ inputs.fallback_manifest_policy }} + run: | + set -euo pipefail + [[ "${CONFIRMATION}" == "STOP_ROLLOUT" || "${CONFIRMATION}" == "STOP_CORRUPT_ROLLOUT" ]] + [[ "${FALLBACK_MANIFEST_POLICY}" == "REQUIRE_SIDECAR" || "${FALLBACK_MANIFEST_POLICY}" == "ALLOW_LEGACY_WITHOUT_SIDECAR" ]] + [[ "${CURRENT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]] + [[ "${FALLBACK_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]] + + - name: Fetch current and known-good immutable manifests + id: state + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + CHANNEL: ${{ inputs.channel }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + run: | + set -euo pipefail + mkdir -p feed-control + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + LATEST_KEY="${CHANNEL}/latest.json" + FALLBACK_KEY="${CHANNEL}/versions/${FALLBACK_VERSION}/latest.json" + FALLBACK_REVOCATION_KEY="${CHANNEL}/revocations/${FALLBACK_VERSION}.json" + + current_metadata="$(aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${LATEST_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/current.json)" + current_etag="$(jq -er '.ETag | select(type == "string" and length > 0)' <<<"${current_metadata}")" + test -n "${current_etag}" + echo "current_etag=${current_etag}" >> "$GITHUB_OUTPUT" + + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${FALLBACK_REVOCATION_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + > feed-control/fallback-revocations.json + if jq -e --arg key "${FALLBACK_REVOCATION_KEY}" \ + '.Contents[]?.Key | select(. == $key)' \ + feed-control/fallback-revocations.json >/dev/null; then + echo "::error title=Fallback version is revoked::${FALLBACK_VERSION} has a durable revocation marker and cannot be restored." + exit 1 + fi + + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${FALLBACK_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/fallback.json >/dev/null + + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${FALLBACK_KEY}.sig" \ + --endpoint-url "${ENDPOINT}" \ + > feed-control/fallback-objects.json + if jq -e --arg key "${FALLBACK_KEY}.sig" \ + '.Contents[]?.Key | select(. == $key)' \ + feed-control/fallback-objects.json >/dev/null; then + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${FALLBACK_KEY}.sig" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/fallback.json.sig >/dev/null + echo "fallback_signature_exists=true" >> "$GITHUB_OUTPUT" + else + echo "fallback_signature_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify stop plan, signed history, and updater artifacts + id: validation + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_VERSION: ${{ inputs.expected_current_version }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + REPO: ${{ github.repository }} + FALLBACK_SIGNATURE_EXISTS: ${{ steps.state.outputs.fallback_signature_exists }} + FALLBACK_MANIFEST_POLICY: ${{ inputs.fallback_manifest_policy }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + signature_args=() + if [[ "${FALLBACK_SIGNATURE_EXISTS}" == "true" ]]; then + signature_args+=(--fallback-signature feed-control/fallback.json.sig) + else + if [[ "${FALLBACK_MANIFEST_POLICY}" != "ALLOW_LEGACY_WITHOUT_SIDECAR" ]]; then + echo "::error title=Historical manifest sidecar missing::Select ALLOW_LEGACY_WITHOUT_SIDECAR only for a known pre-policy fallback." + exit 1 + fi + signature_args+=(--allow-legacy-manifest-without-sidecar true) + fi + cargo run --quiet -p updater-release -- validate-stop \ + --current-manifest feed-control/current.json \ + --fallback-manifest feed-control/fallback.json \ + "${signature_args[@]}" \ + --config apps/desktop/src-tauri/tauri.conf.json \ + --repository "${REPO}" \ + --channel "${CHANNEL}" \ + --expected-current-version "${CURRENT_VERSION}" \ + --expected-fallback-version "${FALLBACK_VERSION}" \ + --confirmation "${CONFIRMATION}" \ + --stop-state feed-control/stop-state.json \ + --download-directory feed-control/artifacts \ + --report feed-control/validation-report.json + + current_manifest_valid="$(jq -er '.current_manifest_valid | tostring' feed-control/stop-state.json)" + current_manifest_sha256="$(jq -er '.current_manifest_sha256 | select(type == "string" and length == 64)' feed-control/stop-state.json)" + echo "current_manifest_valid=${current_manifest_valid}" >> "$GITHUB_OUTPUT" + echo "current_manifest_sha256=${current_manifest_sha256}" >> "$GITHUB_OUTPUT" + if [[ "${current_manifest_valid}" != "true" ]]; then + echo "::warning title=Replacing corrupt updater pointer::The exact ETag-guarded current bytes failed strict validation; their SHA-256 is ${current_manifest_sha256}." + fi + + - name: Setup Bun for legacy sidecar backfill + if: steps.state.outputs.fallback_signature_exists != 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Cache Bun dependencies for legacy sidecar backfill + if: steps.state.outputs.fallback_signature_exists != 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install frontend dependencies for legacy sidecar backfill + if: steps.state.outputs.fallback_signature_exists != 'true' + run: bun install --frozen-lockfile + + - name: Backfill verified legacy manifest sidecar + if: steps.state.outputs.fallback_signature_exists != 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + CHANNEL: ${{ inputs.channel }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + SIGNATURE_KEY="${CHANNEL}/versions/${FALLBACK_VERSION}/latest.json.sig" + + bun --cwd=apps/desktop run tauri signer sign ../../feed-control/fallback.json + cargo run --quiet -p updater-release -- verify-signature \ + --file feed-control/fallback.json \ + --signature feed-control/fallback.json.sig \ + --config apps/desktop/src-tauri/tauri.conf.json + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${SIGNATURE_KEY}" \ + --body feed-control/fallback.json.sig \ + --content-type text/plain \ + --cache-control 'public,max-age=31536000,immutable' \ + --if-none-match '*' \ + --endpoint-url "${ENDPOINT}" >/dev/null + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${SIGNATURE_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/fallback-readback.json.sig >/dev/null + cmp --silent feed-control/fallback.json.sig feed-control/fallback-readback.json.sig + cargo run --quiet -p updater-release -- verify-signature \ + --file feed-control/fallback.json \ + --signature feed-control/fallback-readback.json.sig \ + --config apps/desktop/src-tauri/tauri.conf.json + signature_headers="$(aws s3api head-object \ + --bucket "${R2_BUCKET}" \ + --key "${SIGNATURE_KEY}" \ + --endpoint-url "${ENDPOINT}")" + jq -e \ + '.ContentType == "text/plain" and .CacheControl == "public,max-age=31536000,immutable"' \ + <<<"${signature_headers}" >/dev/null + + - name: Persist revocation and compare-and-swap the channel pointer + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + CHANNEL: ${{ inputs.channel }} + CURRENT_ETAG: ${{ steps.state.outputs.current_etag }} + CURRENT_VERSION: ${{ inputs.expected_current_version }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + REASON: ${{ inputs.reason }} + CURRENT_MANIFEST_SHA256: ${{ steps.validation.outputs.current_manifest_sha256 }} + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + LATEST_KEY="${CHANNEL}/latest.json" + REVOCATION_KEY="${CHANNEL}/revocations/${CURRENT_VERSION}.json" + EVIDENCE_KEY="${CHANNEL}/revocation-evidence/${CURRENT_VERSION}/${CURRENT_MANIFEST_SHA256}.bin" + + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${EVIDENCE_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + > feed-control/evidence-objects.json + if ! jq -e --arg key "${EVIDENCE_KEY}" \ + '.Contents[]?.Key | select(. == $key)' \ + feed-control/evidence-objects.json >/dev/null; then + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${EVIDENCE_KEY}" \ + --body feed-control/current.json \ + --content-type application/octet-stream \ + --cache-control 'public,max-age=31536000,immutable' \ + --if-none-match '*' \ + --endpoint-url "${ENDPOINT}" >/dev/null + fi + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${EVIDENCE_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/current-evidence-readback.bin >/dev/null + cmp --silent feed-control/current.json feed-control/current-evidence-readback.bin + evidence_sha256="$(sha256sum feed-control/current-evidence-readback.bin | awk '{print $1}')" + [[ "${evidence_sha256}" == "${CURRENT_MANIFEST_SHA256}" ]] + evidence_headers="$(aws s3api head-object \ + --bucket "${R2_BUCKET}" \ + --key "${EVIDENCE_KEY}" \ + --endpoint-url "${ENDPOINT}")" + jq -e \ + '.ContentType == "application/octet-stream" and .CacheControl == "public,max-age=31536000,immutable"' \ + <<<"${evidence_headers}" >/dev/null + + jq -n \ + --arg channel "${CHANNEL}" \ + --arg revoked_version "${CURRENT_VERSION}" \ + --arg fallback_version "${FALLBACK_VERSION}" \ + --arg current_manifest_sha256 "${CURRENT_MANIFEST_SHA256}" \ + --arg current_evidence_key "${EVIDENCE_KEY}" \ + --arg reason "${REASON}" \ + '{schema: 1, channel: $channel, revoked_version: $revoked_version, fallback_version: $fallback_version, current_manifest_sha256: $current_manifest_sha256, current_evidence_key: $current_evidence_key, reason: $reason}' \ + > feed-control/revocation.json + + aws s3api list-objects-v2 \ + --bucket "${R2_BUCKET}" \ + --prefix "${REVOCATION_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + > feed-control/revocation-objects.json + if jq -e --arg key "${REVOCATION_KEY}" \ + '.Contents[]?.Key | select(. == $key)' \ + feed-control/revocation-objects.json >/dev/null; then + : + else + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${REVOCATION_KEY}" \ + --body feed-control/revocation.json \ + --content-type application/json \ + --cache-control 'public,max-age=31536000,immutable' \ + --if-none-match '*' \ + --endpoint-url "${ENDPOINT}" >/dev/null + fi + + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${REVOCATION_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/revocation-readback.json >/dev/null + + jq -e \ + --arg channel "${CHANNEL}" \ + --arg version "${CURRENT_VERSION}" \ + '.schema == 1 and .channel == $channel and .revoked_version == $version' \ + feed-control/revocation-readback.json >/dev/null + revocation_headers="$(aws s3api head-object \ + --bucket "${R2_BUCKET}" \ + --key "${REVOCATION_KEY}" \ + --endpoint-url "${ENDPOINT}")" + jq -e \ + '.ContentType == "application/json" and .CacheControl == "public,max-age=31536000,immutable"' \ + <<<"${revocation_headers}" >/dev/null + + aws s3api put-object \ + --bucket "${R2_BUCKET}" \ + --key "${LATEST_KEY}" \ + --body feed-control/fallback.json \ + --content-type application/json \ + --cache-control 'no-store,must-revalidate' \ + --if-match "${CURRENT_ETAG}" \ + --endpoint-url "${ENDPOINT}" >/dev/null + + aws s3api get-object \ + --bucket "${R2_BUCKET}" \ + --key "${LATEST_KEY}" \ + --endpoint-url "${ENDPOINT}" \ + feed-control/readback.json >/dev/null + cmp --silent feed-control/fallback.json feed-control/readback.json + headers="$(aws s3api head-object \ + --bucket "${R2_BUCKET}" \ + --key "${LATEST_KEY}" \ + --endpoint-url "${ENDPOINT}")" + jq -e \ + '.ContentType == "application/json" and .CacheControl == "no-store,must-revalidate"' \ + <<<"${headers}" >/dev/null + + client_url="https://release.gitru.app/${CHANNEL}/latest.json" + client_converged=false + for attempt in {1..12}; do + if curl --fail --silent --show-error \ + --proto '=https' \ + --proto-redir '=https' \ + --connect-timeout 10 \ + --max-time 30 \ + --dump-header feed-control/client-headers.txt \ + --output feed-control/client-latest.json \ + "${client_url}"; then + content_type="$(awk 'BEGIN { IGNORECASE=1 } /^content-type:/ { sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0 } END { print value }' feed-control/client-headers.txt)" + cache_control="$(awk 'BEGIN { IGNORECASE=1 } /^cache-control:/ { sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0 } END { print value }' feed-control/client-headers.txt)" + normalized_cache="${cache_control//[[:space:]]/}" + normalized_cache="${normalized_cache,,}" + if cmp --silent feed-control/fallback.json feed-control/client-latest.json \ + && [[ "${content_type,,}" == application/json* ]] \ + && [[ ",${normalized_cache}," == *,no-store,* ]] \ + && [[ ",${normalized_cache}," == *,must-revalidate,* ]]; then + client_converged=true + break + fi + fi + echo "Waiting for ${client_url} to converge (attempt ${attempt}/12)..." + sleep 5 + done + if [[ "${client_converged}" != "true" ]]; then + echo "::error title=Updater client endpoint did not converge::${client_url} did not serve the fallback bytes and no-store policy." + exit 1 + fi + + - name: Record stop-rollout evidence + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_VERSION: ${{ inputs.expected_current_version }} + FALLBACK_VERSION: ${{ inputs.fallback_version }} + REASON: ${{ inputs.reason }} + FALLBACK_SIGNATURE_EXISTS: ${{ steps.state.outputs.fallback_signature_exists }} + CURRENT_MANIFEST_VALID: ${{ steps.validation.outputs.current_manifest_valid }} + CURRENT_MANIFEST_SHA256: ${{ steps.validation.outputs.current_manifest_sha256 }} + run: | + { + echo '## Updater rollout stopped' + echo + printf '%s\n' "- Channel: \`${CHANNEL}\`" + printf '%s\n' "- Previous pointer: \`${CURRENT_VERSION}\`" + printf '%s\n' "- Known-good pointer: \`${FALLBACK_VERSION}\`" + printf '%s\n' "- Historical manifest sidecar present: \`${FALLBACK_SIGNATURE_EXISTS}\`" + printf '%s\n' "- Previous pointer passed strict validation: \`${CURRENT_MANIFEST_VALID}\`" + printf '%s\n' "- Previous pointer SHA-256: \`${CURRENT_MANIFEST_SHA256}\`" + printf '%s\n' "- Preserved pointer bytes: \`${CHANNEL}/revocation-evidence/${CURRENT_VERSION}/${CURRENT_MANIFEST_SHA256}.bin\`" + printf '%s\n' "- Durable revocation marker: \`${CHANNEL}/revocations/${CURRENT_VERSION}.json\`" + printf '%s\n' "- Reason: ${REASON}" + echo + echo '> This prevents more clients from receiving the bad version. It does not downgrade clients that already installed it; publish a new higher-version forward recovery next.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index 886d8de..c1651cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1472,6 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3896,6 +3897,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -5624,6 +5626,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "updater-release" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "minisign-verify", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "sha2", + "time", + "url", +] + [[package]] name = "ureq" version = "3.4.2" diff --git a/Cargo.toml b/Cargo.toml index 2c6db44..7d5becd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,5 @@ resolver = "2" members = [ - "apps/desktop/src-tauri", "crates/git", "crates/ipc", "crates/logger", + "apps/desktop/src-tauri", "crates/git", "crates/ipc", "crates/logger", "crates/updater-release", ] diff --git a/apps/desktop/src-tauri/src/commands/updater.rs b/apps/desktop/src-tauri/src/commands/updater.rs index 985b701..4d0f0f1 100644 --- a/apps/desktop/src-tauri/src/commands/updater.rs +++ b/apps/desktop/src-tauri/src/commands/updater.rs @@ -7,8 +7,11 @@ use tauri::Emitter; use tauri_plugin_updater::UpdaterExt; use url::Url; -const DEFAULT_UPDATER_BASE_URL: &str = "https://release.gitru.app"; +const PRODUCTION_UPDATER_BASE_URL: &str = "https://release.gitru.app"; const PRODUCTION_IDENTIFIER: &str = "com.ruru.gitru"; +const RELEASE_HOST: &str = "github.com"; +const RELEASE_OWNER: &str = "ruru-m07"; +const RELEASE_REPOSITORY: &str = "gitru"; #[derive(Serialize)] pub struct UpdateCheckResponse { @@ -42,11 +45,206 @@ fn normalize_channel(channel: &str) -> Result<&str, String> { fn endpoint_for_channel(channel: &str) -> Result { let channel = normalize_channel(channel)?; - let base = std::env::var("UPDATER_BASE_URL") - .unwrap_or_else(|_| DEFAULT_UPDATER_BASE_URL.to_string()) - .trim_end_matches('/') - .to_string(); - Ok(format!("{base}/{channel}/latest.json")) + Ok(format!( + "{PRODUCTION_UPDATER_BASE_URL}/{channel}/latest.json" + )) +} + +fn validate_numeric_identifier(identifier: &str, label: &str) -> Result<(), String> { + if identifier.is_empty() || !identifier.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("Update {label} must contain only ASCII digits")); + } + if identifier.len() > 1 && identifier.starts_with('0') { + return Err(format!("Update {label} must not contain a leading zero")); + } + Ok(()) +} + +fn validate_core_version(version: &str) -> Result<(), String> { + let components = version.split('.').collect::>(); + if components.len() != 3 { + return Err("Update version must contain exactly major.minor.patch".to_string()); + } + + for (component, label) in components.iter().zip(["major", "minor", "patch"]) { + validate_numeric_identifier(component, label)?; + } + Ok(()) +} + +fn validate_version_for_channel(channel: &str, version: &str) -> Result<(), String> { + let channel = normalize_channel(channel)?; + if version.contains('+') { + return Err("Update versions must not contain build metadata".to_string()); + } + + match channel { + "stable" => { + if version.contains('-') { + return Err("Stable update versions must not contain a prerelease".to_string()); + } + validate_core_version(version) + } + "beta" => { + let (core, beta_number) = version.split_once("-beta.").ok_or_else(|| { + "Beta update versions must use the major.minor.patch-beta.N form".to_string() + })?; + if beta_number.contains('.') || beta_number.contains('-') { + return Err("Beta update versions must use one numeric beta identifier".to_string()); + } + validate_core_version(core)?; + validate_numeric_identifier(beta_number, "beta number") + } + _ => unreachable!("normalize_channel accepts only stable or beta"), + } +} + +fn runtime_manifest_target(target: &str) -> Result { + if target.contains('-') { + return Ok(target.to_string()); + } + + let architecture = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + architecture => { + return Err(format!( + "Updates are not published for runtime architecture '{architecture}'" + )); + } + }; + let installer = match tauri::utils::platform::bundle_type() { + Some(tauri::utils::config::BundleType::AppImage) => "appimage", + Some(tauri::utils::config::BundleType::Deb) => "deb", + Some(tauri::utils::config::BundleType::Rpm) => "rpm", + Some(tauri::utils::config::BundleType::App) => "app", + Some(tauri::utils::config::BundleType::Nsis) => "nsis", + Some(bundle_type) => { + return Err(format!( + "Updates are not published for runtime bundle type '{bundle_type}'" + )); + } + None => return Err("Could not determine the runtime bundle type".to_string()), + }; + + Ok(format!("{target}-{architecture}-{installer}")) +} + +fn expected_asset_suffix(target: &str) -> Result<&'static str, String> { + match target { + "linux-x86_64" | "linux-x86_64-appimage" => Ok(".AppImage"), + "linux-x86_64-deb" => Ok(".deb"), + "linux-x86_64-rpm" => Ok(".rpm"), + "darwin-aarch64" | "darwin-x86_64" | "darwin-aarch64-app" | "darwin-x86_64-app" => { + Ok(".app.tar.gz") + } + "windows-x86_64" | "windows-x86_64-nsis" => Ok("_x64-setup.exe"), + _ => Err(format!( + "Updater target '{target}' is not published by Gitru" + )), + } +} + +fn validate_download_url(version: &str, target: &str, download_url: &Url) -> Result<(), String> { + if download_url.scheme() != "https" + || download_url.host_str() != Some(RELEASE_HOST) + || !download_url.username().is_empty() + || download_url.password().is_some() + || download_url.port().is_some() + || download_url.query().is_some() + || download_url.fragment().is_some() + { + return Err("Update download URL must use the canonical GitHub release origin".to_string()); + } + if download_url.as_str().contains('%') { + return Err("Update download URL must not contain percent-encoding".to_string()); + } + + let segments = download_url + .path_segments() + .ok_or_else(|| "Update download URL must contain a release asset path".to_string())? + .collect::>(); + let expected_tag = format!("v{version}"); + if segments.len() != 6 + || segments[0] != RELEASE_OWNER + || segments[1] != RELEASE_REPOSITORY + || segments[2] != "releases" + || segments[3] != "download" + || segments[4] != expected_tag + || segments[5].is_empty() + { + return Err(format!( + "Update download URL must point to the v{version} Gitru release" + )); + } + + let target = runtime_manifest_target(target)?; + let expected_suffix = expected_asset_suffix(&target)?; + if !segments[5].ends_with(expected_suffix) { + return Err(format!( + "Update asset for target '{target}' must end with '{expected_suffix}'" + )); + } + Ok(()) +} + +fn validate_raw_manifest_candidate( + version: &str, + download_url: &Url, + raw_manifest: &serde_json::Value, +) -> Result<(), String> { + let raw_version = raw_manifest + .get("version") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Update manifest must contain a string version".to_string())?; + if raw_version != version { + return Err("Update manifest version must use its exact canonical form".to_string()); + } + + let mut matching_raw_urls = Vec::new(); + if let Some(url) = raw_manifest.get("url").and_then(serde_json::Value::as_str) { + matching_raw_urls.push(url); + } + if let Some(platforms) = raw_manifest + .get("platforms") + .and_then(serde_json::Value::as_object) + { + matching_raw_urls.extend( + platforms + .values() + .filter_map(|platform| platform.get("url").and_then(serde_json::Value::as_str)), + ); + } + + let mut found_candidate = false; + for raw_url in matching_raw_urls { + let parsed = Url::parse(raw_url) + .map_err(|error| format!("Invalid update URL in raw manifest: {error}"))?; + if parsed == *download_url { + found_candidate = true; + if raw_url != download_url.as_str() { + return Err( + "Update download URL must not rely on URL parser normalization".to_string(), + ); + } + } + } + if !found_candidate { + return Err("Selected update URL was not present in the raw manifest".to_string()); + } + Ok(()) +} + +fn validate_update_candidate( + channel: &str, + version: &str, + target: &str, + download_url: &Url, + raw_manifest: &serde_json::Value, +) -> Result<(), String> { + validate_version_for_channel(channel, version)?; + validate_download_url(version, target, download_url)?; + validate_raw_manifest_candidate(version, download_url, raw_manifest) } fn ensure_production_identity(identifier: &str) -> Result<(), String> { @@ -83,6 +281,13 @@ pub async fn check_for_update_by_channel( .map_err(|e| format!("Failed to check for updates: {e}"))?; if let Some(update) = update { + validate_update_candidate( + &channel, + &update.version, + &update.target, + &update.download_url, + &update.raw_json, + )?; return Ok(UpdateCheckResponse { available: true, channel, @@ -129,6 +334,13 @@ pub async fn download_and_install_update_by_channel( let Some(update) = update else { return Ok("No update available".to_string()); }; + validate_update_candidate( + &channel, + &update.version, + &update.target, + &update.download_url, + &update.raw_json, + )?; let next_version = update.version.clone(); let version = Some(next_version.clone()); @@ -216,7 +428,170 @@ pub async fn download_and_install_update_by_channel( #[cfg(test)] mod tests { - use super::{ensure_production_identity, PRODUCTION_IDENTIFIER}; + use super::{ + endpoint_for_channel, ensure_production_identity, validate_download_url, + validate_raw_manifest_candidate, validate_version_for_channel, PRODUCTION_IDENTIFIER, + }; + use serde_json::json; + use url::Url; + + fn release_url(version: &str, asset: &str) -> Url { + Url::parse(&format!( + "https://github.com/ruru-m07/gitru/releases/download/v{version}/{asset}" + )) + .expect("test release URL must parse") + } + + #[test] + fn updater_endpoints_are_pinned_by_channel() { + assert_eq!( + endpoint_for_channel("stable").unwrap(), + "https://release.gitru.app/stable/latest.json" + ); + assert_eq!( + endpoint_for_channel(" BETA ").unwrap(), + "https://release.gitru.app/beta/latest.json" + ); + assert!(endpoint_for_channel("../beta").is_err()); + assert!(endpoint_for_channel("nightly").is_err()); + } + + #[test] + fn updater_versions_are_isolated_by_channel() { + assert!(validate_version_for_channel("stable", "1.2.3").is_ok()); + assert!(validate_version_for_channel("beta", "1.2.3-beta.0").is_ok()); + + for version in [ + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2", + "1.2.3-beta.1", + "1.2.3+build.1", + ] { + assert!( + validate_version_for_channel("stable", version).is_err(), + "stable unexpectedly accepted {version}" + ); + } + for version in [ + "1.2.3", + "1.2.3-beta", + "1.2.3-beta.01", + "1.2.3-beta.1.2", + "1.2.3-alpha.1", + "1.2.3-beta.1+build.1", + ] { + assert!( + validate_version_for_channel("beta", version).is_err(), + "beta unexpectedly accepted {version}" + ); + } + } + + #[test] + fn updater_download_urls_match_release_version_target_and_asset() { + let cases = [ + ("linux-x86_64-appimage", "Gitru_1.2.3-beta.4_amd64.AppImage"), + ("linux-x86_64", "Gitru_1.2.3-beta.4_amd64.AppImage"), + ("linux-x86_64-deb", "Gitru_1.2.3-beta.4_amd64.deb"), + ("linux-x86_64-rpm", "Gitru-1.2.3-beta.4-1.x86_64.rpm"), + ("darwin-aarch64-app", "Gitru_universal.app.tar.gz"), + ("darwin-x86_64", "Gitru_universal.app.tar.gz"), + ("windows-x86_64-nsis", "Gitru_1.2.3-beta.4_x64-setup.exe"), + ("windows-x86_64", "Gitru_1.2.3-beta.4_x64-setup.exe"), + ]; + + for (target, asset) in cases { + assert!( + validate_download_url("1.2.3-beta.4", target, &release_url("1.2.3-beta.4", asset)) + .is_ok(), + "valid target {target} was rejected" + ); + } + } + + #[test] + fn updater_download_urls_reject_untrusted_locations_and_shapes() { + let invalid_urls = [ + "http://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "https://github.example/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "https://user@github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "https://github.com:444/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "https://github.com/ruru-m07/other/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.4/Gitru_1.2.3_amd64.AppImage", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage?download=1", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage#fragment", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/%2e%2e%2fasset.AppImage", + ]; + for raw_url in invalid_urls { + let url = Url::parse(raw_url).expect("invalid policy URL should still parse"); + assert!( + validate_download_url("1.2.3", "linux-x86_64-appimage", &url).is_err(), + "untrusted URL unexpectedly accepted: {raw_url}" + ); + } + + assert!(validate_download_url( + "1.2.3", + "linux-x86_64-appimage", + &release_url("1.2.3", "Gitru_1.2.3_amd64.deb") + ) + .is_err()); + } + + #[test] + fn updater_rejects_raw_manifest_url_normalization() { + let candidate = release_url("1.2.3", "Gitru_1.2.3_amd64.AppImage"); + let canonical = json!({ + "version": "1.2.3", + "platforms": { + "linux-x86_64-appimage": { + "url": candidate.as_str(), + "signature": "fixture" + } + } + }); + assert!(validate_raw_manifest_candidate("1.2.3", &candidate, &canonical).is_ok()); + + let explicit_default_port = json!({ + "version": "1.2.3", + "platforms": { + "linux-x86_64-appimage": { + "url": "https://github.com:443/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.AppImage", + "signature": "fixture" + } + } + }); + assert!( + validate_raw_manifest_candidate("1.2.3", &candidate, &explicit_default_port).is_err() + ); + + let traversal_path = json!({ + "version": "1.2.3", + "platforms": { + "linux-x86_64-appimage": { + "url": "https://github.com/ruru-m07/gitru/releases/download/ignored/../v1.2.3/Gitru_1.2.3_amd64.AppImage", + "signature": "fixture" + } + } + }); + assert!(validate_raw_manifest_candidate("1.2.3", &candidate, &traversal_path).is_err()); + + let noncanonical_version = json!({ + "version": "v1.2.3", + "platforms": { + "linux-x86_64-appimage": { + "url": candidate.as_str(), + "signature": "fixture" + } + } + }); + assert!( + validate_raw_manifest_candidate("1.2.3", &candidate, &noncanonical_version).is_err() + ); + } #[test] fn updater_accepts_only_the_production_identity() { diff --git a/crates/updater-release/Cargo.toml b/crates/updater-release/Cargo.toml new file mode 100644 index 0000000..7c7e3f8 --- /dev/null +++ b/crates/updater-release/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "updater-release" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +base64 = "0.22" +minisign-verify = "0.2" +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls-no-provider"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +time = { version = "0.3", features = ["parsing"] } +url = "2" diff --git a/crates/updater-release/src/lib.rs b/crates/updater-release/src/lib.rs new file mode 100644 index 0000000..409ffb2 --- /dev/null +++ b/crates/updater-release/src/lib.rs @@ -0,0 +1,1204 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use minisign_verify::{PublicKey, Signature}; +use reqwest::blocking::Client; +use semver::Version; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + str::FromStr, + time::Duration, +}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use url::Url; + +pub const PRODUCTION_IDENTIFIER: &str = "com.ruru.gitru"; + +const REQUIRED_TARGETS: [&str; 6] = [ + "linux-x86_64-appimage", + "linux-x86_64-deb", + "linux-x86_64-rpm", + "darwin-aarch64-app", + "darwin-x86_64-app", + "windows-x86_64-nsis", +]; + +const OPTIONAL_ALIASES: [(&str, &str); 4] = [ + ("linux-x86_64", "linux-x86_64-appimage"), + ("darwin-aarch64", "darwin-aarch64-app"), + ("darwin-x86_64", "darwin-x86_64-app"), + ("windows-x86_64", "windows-x86_64-nsis"), +]; + +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Channel { + Stable, + Beta, +} + +impl Channel { + pub fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } + + pub fn accepts(self, version: &Version) -> bool { + if !version.build.is_empty() { + return false; + } + + match self { + Self::Stable => version.pre.is_empty(), + Self::Beta => { + let prerelease = version.pre.as_str(); + let Some(sequence) = prerelease.strip_prefix("beta.") else { + return false; + }; + !sequence.is_empty() + && sequence.bytes().all(|byte| byte.is_ascii_digit()) + && (sequence == "0" || !sequence.starts_with('0')) + } + } + } +} + +impl FromStr for Channel { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "stable" => Ok(Self::Stable), + "beta" => Ok(Self::Beta), + _ => Err(format!( + "invalid updater channel {value:?}; expected stable or beta" + )), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PlatformEntry { + pub url: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub version: String, + #[serde(default)] + pub notes: Option, + pub pub_date: String, + pub platforms: BTreeMap, +} + +#[derive(Clone, Debug)] +pub struct ManifestPolicy<'a> { + pub repository: &'a str, + pub channel: Channel, +} + +#[derive(Clone, Debug)] +pub struct ValidatedArtifact { + pub url: Url, + pub signature: String, + pub asset_name: String, + pub targets: Vec, +} + +#[derive(Clone, Debug)] +pub struct ValidatedManifest { + pub manifest: Manifest, + pub version: Version, + pub channel: Channel, + pub artifacts: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ArtifactReport { + pub url: String, + pub targets: Vec, + pub bytes: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ValidationReport { + pub version: String, + pub channel: Channel, + pub manifest_sha256: String, + pub artifacts: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StopPlanState { + pub current_manifest_valid: bool, + pub current_manifest_sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +pub fn parse_manifest(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|error| format!("invalid updater manifest JSON: {error}")) +} + +pub fn validate_manifest( + manifest: Manifest, + policy: &ManifestPolicy<'_>, +) -> Result { + validate_repository(policy.repository)?; + let version = Version::parse(&manifest.version) + .map_err(|error| format!("manifest version is not strict SemVer: {error}"))?; + if version.to_string() != manifest.version { + return Err(format!( + "manifest version must use canonical SemVer without a leading v: {}", + manifest.version + )); + } + if !policy.channel.accepts(&version) { + return Err(format!( + "manifest version {} does not belong to the {} channel", + version, + policy.channel.as_str() + )); + } + + OffsetDateTime::parse(&manifest.pub_date, &Rfc3339) + .map_err(|error| format!("manifest pub_date is not RFC 3339: {error}"))?; + + let allowed_targets: BTreeSet<&str> = REQUIRED_TARGETS + .iter() + .copied() + .chain(OPTIONAL_ALIASES.iter().map(|(alias, _)| *alias)) + .collect(); + + for required in REQUIRED_TARGETS { + if !manifest.platforms.contains_key(required) { + return Err(format!("manifest is missing required target {required}")); + } + } + + for target in manifest.platforms.keys() { + if !allowed_targets.contains(target.as_str()) { + return Err(format!("manifest contains unsupported target {target}")); + } + } + + for (alias, canonical) in OPTIONAL_ALIASES { + if let Some(alias_entry) = manifest.platforms.get(alias) { + let canonical_entry = manifest + .platforms + .get(canonical) + .expect("required canonical target checked above"); + if alias_entry != canonical_entry { + return Err(format!( + "target alias {alias} must exactly match {canonical}" + )); + } + } + } + + let mut unique = BTreeMap::)>::new(); + for (target, entry) in &manifest.platforms { + decode_signature(&entry.signature) + .map_err(|error| format!("invalid signature for target {target}: {error}"))?; + let (url, asset_name) = validate_artifact_url(policy.repository, &version, &entry.url) + .map_err(|error| format!("invalid URL for target {target}: {error}"))?; + validate_asset_type(target, &asset_name)?; + + match unique.get_mut(url.as_str()) { + Some((existing_signature, _, targets)) => { + if existing_signature != &entry.signature { + return Err(format!( + "artifact {} is referenced with different signatures", + url + )); + } + targets.push(target.clone()); + } + None => { + unique.insert( + url.to_string(), + (entry.signature.clone(), asset_name, vec![target.clone()]), + ); + } + } + } + + let artifacts = unique + .into_iter() + .map(|(url, (signature, asset_name, targets))| { + Ok(ValidatedArtifact { + url: Url::parse(&url).map_err(|error| error.to_string())?, + signature, + asset_name, + targets, + }) + }) + .collect::>>()?; + + Ok(ValidatedManifest { + manifest, + version, + channel: policy.channel, + artifacts, + }) +} + +pub fn validate_release_metadata( + candidate: &ValidatedManifest, + tag: &str, + package_version: &str, + historical_versions: &[String], + revoked_versions: &[String], +) -> Result<()> { + let expected_tag = format!("v{}", candidate.version); + if tag != expected_tag { + return Err(format!( + "release tag {tag:?} must exactly equal {expected_tag:?}" + )); + } + if package_version != candidate.version.to_string() { + return Err(format!( + "desktop package version {package_version:?} does not match manifest version {}", + candidate.version + )); + } + + validate_high_water(&candidate.version, candidate.channel, historical_versions)?; + validate_revocations(&candidate.version, candidate.channel, revoked_versions) +} + +pub fn current_pointer_high_water( + candidate_bytes: &[u8], + current_bytes: &[u8], + policy: &ManifestPolicy<'_>, +) -> Result> { + if candidate_bytes == current_bytes { + return Ok(None); + } + + let current = validate_manifest(parse_manifest(current_bytes)?, policy) + .map_err(|error| format!("current channel pointer is invalid: {error}"))?; + Ok(Some(current.version.to_string())) +} + +pub fn validate_event_metadata(tag: &str, package_version: &str, channel: Channel) -> Result<()> { + let version = Version::parse(package_version) + .map_err(|error| format!("desktop package version is not strict SemVer: {error}"))?; + if version.to_string() != package_version { + return Err("desktop package version is not canonical SemVer".to_string()); + } + if !channel.accepts(&version) { + return Err(format!( + "desktop package version {version} does not belong to the {} channel", + channel.as_str() + )); + } + let expected_tag = format!("v{version}"); + if tag != expected_tag { + return Err(format!( + "release tag {tag:?} must exactly equal {expected_tag:?}" + )); + } + Ok(()) +} + +pub fn validate_high_water( + candidate: &Version, + channel: Channel, + historical_versions: &[String], +) -> Result<()> { + for raw in historical_versions { + let historical = Version::parse(raw) + .map_err(|error| format!("invalid historical version {raw:?}: {error}"))?; + if !channel.accepts(&historical) { + return Err(format!( + "historical version {historical} does not belong to the {} channel", + channel.as_str() + )); + } + if candidate <= &historical { + return Err(format!( + "candidate {candidate} must be newer than historical high-water version {historical}" + )); + } + } + Ok(()) +} + +pub fn validate_revocations( + candidate: &Version, + channel: Channel, + revoked_versions: &[String], +) -> Result<()> { + for raw in revoked_versions { + let revoked = Version::parse(raw) + .map_err(|error| format!("invalid revoked version {raw:?}: {error}"))?; + if revoked.to_string() != *raw { + return Err(format!("revoked version {raw:?} must use canonical SemVer")); + } + if !channel.accepts(&revoked) { + return Err(format!( + "revoked version {revoked} does not belong to the {} channel", + channel.as_str() + )); + } + if candidate == &revoked { + return Err(format!( + "candidate {candidate} is permanently blocked by its rollout revocation marker" + )); + } + } + Ok(()) +} + +pub fn verify_manifest_sidecar_or_legacy( + manifest_bytes: &[u8], + signature: Option<&str>, + public_key: &str, + allow_legacy_without_sidecar: bool, +) -> Result { + match signature { + Some(signature) => { + verify_embedded_signature(manifest_bytes, signature, public_key) + .map_err(|error| format!("fallback manifest sidecar failed verification: {error}"))?; + Ok(true) + } + None if allow_legacy_without_sidecar => Ok(false), + None => Err( + "fallback manifest has no signature sidecar; explicitly allow a legacy manifest only after verifying every embedded artifact signature" + .to_string(), + ), + } +} + +pub fn validate_stop_plan( + current: &ValidatedManifest, + fallback: &ValidatedManifest, + expected_current: &str, + expected_fallback: &str, +) -> Result<()> { + if current.channel != fallback.channel { + return Err("current and fallback manifests belong to different channels".to_string()); + } + if current.version.to_string() != expected_current { + return Err(format!( + "current feed is {}, not the expected compare-and-swap version {expected_current}", + current.version + )); + } + if fallback.version.to_string() != expected_fallback { + return Err(format!( + "fallback manifest is {}, not requested version {expected_fallback}", + fallback.version + )); + } + if fallback.version >= current.version { + return Err(format!( + "stop fallback {} must be older than current feed {}", + fallback.version, current.version + )); + } + Ok(()) +} + +pub fn validate_stop_plan_with_current_bytes( + current_bytes: &[u8], + fallback: &ValidatedManifest, + policy: &ManifestPolicy<'_>, + expected_current: &str, + expected_fallback: &str, + confirmation: &str, +) -> Result { + let asserted_current = Version::parse(expected_current) + .map_err(|error| format!("expected current version is not strict SemVer: {error}"))?; + if asserted_current.to_string() != expected_current { + return Err("expected current version must use canonical SemVer".to_string()); + } + if !policy.channel.accepts(&asserted_current) { + return Err(format!( + "expected current version {asserted_current} does not belong to the {} channel", + policy.channel.as_str() + )); + } + if fallback.channel != policy.channel { + return Err("fallback manifest belongs to the wrong channel".to_string()); + } + if fallback.version.to_string() != expected_fallback { + return Err(format!( + "fallback manifest is {}, not requested version {expected_fallback}", + fallback.version + )); + } + if fallback.version >= asserted_current { + return Err(format!( + "stop fallback {} must be older than asserted current feed {}", + fallback.version, asserted_current + )); + } + + let current_manifest_sha256 = digest_hex(Sha256::digest(current_bytes).as_slice()); + match parse_manifest(current_bytes).and_then(|manifest| validate_manifest(manifest, policy)) { + Ok(current) => { + if confirmation != "STOP_ROLLOUT" { + return Err( + "a valid current pointer requires confirmation STOP_ROLLOUT".to_string() + ); + } + if current.version != asserted_current { + return Err(format!( + "current feed is {}, not the expected compare-and-swap version {expected_current}", + current.version + )); + } + Ok(StopPlanState { + current_manifest_valid: true, + current_manifest_sha256, + warning: None, + }) + } + Err(error) => { + if let Ok(value) = serde_json::from_slice::(current_bytes) + && let Some(raw) = value.get("version").and_then(serde_json::Value::as_str) + && let Ok(declared) = Version::parse(raw) + && declared.to_string() == raw + && declared != asserted_current + { + return Err(format!( + "corrupt current pointer declares version {declared}, not asserted version {asserted_current}" + )); + } + if confirmation != "STOP_CORRUPT_ROLLOUT" { + return Err(format!( + "current pointer failed strict validation ({error}); rerun with confirmation STOP_CORRUPT_ROLLOUT to replace these exact ETag-guarded bytes" + )); + } + Ok(StopPlanState { + current_manifest_valid: false, + current_manifest_sha256, + warning: Some(format!( + "current pointer failed strict validation and was replaced under explicit corrupt-rollout confirmation: {error}" + )), + }) + } + } +} + +pub fn production_public_key(config_bytes: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(config_bytes) + .map_err(|error| format!("invalid Tauri configuration JSON: {error}"))?; + let identifier = value + .get("identifier") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Tauri configuration is missing identifier".to_string())?; + if identifier != PRODUCTION_IDENTIFIER { + return Err(format!( + "refusing updater publication for non-production identifier {identifier:?}" + )); + } + if value + .pointer("/bundle/createUpdaterArtifacts") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return Err( + "production configuration must enable bundle.createUpdaterArtifacts".to_string(), + ); + } + let public_key = value + .pointer("/plugins/updater/pubkey") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "production updater public key is empty".to_string())?; + decode_public_key(public_key)?; + Ok(public_key.to_string()) +} + +pub fn package_version(package_bytes: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(package_bytes) + .map_err(|error| format!("invalid package JSON: {error}"))?; + let raw = value + .get("version") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "package JSON is missing a string version".to_string())?; + let parsed = Version::parse(raw) + .map_err(|error| format!("package version is not strict SemVer: {error}"))?; + if parsed.to_string() != raw { + return Err("package version is not canonical SemVer".to_string()); + } + Ok(raw.to_string()) +} + +pub fn verify_embedded_signature( + bytes: &[u8], + signature_base64: &str, + public_key_base64: &str, +) -> Result<()> { + let public_key = decode_public_key(public_key_base64)?; + let signature = decode_signature(signature_base64)?; + public_key + .verify(bytes, &signature, true) + .map_err(|error| format!("Minisign verification failed: {error}")) +} + +pub fn verify_and_download( + manifest_bytes: &[u8], + validated: &ValidatedManifest, + public_key_base64: &str, + output_directory: &Path, +) -> Result { + fs::create_dir_all(output_directory).map_err(|error| { + format!( + "failed to create artifact directory {}: {error}", + output_directory.display() + ) + })?; + let public_key = decode_public_key(public_key_base64)?; + let _ = rustls::crypto::ring::default_provider().install_default(); + let client = Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(20 * 60)) + .user_agent("gitru-updater-release-validator") + .build() + .map_err(|error| format!("failed to create HTTPS client: {error}"))?; + + let mut reports = Vec::with_capacity(validated.artifacts.len()); + for artifact in &validated.artifacts { + let signature = decode_signature(&artifact.signature)?; + let mut verifier = public_key + .verify_stream(&signature) + .map_err(|error| format!("unsupported signature for {}: {error}", artifact.url))?; + let mut response = client + .get(artifact.url.clone()) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| format!("failed to download {}: {error}", artifact.url))?; + if response.url().scheme() != "https" { + return Err(format!("artifact redirect left HTTPS: {}", response.url())); + } + + let output_path = safe_output_path(output_directory, &artifact.asset_name)?; + let partial_path = output_path.with_extension(format!( + "{}.partial", + output_path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("download") + )); + let mut file = File::create(&partial_path) + .map_err(|error| format!("failed to create {}: {error}", partial_path.display()))?; + let mut sha256 = Sha256::new(); + let mut bytes_written = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = response + .read(&mut buffer) + .map_err(|error| format!("failed while downloading {}: {error}", artifact.url))?; + if count == 0 { + break; + } + file.write_all(&buffer[..count]).map_err(|error| { + format!("failed while writing {}: {error}", partial_path.display()) + })?; + sha256.update(&buffer[..count]); + verifier.update(&buffer[..count]); + bytes_written = bytes_written.saturating_add(count as u64); + } + file.sync_all() + .map_err(|error| format!("failed to sync {}: {error}", partial_path.display()))?; + drop(file); + verifier.finalize().map_err(|error| { + let _ = fs::remove_file(&partial_path); + format!( + "signature verification failed for {}: {error}", + artifact.url + ) + })?; + fs::rename(&partial_path, &output_path).map_err(|error| { + format!( + "failed to finalize downloaded artifact {}: {error}", + output_path.display() + ) + })?; + + reports.push(ArtifactReport { + url: artifact.url.to_string(), + targets: artifact.targets.clone(), + bytes: bytes_written, + sha256: digest_hex(sha256.finalize().as_slice()), + }); + } + + Ok(ValidationReport { + version: validated.version.to_string(), + channel: validated.channel, + manifest_sha256: digest_hex(Sha256::digest(manifest_bytes).as_slice()), + artifacts: reports, + }) +} + +fn validate_repository(repository: &str) -> Result<()> { + let mut parts = repository.split('/'); + let Some(owner) = parts.next() else { + return Err("repository must be owner/name".to_string()); + }; + let Some(name) = parts.next() else { + return Err("repository must be owner/name".to_string()); + }; + if parts.next().is_some() + || !valid_slug(owner) + || !valid_slug(name) + || owner.is_empty() + || name.is_empty() + { + return Err("repository must be a safe owner/name slug".to_string()); + } + Ok(()) +} + +fn valid_slug(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn validate_artifact_url(repository: &str, version: &Version, raw: &str) -> Result<(Url, String)> { + if !raw.starts_with("https://github.com/") { + return Err("artifact URL must use the exact https://github.com origin".to_string()); + } + if raw.contains('%') || raw.contains('\\') { + return Err("artifact URL must not contain encoded or backslash path segments".to_string()); + } + let url = Url::parse(raw).map_err(|error| format!("URL parse failed: {error}"))?; + if url.scheme() != "https" + || url.host_str() != Some("github.com") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("artifact URL contains a forbidden origin or component".to_string()); + } + let segments = url + .path_segments() + .ok_or_else(|| "artifact URL has no path".to_string())? + .collect::>(); + let repository_parts = repository.split('/').collect::>(); + if segments.len() != 6 + || segments[0] != repository_parts[0] + || segments[1] != repository_parts[1] + || segments[2] != "releases" + || segments[3] != "download" + || segments[4] != format!("v{version}") + { + return Err(format!( + "artifact URL must point to {repository} release tag v{version}" + )); + } + let asset_name = segments[5].to_string(); + if asset_name.is_empty() || !valid_slug(&asset_name) { + return Err("artifact asset name contains unsafe characters".to_string()); + } + let expected = + format!("https://github.com/{repository}/releases/download/v{version}/{asset_name}"); + if raw != expected { + return Err("artifact URL is not in canonical form".to_string()); + } + Ok((url, asset_name)) +} + +fn validate_asset_type(target: &str, asset_name: &str) -> Result<()> { + let valid = match target { + "linux-x86_64" | "linux-x86_64-appimage" => asset_name.ends_with(".AppImage"), + "linux-x86_64-deb" => asset_name.ends_with(".deb"), + "linux-x86_64-rpm" => asset_name.ends_with(".rpm"), + "darwin-aarch64" | "darwin-x86_64" | "darwin-aarch64-app" | "darwin-x86_64-app" => { + asset_name.ends_with(".app.tar.gz") + } + "windows-x86_64" | "windows-x86_64-nsis" => asset_name.ends_with("_x64-setup.exe"), + _ => false, + }; + if !valid { + return Err(format!( + "artifact {asset_name:?} has the wrong type for target {target}" + )); + } + Ok(()) +} + +fn decode_public_key(value: &str) -> Result { + let decoded = decode_canonical_base64(value, "public key")?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| "decoded updater public key is not UTF-8".to_string())?; + PublicKey::decode(text).map_err(|error| format!("invalid updater public key: {error}")) +} + +fn decode_signature(value: &str) -> Result { + let decoded = decode_canonical_base64(value, "signature")?; + let text = std::str::from_utf8(&decoded) + .map_err(|_| "decoded updater signature is not UTF-8".to_string())?; + Signature::decode(text).map_err(|error| format!("invalid updater signature: {error}")) +} + +fn decode_canonical_base64(value: &str, label: &str) -> Result> { + if value.is_empty() || value.trim() != value { + return Err(format!("{label} must be non-empty canonical base64")); + } + let decoded = BASE64 + .decode(value) + .map_err(|error| format!("{label} is not base64: {error}"))?; + if BASE64.encode(&decoded) != value { + return Err(format!("{label} is not canonical base64")); + } + Ok(decoded) +} + +fn safe_output_path(directory: &Path, asset_name: &str) -> Result { + if !valid_slug(asset_name) { + return Err("unsafe artifact output filename".to_string()); + } + Ok(directory.join(asset_name)) +} + +fn digest_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPOSITORY: &str = "ruru-m07/gitru"; + const PUBLIC_KEY: &str = include_str!("../tests/fixtures/test-only.pub"); + const WRONG_PUBLIC_KEY: &str = include_str!("../tests/fixtures/wrong-test-only.pub"); + const SIGNATURE: &str = include_str!("../tests/fixtures/artifact.bin.sig"); + const OTHER_SIGNATURE: &str = include_str!("../tests/fixtures/other.bin.sig"); + const ARTIFACT: &[u8] = include_bytes!("../tests/fixtures/artifact.bin"); + + fn entry(asset: &str) -> PlatformEntry { + PlatformEntry { + url: format!("https://github.com/{REPOSITORY}/releases/download/v1.2.3/{asset}"), + signature: SIGNATURE.trim().to_string(), + } + } + + fn manifest(version: &str) -> Manifest { + let mut platforms = BTreeMap::new(); + platforms.insert( + "linux-x86_64-appimage".to_string(), + entry("Gitru_1.2.3_amd64.AppImage"), + ); + platforms.insert( + "linux-x86_64-deb".to_string(), + entry("Gitru_1.2.3_amd64.deb"), + ); + platforms.insert( + "linux-x86_64-rpm".to_string(), + entry("Gitru-1.2.3-1.x86_64.rpm"), + ); + platforms.insert( + "darwin-aarch64-app".to_string(), + entry("Gitru_universal.app.tar.gz"), + ); + platforms.insert( + "darwin-x86_64-app".to_string(), + entry("Gitru_universal.app.tar.gz"), + ); + platforms.insert( + "windows-x86_64-nsis".to_string(), + entry("Gitru_1.2.3_x64-setup.exe"), + ); + Manifest { + version: version.to_string(), + notes: Some(String::new()), + pub_date: "2026-09-20T12:00:00Z".to_string(), + platforms, + } + } + + fn stable_policy() -> ManifestPolicy<'static> { + ManifestPolicy { + repository: REPOSITORY, + channel: Channel::Stable, + } + } + + #[test] + fn validates_complete_stable_manifest_and_deduplicates_assets() { + let validated = validate_manifest(manifest("1.2.3"), &stable_policy()).unwrap(); + assert_eq!(validated.artifacts.len(), 5); + assert_eq!(validated.version, Version::new(1, 2, 3)); + } + + #[test] + fn validates_beta_channel_and_rejects_cross_channel_versions() { + let mut beta = manifest("1.2.3-beta.4"); + for entry in beta.platforms.values_mut() { + entry.url = entry.url.replace("v1.2.3/", "v1.2.3-beta.4/"); + } + let policy = ManifestPolicy { + repository: REPOSITORY, + channel: Channel::Beta, + }; + assert!(validate_manifest(beta.clone(), &policy).is_ok()); + assert!(validate_manifest(beta, &stable_policy()).is_err()); + assert!(validate_manifest(manifest("1.2.3"), &policy).is_err()); + } + + #[test] + fn rejects_build_metadata_and_non_beta_prereleases() { + assert!(validate_manifest(manifest("1.2.3+rebuilt"), &stable_policy()).is_err()); + let policy = ManifestPolicy { + repository: REPOSITORY, + channel: Channel::Beta, + }; + assert!( + !policy + .channel + .accepts(&Version::parse("1.2.3-rc.1").unwrap()) + ); + assert!(Version::parse("1.2.3-beta.01").is_err()); + } + + #[test] + fn rejects_missing_unknown_and_mismatched_alias_targets() { + let mut missing = manifest("1.2.3"); + missing.platforms.remove("linux-x86_64-deb"); + assert!(validate_manifest(missing, &stable_policy()).is_err()); + + let mut unknown = manifest("1.2.3"); + unknown + .platforms + .insert("plan9-amd64".to_string(), entry("Gitru.zip")); + assert!(validate_manifest(unknown, &stable_policy()).is_err()); + + let mut alias = manifest("1.2.3"); + alias.platforms.insert( + "linux-x86_64".to_string(), + entry("Gitru_other_amd64.AppImage"), + ); + assert!(validate_manifest(alias, &stable_policy()).is_err()); + } + + #[test] + fn rejects_untrusted_or_noncanonical_urls() { + for bad_url in [ + "http://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb", + "https://evil.example/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb", + "https://user@github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb", + "https://github.com:444/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb", + "https://github.com/ruru-m07/gitru/releases/download/v9.9.9/Gitru_1.2.3_amd64.deb", + "https://github.com/ruru-m07/other/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb?x=1", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_1.2.3_amd64.deb#x", + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/%2e%2e", + ] { + let mut candidate = manifest("1.2.3"); + candidate.platforms.get_mut("linux-x86_64-deb").unwrap().url = bad_url.to_string(); + assert!( + validate_manifest(candidate, &stable_policy()).is_err(), + "accepted {bad_url}" + ); + } + } + + #[test] + fn rejects_target_extension_and_signature_conflicts() { + let mut extension = manifest("1.2.3"); + extension.platforms.get_mut("linux-x86_64-deb").unwrap().url = + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru.exe".to_string(); + assert!(validate_manifest(extension, &stable_policy()).is_err()); + + let mut wrong_windows_architecture = manifest("1.2.3"); + wrong_windows_architecture + .platforms + .get_mut("windows-x86_64-nsis") + .unwrap() + .url = + "https://github.com/ruru-m07/gitru/releases/download/v1.2.3/Gitru_arm64-setup.exe" + .to_string(); + assert!(validate_manifest(wrong_windows_architecture, &stable_policy()).is_err()); + + let mut conflict = manifest("1.2.3"); + conflict.platforms.insert( + "darwin-aarch64".to_string(), + PlatformEntry { + url: conflict.platforms["darwin-aarch64-app"].url.clone(), + signature: OTHER_SIGNATURE.trim().to_string(), + }, + ); + assert!(validate_manifest(conflict, &stable_policy()).is_err()); + } + + #[test] + fn release_metadata_enforces_exact_tag_package_and_high_water() { + let validated = validate_manifest(manifest("1.2.3"), &stable_policy()).unwrap(); + assert!( + validate_release_metadata(&validated, "v1.2.3", "1.2.3", &["1.2.2".to_string()], &[]) + .is_ok() + ); + assert!(validate_release_metadata(&validated, "1.2.3", "1.2.3", &[], &[]).is_err()); + assert!(validate_release_metadata(&validated, "v1.2.3", "1.2.2", &[], &[]).is_err()); + assert!( + validate_release_metadata(&validated, "v1.2.3", "1.2.3", &["1.2.3".to_string()], &[]) + .is_err() + ); + assert!( + validate_release_metadata(&validated, "v1.2.3", "1.2.3", &["1.2.4".to_string()], &[]) + .is_err() + ); + } + + #[test] + fn current_pointer_joins_high_water_unless_bytes_match_candidate() { + let candidate = serde_json::to_vec(&manifest("1.2.3")).unwrap(); + assert_eq!( + current_pointer_high_water(&candidate, &candidate, &stable_policy()).unwrap(), + None + ); + + let mut higher = manifest("1.2.4"); + for entry in higher.platforms.values_mut() { + entry.url = entry.url.replace("v1.2.3/", "v1.2.4/"); + } + let higher = serde_json::to_vec(&higher).unwrap(); + assert_eq!( + current_pointer_high_water(&candidate, &higher, &stable_policy()).unwrap(), + Some("1.2.4".to_string()) + ); + assert!( + current_pointer_high_water(&candidate, br#"{"version":"9.9.9"}"#, &stable_policy()) + .is_err() + ); + } + + #[test] + fn revoked_release_cannot_be_repromoted_but_forward_recovery_can() { + let revoked = vec!["1.2.3".to_string()]; + assert!( + validate_revocations(&Version::parse("1.2.3").unwrap(), Channel::Stable, &revoked) + .is_err() + ); + assert!( + validate_revocations(&Version::parse("1.2.4").unwrap(), Channel::Stable, &revoked) + .is_ok() + ); + assert!( + validate_revocations( + &Version::parse("1.2.4").unwrap(), + Channel::Stable, + &["1.2.3-beta.1".to_string()] + ) + .is_err() + ); + } + + #[test] + fn legacy_manifest_requires_explicit_opt_in_and_bad_sidecars_still_fail() { + assert!( + verify_manifest_sidecar_or_legacy(ARTIFACT, None, PUBLIC_KEY.trim(), false).is_err() + ); + assert!( + !verify_manifest_sidecar_or_legacy(ARTIFACT, None, PUBLIC_KEY.trim(), true).unwrap() + ); + assert!( + verify_manifest_sidecar_or_legacy( + ARTIFACT, + Some(SIGNATURE.trim()), + PUBLIC_KEY.trim(), + false + ) + .unwrap() + ); + assert!( + verify_manifest_sidecar_or_legacy( + ARTIFACT, + Some(OTHER_SIGNATURE.trim()), + PUBLIC_KEY.trim(), + true + ) + .is_err() + ); + } + + #[test] + fn release_event_enforces_channel_and_exact_tag() { + assert!(validate_event_metadata("v1.2.3", "1.2.3", Channel::Stable).is_ok()); + assert!(validate_event_metadata("v1.2.3-beta.4", "1.2.3-beta.4", Channel::Beta).is_ok()); + assert!(validate_event_metadata("v1.2.3", "1.2.3", Channel::Beta).is_err()); + assert!(validate_event_metadata("1.2.3", "1.2.3", Channel::Stable).is_err()); + } + + #[test] + fn forward_recovery_must_exceed_the_bad_historical_version() { + let bad_version = vec!["1.2.3".to_string()]; + assert!( + validate_high_water( + &Version::parse("1.2.4").unwrap(), + Channel::Stable, + &bad_version + ) + .is_ok() + ); + assert!( + validate_high_water( + &Version::parse("1.2.2").unwrap(), + Channel::Stable, + &bad_version + ) + .is_err() + ); + } + + #[test] + fn verifies_valid_bytes_and_rejects_tampering_wrong_keys_and_swapped_signatures() { + assert!(verify_embedded_signature(ARTIFACT, SIGNATURE.trim(), PUBLIC_KEY.trim()).is_ok()); + + let mut tampered = ARTIFACT.to_vec(); + tampered[0] ^= 1; + assert!(verify_embedded_signature(&tampered, SIGNATURE.trim(), PUBLIC_KEY.trim()).is_err()); + assert!( + verify_embedded_signature(ARTIFACT, SIGNATURE.trim(), WRONG_PUBLIC_KEY.trim()).is_err() + ); + assert!( + verify_embedded_signature(ARTIFACT, OTHER_SIGNATURE.trim(), PUBLIC_KEY.trim()).is_err() + ); + } + + #[test] + fn production_config_requires_identity_artifacts_and_a_valid_public_key() { + let valid = serde_json::json!({ + "identifier": PRODUCTION_IDENTIFIER, + "bundle": { "createUpdaterArtifacts": true }, + "plugins": { "updater": { "pubkey": PUBLIC_KEY.trim() } } + }); + assert_eq!( + production_public_key(valid.to_string().as_bytes()).unwrap(), + PUBLIC_KEY.trim() + ); + + let mut invalid_identity = valid.clone(); + invalid_identity["identifier"] = serde_json::json!("com.ruru.gitru.qualification"); + assert!(production_public_key(invalid_identity.to_string().as_bytes()).is_err()); + + let mut missing_artifacts = valid.clone(); + missing_artifacts["bundle"]["createUpdaterArtifacts"] = serde_json::json!(false); + assert!(production_public_key(missing_artifacts.to_string().as_bytes()).is_err()); + + let mut invalid_key = valid; + invalid_key["plugins"]["updater"]["pubkey"] = serde_json::json!("not-base64"); + assert!(production_public_key(invalid_key.to_string().as_bytes()).is_err()); + } + + #[test] + fn stop_plan_requires_exact_current_and_an_older_fallback() { + let current = validate_manifest(manifest("1.2.3"), &stable_policy()).unwrap(); + let mut older_manifest = manifest("1.2.2"); + for entry in older_manifest.platforms.values_mut() { + entry.url = entry.url.replace("v1.2.3/", "v1.2.2/"); + } + let older = validate_manifest(older_manifest, &stable_policy()).unwrap(); + assert!(validate_stop_plan(¤t, &older, "1.2.3", "1.2.2").is_ok()); + assert!(validate_stop_plan(¤t, &older, "1.2.4", "1.2.2").is_err()); + assert!(validate_stop_plan(¤t, ¤t, "1.2.3", "1.2.3").is_err()); + } + + #[test] + fn corrupt_current_pointer_requires_distinct_confirmation_and_preserves_evidence() { + let mut older_manifest = manifest("1.2.2"); + for entry in older_manifest.platforms.values_mut() { + entry.url = entry.url.replace("v1.2.3/", "v1.2.2/"); + } + let fallback = validate_manifest(older_manifest, &stable_policy()).unwrap(); + let corrupt = br#"{"version":"1.2.3","unexpected":true}"#; + + assert!( + validate_stop_plan_with_current_bytes( + corrupt, + &fallback, + &stable_policy(), + "1.2.3", + "1.2.2", + "STOP_ROLLOUT" + ) + .is_err() + ); + let state = validate_stop_plan_with_current_bytes( + corrupt, + &fallback, + &stable_policy(), + "1.2.3", + "1.2.2", + "STOP_CORRUPT_ROLLOUT", + ) + .unwrap(); + assert!(!state.current_manifest_valid); + assert_eq!(state.current_manifest_sha256.len(), 64); + assert!(state.warning.is_some()); + + let wrong_declared_version = br#"{"version":"1.2.4","unexpected":true}"#; + assert!( + validate_stop_plan_with_current_bytes( + wrong_declared_version, + &fallback, + &stable_policy(), + "1.2.3", + "1.2.2", + "STOP_CORRUPT_ROLLOUT" + ) + .is_err() + ); + let wrong_channel_version = br#"{"version":"1.2.3-beta.1","unexpected":true}"#; + assert!( + validate_stop_plan_with_current_bytes( + wrong_channel_version, + &fallback, + &stable_policy(), + "1.2.3", + "1.2.2", + "STOP_CORRUPT_ROLLOUT" + ) + .is_err() + ); + } + + #[test] + fn valid_current_pointer_rejects_corrupt_rollout_confirmation() { + let current_manifest = manifest("1.2.3"); + let current_bytes = serde_json::to_vec(¤t_manifest).unwrap(); + let current = validate_manifest(current_manifest, &stable_policy()).unwrap(); + let mut older_manifest = manifest("1.2.2"); + for entry in older_manifest.platforms.values_mut() { + entry.url = entry.url.replace("v1.2.3/", "v1.2.2/"); + } + let fallback = validate_manifest(older_manifest, &stable_policy()).unwrap(); + + assert!( + validate_stop_plan_with_current_bytes( + ¤t_bytes, + &fallback, + &stable_policy(), + "1.2.3", + "1.2.2", + "STOP_CORRUPT_ROLLOUT" + ) + .is_err() + ); + assert!(validate_stop_plan(¤t, &fallback, "1.2.3", "1.2.2").is_ok()); + } +} diff --git a/crates/updater-release/src/main.rs b/crates/updater-release/src/main.rs new file mode 100644 index 0000000..de6caf7 --- /dev/null +++ b/crates/updater-release/src/main.rs @@ -0,0 +1,380 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, + process, + str::FromStr, +}; +use updater_release::{ + Channel, ManifestPolicy, Result, StopPlanState, ValidationReport, current_pointer_high_water, + package_version, parse_manifest, production_public_key, validate_event_metadata, + validate_manifest, validate_release_metadata, validate_stop_plan_with_current_bytes, + verify_and_download, verify_embedded_signature, verify_manifest_sidecar_or_legacy, +}; + +fn main() { + if let Err(error) = run() { + eprintln!("updater-release: {error}"); + process::exit(1); + } +} + +fn run() -> Result<()> { + let mut arguments = env::args().skip(1); + let command = arguments.next().ok_or_else(usage)?; + let flags = parse_flags(arguments.collect())?; + + match command.as_str() { + "validate-event" => validate_event_command(&flags), + "validate-release" => validate_release_command(&flags), + "validate-promotion" => validate_promotion_command(&flags), + "validate-feed" => validate_feed_command(&flags), + "validate-stop" => validate_stop_command(&flags), + "verify-signature" => verify_signature_command(&flags), + "help" | "--help" | "-h" => { + println!("{}", usage()); + Ok(()) + } + _ => Err(format!("unknown command {command:?}\n\n{}", usage())), + } +} + +fn validate_event_command(flags: &BTreeMap) -> Result<()> { + require_only(flags, &["package-json", "tag", "channel"])?; + let package = package_version(&read(&required_path(flags, "package-json")?)?)?; + let channel = required_channel(flags)?; + validate_event_metadata(required(flags, "tag")?, &package, channel)?; + println!("validated {} release event for {package}", channel.as_str()); + Ok(()) +} + +fn validate_release_command(flags: &BTreeMap) -> Result<()> { + require_only( + flags, + &[ + "manifest", + "config", + "package-json", + "repository", + "tag", + "channel", + "history", + "revocations", + "current-manifest", + "download-directory", + "report", + ], + )?; + let manifest_path = required_path(flags, "manifest")?; + let manifest_bytes = read(&manifest_path)?; + let channel = required_channel(flags)?; + let repository = required(flags, "repository")?; + let validated = validate_manifest( + parse_manifest(&manifest_bytes)?, + &ManifestPolicy { + repository, + channel, + }, + )?; + let package_bytes = read(&required_path(flags, "package-json")?)?; + let mut history = read_history(&required_path(flags, "history")?)?; + let revocations = read_history(&required_path(flags, "revocations")?)?; + if let Some(current_path) = flags.get("current-manifest") { + let current_bytes = read(Path::new(current_path))?; + if let Some(current_version) = current_pointer_high_water( + &manifest_bytes, + ¤t_bytes, + &ManifestPolicy { + repository, + channel, + }, + )? { + history.push(current_version); + } + } + validate_release_metadata( + &validated, + required(flags, "tag")?, + &package_version(&package_bytes)?, + &history, + &revocations, + )?; + let public_key = read_production_public_key(flags)?; + let report = verify_and_download( + &manifest_bytes, + &validated, + &public_key, + &required_path(flags, "download-directory")?, + )?; + write_report(flags, &report) +} + +fn validate_feed_command(flags: &BTreeMap) -> Result<()> { + require_only( + flags, + &[ + "manifest", + "config", + "repository", + "channel", + "download-directory", + "report", + ], + )?; + let manifest_path = required_path(flags, "manifest")?; + let manifest_bytes = read(&manifest_path)?; + let validated = validate_manifest( + parse_manifest(&manifest_bytes)?, + &ManifestPolicy { + repository: required(flags, "repository")?, + channel: required_channel(flags)?, + }, + )?; + let public_key = read_production_public_key(flags)?; + let report = verify_and_download( + &manifest_bytes, + &validated, + &public_key, + &required_path(flags, "download-directory")?, + )?; + write_report(flags, &report) +} + +fn validate_promotion_command(flags: &BTreeMap) -> Result<()> { + require_only( + flags, + &[ + "manifest", + "manifest-signature", + "config", + "package-json", + "repository", + "tag", + "channel", + "history", + "revocations", + "current-manifest", + ], + )?; + let manifest_bytes = read(&required_path(flags, "manifest")?)?; + let channel = required_channel(flags)?; + let repository = required(flags, "repository")?; + let policy = ManifestPolicy { + repository, + channel, + }; + let validated = validate_manifest(parse_manifest(&manifest_bytes)?, &policy)?; + let mut history = read_history(&required_path(flags, "history")?)?; + if let Some(current_path) = flags.get("current-manifest") { + let current_bytes = read(Path::new(current_path))?; + if let Some(current_version) = + current_pointer_high_water(&manifest_bytes, ¤t_bytes, &policy)? + { + history.push(current_version); + } + } + validate_release_metadata( + &validated, + required(flags, "tag")?, + &package_version(&read(&required_path(flags, "package-json")?)?)?, + &history, + &read_history(&required_path(flags, "revocations")?)?, + )?; + let signature = read_string(&required_path(flags, "manifest-signature")?)?; + verify_embedded_signature( + &manifest_bytes, + &signature, + &read_production_public_key(flags)?, + ) + .map_err(|error| format!("candidate manifest sidecar failed verification: {error}"))?; + println!( + "validated {} {} promotion state and manifest sidecar", + channel.as_str(), + validated.version + ); + Ok(()) +} + +fn validate_stop_command(flags: &BTreeMap) -> Result<()> { + require_only( + flags, + &[ + "current-manifest", + "fallback-manifest", + "fallback-signature", + "allow-legacy-manifest-without-sidecar", + "confirmation", + "stop-state", + "config", + "repository", + "channel", + "expected-current-version", + "expected-fallback-version", + "download-directory", + "report", + ], + )?; + let channel = required_channel(flags)?; + let repository = required(flags, "repository")?; + let policy = ManifestPolicy { + repository, + channel, + }; + let current_bytes = read(&required_path(flags, "current-manifest")?)?; + let fallback_bytes = read(&required_path(flags, "fallback-manifest")?)?; + let fallback = validate_manifest(parse_manifest(&fallback_bytes)?, &policy)?; + + let public_key = read_production_public_key(flags)?; + let fallback_signature = flags + .get("fallback-signature") + .map(|path| read_string(Path::new(path))) + .transpose()?; + let allow_legacy = match flags.get("allow-legacy-manifest-without-sidecar") { + Some(value) if value == "true" => true, + Some(value) if value == "false" => false, + Some(value) => { + return Err(format!( + "--allow-legacy-manifest-without-sidecar must be true or false, got {value:?}" + )); + } + None => false, + }; + if fallback_signature.is_some() && allow_legacy { + return Err( + "do not allow a legacy manifest when a fallback signature sidecar is available" + .to_string(), + ); + } + let sidecar_verified = verify_manifest_sidecar_or_legacy( + &fallback_bytes, + fallback_signature.as_deref(), + &public_key, + allow_legacy, + )?; + let stop_state = validate_stop_plan_with_current_bytes( + ¤t_bytes, + &fallback, + &policy, + required(flags, "expected-current-version")?, + required(flags, "expected-fallback-version")?, + required(flags, "confirmation")?, + )?; + write_stop_state(&required_path(flags, "stop-state")?, &stop_state)?; + let report = verify_and_download( + &fallback_bytes, + &fallback, + &public_key, + &required_path(flags, "download-directory")?, + )?; + if !sidecar_verified { + println!( + "legacy fallback has no manifest sidecar; all embedded artifact signatures were verified" + ); + } + write_report(flags, &report) +} + +fn write_stop_state(path: &Path, state: &StopPlanState) -> Result<()> { + let json = serde_json::to_string_pretty(state) + .map_err(|error| format!("failed to serialize stop state: {error}"))?; + fs::write(path, format!("{json}\n")) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + if let Some(warning) = &state.warning { + println!("WARNING: {warning}"); + } + Ok(()) +} + +fn verify_signature_command(flags: &BTreeMap) -> Result<()> { + require_only(flags, &["file", "signature", "config"])?; + let bytes = read(&required_path(flags, "file")?)?; + let signature = read_string(&required_path(flags, "signature")?)?; + let public_key = read_production_public_key(flags)?; + verify_embedded_signature(&bytes, &signature, &public_key)?; + println!("verified {}", required_path(flags, "file")?.display()); + Ok(()) +} + +fn parse_flags(arguments: Vec) -> Result> { + let mut flags = BTreeMap::new(); + let mut index = 0; + while index < arguments.len() { + let raw = &arguments[index]; + let name = raw + .strip_prefix("--") + .filter(|name| !name.is_empty()) + .ok_or_else(|| format!("expected --flag, got {raw:?}"))?; + let value = arguments + .get(index + 1) + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| format!("missing value for --{name}"))?; + if flags.insert(name.to_string(), value.clone()).is_some() { + return Err(format!("duplicate --{name}")); + } + index += 2; + } + Ok(flags) +} + +fn require_only(flags: &BTreeMap, allowed: &[&str]) -> Result<()> { + for flag in flags.keys() { + if !allowed.contains(&flag.as_str()) { + return Err(format!("unexpected flag --{flag}")); + } + } + Ok(()) +} + +fn required<'a>(flags: &'a BTreeMap, name: &str) -> Result<&'a str> { + flags + .get(name) + .map(String::as_str) + .ok_or_else(|| format!("missing required --{name}")) +} + +fn required_path(flags: &BTreeMap, name: &str) -> Result { + required(flags, name).map(PathBuf::from) +} + +fn required_channel(flags: &BTreeMap) -> Result { + Channel::from_str(required(flags, "channel")?) +} + +fn read(path: &Path) -> Result> { + fs::read(path).map_err(|error| format!("failed to read {}: {error}", path.display())) +} + +fn read_string(path: &Path) -> Result { + String::from_utf8(read(path)?).map_err(|_| format!("{} is not UTF-8", path.display())) +} + +fn read_history(path: &Path) -> Result> { + Ok(read_string(path)? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect()) +} + +fn read_production_public_key(flags: &BTreeMap) -> Result { + production_public_key(&read(&required_path(flags, "config")?)?) +} + +fn write_report(flags: &BTreeMap, report: &ValidationReport) -> Result<()> { + let json = serde_json::to_string_pretty(report) + .map_err(|error| format!("failed to serialize validation report: {error}"))?; + fs::write(required_path(flags, "report")?, format!("{json}\n")) + .map_err(|error| format!("failed to write validation report: {error}"))?; + println!( + "validated {} {} manifest and {} unique signed artifacts", + report.channel.as_str(), + report.version, + report.artifacts.len() + ); + Ok(()) +} + +fn usage() -> String { + "Usage:\n updater-release validate-event --package-json PATH --tag vVERSION --channel stable|beta\n updater-release validate-release --manifest PATH --config PATH --package-json PATH --repository OWNER/NAME --tag vVERSION --channel stable|beta --history PATH --revocations PATH [--current-manifest PATH] --download-directory PATH --report PATH\n updater-release validate-promotion --manifest PATH --manifest-signature PATH --config PATH --package-json PATH --repository OWNER/NAME --tag vVERSION --channel stable|beta --history PATH --revocations PATH [--current-manifest PATH]\n updater-release validate-feed --manifest PATH --config PATH --repository OWNER/NAME --channel stable|beta --download-directory PATH --report PATH\n updater-release validate-stop --current-manifest PATH --fallback-manifest PATH (--fallback-signature PATH | --allow-legacy-manifest-without-sidecar true) --config PATH --repository OWNER/NAME --channel stable|beta --expected-current-version VERSION --expected-fallback-version VERSION --confirmation STOP_ROLLOUT|STOP_CORRUPT_ROLLOUT --stop-state PATH --download-directory PATH --report PATH\n updater-release verify-signature --file PATH --signature PATH --config PATH".to_string() +} diff --git a/crates/updater-release/tests/fixtures/.gitattributes b/crates/updater-release/tests/fixtures/.gitattributes new file mode 100644 index 0000000..ba91382 --- /dev/null +++ b/crates/updater-release/tests/fixtures/.gitattributes @@ -0,0 +1,3 @@ +artifact.bin -text +*.pub -text +*.sig -text diff --git a/crates/updater-release/tests/fixtures/README.md b/crates/updater-release/tests/fixtures/README.md new file mode 100644 index 0000000..07920c1 --- /dev/null +++ b/crates/updater-release/tests/fixtures/README.md @@ -0,0 +1,5 @@ +# Test-only updater signatures + +These files use a disposable Minisign key generated only for the +`updater-release` test suite. The private key is intentionally not stored in +the repository and must never be used for a Gitru release. diff --git a/crates/updater-release/tests/fixtures/artifact.bin b/crates/updater-release/tests/fixtures/artifact.bin new file mode 100644 index 0000000..9f3b5fa --- /dev/null +++ b/crates/updater-release/tests/fixtures/artifact.bin @@ -0,0 +1 @@ +gitru updater release integrity fixture v1 diff --git a/crates/updater-release/tests/fixtures/artifact.bin.sig b/crates/updater-release/tests/fixtures/artifact.bin.sig new file mode 100644 index 0000000..8d25513 --- /dev/null +++ b/crates/updater-release/tests/fixtures/artifact.bin.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUMmIzaVdWZ3BsUWZtRnpqTFpqaE5QMFI5ZEQvVkNkbWxTUTlheDIzWE05bTJSMmtSbDdzWFMvZDV6NWhpSytFblp2L1FKcGZ6UEhKMnhBN1lDMW5CMXpTQ1RsZ2NnRGc0PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg5OTEzNDMxCWZpbGU6YXJ0aWZhY3QuYmluCkpud3FzVTIvTkYvanh6U3ZzaktCLy81Vk9DQUQzdVA4TmVITDBxbnR2aGlDdmdhVDZ5OEM5UGV4Y3JUTHBYcTh2ZGNBbC81VUYxb29LMXFyK1lwVUNRPT0K diff --git a/crates/updater-release/tests/fixtures/other.bin.sig b/crates/updater-release/tests/fixtures/other.bin.sig new file mode 100644 index 0000000..df33c4b --- /dev/null +++ b/crates/updater-release/tests/fixtures/other.bin.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUMmIzaVdWZ3BsUWFuaUVsM1J4bWh1dTJoTGRlc25xZUpweVVGV3g3bk94SW5Yb3htdERUOVZvd3RteEtrd09vWmNUazVNejA3L0VucCtQc3dVdmFDNVp0OFZtOHNOM2dvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg5OTEzNTExCWZpbGU6b3RoZXIuYmluCjRHZ0FQOVZ6b04yYng1ZlZPc0MyREhEQUttWjM2aFJNQWhlWnZzamYwb0E0U0xMcjJNZzJPZ05jbm9nNjBnZGhyeklKWWxlUUZKbktDNnZPV1MxaENBPT0K diff --git a/crates/updater-release/tests/fixtures/test-only.pub b/crates/updater-release/tests/fixtures/test-only.pub new file mode 100644 index 0000000..90c9871 --- /dev/null +++ b/crates/updater-release/tests/fixtures/test-only.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxNjUwQTU2OTY3ODZGRjYKUldUMmIzaVdWZ3BsUVV2ZnVxOENvVS9VWTZmb2ZaYmI1K2c1VHZZMERmNkx5OStKSURuc0ZMNmgK diff --git a/crates/updater-release/tests/fixtures/wrong-test-only.pub b/crates/updater-release/tests/fixtures/wrong-test-only.pub new file mode 100644 index 0000000..ff99200 --- /dev/null +++ b/crates/updater-release/tests/fixtures/wrong-test-only.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFNzJCMjlFOEZENjcxN0MKUldSOGNkYVBuckp5dnNrd05zbVRCdW9nNC9aaW9aNHUya3NpcnlsNndsbmJYTmF0M0cxSzN1Yy8K diff --git a/docs/architecture/tauri-3-cef-qualification.md b/docs/architecture/tauri-3-cef-qualification.md index c96839f..c1fec3c 100644 --- a/docs/architecture/tauri-3-cef-qualification.md +++ b/docs/architecture/tauri-3-cef-qualification.md @@ -199,7 +199,7 @@ blocked until the applicable gates are closed or explicitly accepted: | macOS stability | Successful hosted runs plus nondeterministic child-target disconnects | Repeated packaged native runs and failure-rate acceptance | | macOS distribution | Native arm64 and x64 packages work; universal CEF resolution is unsupported | Architecture-aware downloads/updates, or a proven universal packaging strategy | | JavaScript API graph | Root v3 API plus eight plugin-nested v2 copies | Updated plugin metadata, frozen install, and plugin/API requalification | -| Signing and updates | Qualification bundles are unsigned and updater-disabled | Signed/notarized install, relaunch, update, failed-update recovery, and rollback on every shipped target | +| Signing and updates | Qualification bundles are unsigned and updater-disabled; feed policy, artifact Minisign verification, and stop-rollout controls are automated | Signed/notarized install, relaunch, update, failed-update recovery, and forward recovery on every shipped target | | User profile migration | Qualification identifier is isolated | Tested production-identity transition with existing user state and a recovery plan | | Cost and performance | Package sizes recorded; limits not approved | Approved installer, installed-size, update, cache, memory, and launch budgets with reproducible measurements | @@ -218,3 +218,6 @@ blocked until the applicable gates are closed or explicitly accepted: release workflow's identity guard until that review is complete. Tracking issue: RURU-93. + +The zero-cost feed controls and their limits are documented in the +[updater integrity and recovery runbook](../releasing/updater-integrity.md). diff --git a/docs/releasing/updater-integrity.md b/docs/releasing/updater-integrity.md new file mode 100644 index 0000000..6449053 --- /dev/null +++ b/docs/releasing/updater-integrity.md @@ -0,0 +1,169 @@ +# Updater integrity and recovery + +This runbook covers the zero-cost controls around Gitru's updater feed. It does +not promote the current Tauri 3/CEF qualification build to production or claim +native installed-update coverage. + +## Release invariants + +- A stable release uses `MAJOR.MINOR.PATCH`; a beta uses + `MAJOR.MINOR.PATCH-beta.N`. The manifest version never starts with `v`, while + its Git tag is exactly `v`. +- Stable manifests publish only to the `stable` channel and beta manifests only + to `beta`. A candidate must be newer than every version ever published in the + channel, not merely newer than its potentially rewound current pointer. A + version with a durable `/revocations/.json` marker can never + be promoted again; recovery always uses a higher version. +- Every updater URL must be an exact HTTPS GitHub release asset URL: + + ```text + https://github.com/ruru-m07/gitru/releases/download/v/ + ``` + + Reject a different owner, repository, host, port, tag, or scheme, and reject + user information, query strings, fragments, encoded traversal, missing + targets, unknown targets, and target/extension mismatches. +- Verify every downloaded updater artifact against the manifest's embedded + Tauri minisign signature and the configured updater public key. A valid URL + or checksum alone is not sufficient. Reject malformed signatures, a wrong + key, modified artifacts, and signatures copied from another target. +- Keep private signing keys out of the repository and logs. Test fixtures may + contain a clearly labelled disposable public key, artifact, and signature, + but never the fixture private key. + +Use the repository validator for preflight and readback checks. Consult its +`--help` output for the current command names; the stable invocation shape is: + +```bash +cargo run -p updater-release -- ... +``` + +## Publish a manifest + +1. Build and sign all expected updater artifacts. `tauri-action` updates one + shared GitHub `latest.json` asset, so serialize its platform matrix until + manifest aggregation is moved into a dedicated job. Generate the final + manifest only after all platform jobs have completed. +2. Run the updater-release validator against the candidate manifest and its + downloaded artifacts. Confirm the channel, version/tag match, complete + target set, exact GitHub asset origins, and embedded minisign signatures. +3. Read the current channel pointer bytes and ETag together. Treat only an + absent object as an empty channel; authentication, network, and server errors + must stop publication. Strictly validate a present pointer and include its + version in the high-water check unless its bytes are exactly the candidate. + This prevents a lower candidate from overwriting a newer pointer that was + only partially or manually published without versioned history. A malformed + current pointer fails normal publication closed. +4. Upload the candidate first to the versioned key: + + ```text + /versions//latest.json + ``` + + Cloudflare R2 does not provide Object Lock, so immutability is enforced by + the workflow: create the object conditionally with `If-None-Match: *`. If + that key already exists, continue only when its bytes are identical to the + candidate. Never overwrite a version with different content. + Sign those exact manifest bytes with the updater key and store the + create-only audit sidecar at `latest.json.sig`. Tauri clients verify each + artifact rather than this sidecar; recovery tooling verifies the sidecar + before trusting historical manifest metadata. +5. Reject the candidate if its durable revocation marker exists. Read the + versioned object back and verify its bytes or digest, content type, + cache policy, and updater-release validation result. Versioned manifests use + a long-lived immutable cache policy, for example + `public, max-age=31536000, immutable`. +6. Promote the channel pointer last by uploading the same validated bytes to: + + ```text + /latest.json + ``` + + Use compare-and-swap: `If-Match` with the ETag captured before validation, or + `If-None-Match: *` for an empty channel. The pointer uses a revalidation + policy such as `no-cache, no-store, must-revalidate`. Read it back through + both the R2 S3 API and the exact client URL at + `https://release.gitru.app//latest.json`. Retry the public read until + it serves byte-identical content with JSON and no-store cache headers; a + successful origin write alone is not publication success. + +Perform the expensive artifact downloads, signature checks, and candidate +manifest signing in an unlocked preparation job. Transfer the validated +manifest, sidecar, report, validator binary, and exact configuration inputs with +pinned artifact actions. Each preparation attempt has a distinct evidence +artifact; failed-job retries reuse the successful preparation job's exact +artifact instead of rebuilding release bytes. Serialize only the short metadata +revalidation and manifest promotion job per channel, with a bounded timeout and +the full pending queue enabled. Never cancel or displace an in-progress or +already-queued feed operation. Builds and artifact verification do not hold +this lock, so an emergency stop can run while they proceed; the later publisher +then observes the durable revocation marker and fails closed. The ETag +compare-and-swap also prevents a stale release or operator action from +clobbering a newer pointer. Publishing the create-only versioned object first +ensures a failed promotion can be retried without changing release history; +byte-identical retries are safe. + +## Stop a rollout and recover + +If a release is harmful, first stop future adoption: + +1. Identify the last known-good immutable manifest and validate it again. It + must not have its own durable revocation marker; a revoked version is never + eligible as a fallback. +2. Verify its versioned `latest.json.sig`, then re-download and verify every + referenced updater artifact. During the initial rollout of this policy, + historical manifests may predate the sidecar. That legacy absence requires + selecting `ALLOW_LEGACY_WITHOUT_SIDECAR` and never skips embedded signature + verification for every referenced artifact. After those artifacts pass, the + workflow signs the exact historical manifest, conditionally backfills its + create-only sidecar, and verifies the sidecar readback before changing the + pointer. If a sidecar already exists, an invalid sidecar is a hard failure. +3. Before changing the pointer, preserve the exact current pointer bytes at the + create-only, hash-addressed key + `/revocation-evidence//.bin`, then verify its + bytes, digest, and immutable headers. Create the immutable rollout marker + `/revocations/.json` with `If-None-Match: *`. The marker + records the initiating evidence key and digest and makes the stop sticky: + delayed or rerun release jobs reject that version. A later out-of-band + corruption of the same asserted version produces a new hash-addressed + evidence object and incident summary without weakening the existing marker. +4. Repoint only `/latest.json` to those exact known-good bytes with + `If-Match` against the current pointer's previously captured ETag. +5. Read the pointer back through R2 and the exact public client URL. Verify exact + bytes, JSON/no-store headers, origin rules, and artifact signatures. Record + the affected and restored versions plus the revocation marker in the + incident. + +If the current pointer itself is malformed, recovery must still be possible. +Capture its bytes and ETag, fully validate the fallback as above, enter the +asserted current version, and use the distinct `STOP_CORRUPT_ROLLOUT` +confirmation. The validator accepts this mode only when strict current-pointer +validation actually fails, checks that the asserted version is canonical for +the selected channel and newer than the fallback, and rejects a different +readable version in the corrupt JSON. Preserve the corrupt bytes themselves +under the hash-addressed evidence key and record its SHA-256/key in the +revocation marker and run summary, tombstone the asserted version, then replace +only the ETag returned with those downloaded bytes. A valid pointer requires +ordinary `STOP_ROLLOUT` and cannot use the corrupt override. + +This pointer change protects only clients that have not already installed the +bad version. It does not downgrade or repair clients that already updated. + +The real rollback is a forward recovery release: revert to known-good source or +apply the fix, assign a version strictly higher than the bad release, build and +sign fresh artifacts, validate them, and publish them through the normal +immutable-first flow. Never reuse a version, mutate a versioned manifest, or +publish a lower version as a repair. + +## Qualification boundary + +RURU-93 remains deliberately inert: it uses a disposable application identity, +has updater artifacts and endpoints disabled, and rejects updater commands. +These controls must not be weakened to make this runbook pass. Native +previous-to-candidate installation, relaunch, failed-update recovery, and +architecture-aware delivery remain later promotion gates for RURU-88/RURU-93. + +Apple Developer ID signing and notarization, plus Windows Authenticode signing +and SmartScreen reputation, require paid external credentials and remain +unverified. The validation and publishing controls above prove feed and artifact +integrity; they do not substitute for either platform's trust checks. diff --git a/docs/testing.md b/docs/testing.md index 8205718..be54e3d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,6 +43,28 @@ configuration whose identifier is not exactly `com.ruru.gitru`, and then runs revision or failing production revision therefore cannot publish application artifacts or the updater manifest. +After all platform builds finish, publication also validates the release +channel and historical version high-water mark, downloads every expected +updater artifact, verifies its embedded Minisign signature, creates signed +create-only manifest history, and compare-and-swaps the mutable channel pointer +last. Expensive artifact verification/signing runs outside the channel lock; +pinned artifact actions hand a compact evidence bundle to the bounded, +metadata-only promotion job, whose full pending queue prevents a later run from +displacing an already-queued publish or emergency stop. Rollout stops write +permanent per-version revocation markers and support pre-sidecar historical +manifests only with explicit legacy opt-in, full artifact-signature +verification, and a verified create-only sidecar backfill. +Normal publication validates the actual current pointer and folds its version +into the high-water mark even when versioned history is missing. Emergency +control has a separately confirmed corrupt-pointer recovery path that preserves +the replaced bytes under a create-only hash-addressed evidence key, and revoked +versions are rejected as fallbacks. Both flows verify that the exact public +client URL converges to the R2 write. The platform release matrix is serialized +because the current Tauri action mutates one shared GitHub `latest.json` asset. +See the [updater integrity and recovery runbook](./releasing/updater-integrity.md). +These feed controls do not replace native install/relaunch qualification or +Apple and Windows publisher trust checks. + On the RURU-93 migration branch, CEF is the only desktop runtime. The cross-platform release-mode E2E job is therefore a required CEF/CDP gate, and the Rust quality and test jobs install the same CEF/GTK4 prerequisites. The