From 8f25301d4c00a58b712b5861634792f5727e317b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 23 Sep 2026 16:32:42 +0100 Subject: [PATCH 01/27] feat: add public Checkmate Launcher metadata --- desktop/README.md | 18 ++++++++++++++++++ lapkb-app.json | 12 ++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 lapkb-app.json diff --git a/desktop/README.md b/desktop/README.md index 3a43b44..1812495 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -94,6 +94,24 @@ it does not use the bacterial growth ODE or `get_e2()`. Subsequent regimen simulation/results stages, embedding static plot images in exported workbooks, installer signing, and desktop end-to-end automation remain later migration phases. +## LAPKB Launcher + +The repository-root `lapkb-app.json` supplies Checkmate's discovery metadata to +LAPKB Launcher. Launcher takes the displayed name and bundle identifier from +`desktop/src-tauri/tauri.conf.json`: **Checkmate** and `org.lapkb.checkmate`. +The native executable is `checkmate-desktop`. + +Checkmate is public: neither opening it directly nor opening it through Launcher +requires a LAPKB sign-in or a product role. The stable Launcher logical ID remains +`checkerboard`; this preserves the catalog identity across the desktop rename, +not a second app entry or an alias for the old bundle identifier. + +A Launcher build must include the regenerated Checkmate catalog entry to discover +the renamed app. This metadata does not supply an installer or an update feed. +Install/Update support requires a separately verified release archive and trusted +update source; until then, install Checkmate independently and use Launcher to +open the discovered installation. + ## Commands ```sh diff --git a/lapkb-app.json b/lapkb-app.json new file mode 100644 index 0000000..0909787 --- /dev/null +++ b/lapkb-app.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/LAPKB/desktop-releases/main/schemas/app-source-v1.schema.json", + "schemaVersion": 1, + "id": "checkerboard", + "description": "Analyze drug interactions and compare combination regimens.", + "accessPolicy": { + "kind": "public" + }, + "icon": "desktop/src-tauri/icons/128x128.png", + "tauriConfig": "desktop/src-tauri/tauri.conf.json", + "executableAliases": ["checkmate-desktop"] +} From 99094bde26dd2047f37a91c7db744d91bb39b28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Mon, 28 Sep 2026 14:32:08 +0200 Subject: [PATCH 02/27] ci: build Checkmate for six targets on hosted public runners --- .github/workflows/ci.yml | 149 ++++++++++++ scripts/ci/build-container-candidate.sh | 202 ++++++++++++++++ scripts/ci/build-macos-candidate.sh | 38 ++++ scripts/ci/linux-native.Dockerfile | 58 +++++ .../ci/linux-native.Dockerfile.dockerignore | 6 + scripts/ci/owned-temp-dir.py | 98 ++++++++ scripts/ci/validate-source.sh | 14 ++ scripts/ci/verify-linux-artifacts.mjs | 215 ++++++++++++++++++ scripts/ci/verify-windows-artifacts.mjs | 67 ++++++ scripts/ci/windows-cross.Dockerfile | 67 ++++++ .../ci/windows-cross.Dockerfile.dockerignore | 6 + 11 files changed, 920 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/ci/build-container-candidate.sh create mode 100644 scripts/ci/build-macos-candidate.sh create mode 100644 scripts/ci/linux-native.Dockerfile create mode 100644 scripts/ci/linux-native.Dockerfile.dockerignore create mode 100644 scripts/ci/owned-temp-dir.py create mode 100755 scripts/ci/validate-source.sh create mode 100644 scripts/ci/verify-linux-artifacts.mjs create mode 100644 scripts/ci/verify-windows-artifacts.mjs create mode 100644 scripts/ci/windows-cross.Dockerfile create mode 100644 scripts/ci/windows-cross.Dockerfile.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b74c5f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,149 @@ +name: Checkmate CI + +on: + push: + branches: [launcher-checkmate-support] + pull_request: + branches: [launcher-checkmate-support] + workflow_dispatch: + inputs: + source_sha: + description: Full commit SHA reachable from launcher-checkmate-support + required: true + type: string + +permissions: + contents: read + +env: + CHECKMATE_SOURCE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.source_sha || github.sha }} + CHECKMATE_REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} + CARGO_BUILD_JOBS: "2" + GIT_TERMINAL_PROMPT: "0" + +jobs: + macos: + name: macOS ARM64, then Intel build candidates + if: >- + github.repository == 'LAPKB/Checkerboard' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/launcher-checkmate-support') + runs-on: macos-15 + timeout-minutes: 180 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.CHECKMATE_SOURCE_SHA }} + fetch-depth: 0 + persist-credentials: false + - name: Verify requested source + if: github.event_name == 'workflow_dispatch' + run: bash scripts/ci/validate-source.sh "$CHECKMATE_SOURCE_SHA" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24 + cache: npm + cache-dependency-path: desktop/package-lock.json + - name: Create private Rust build directories + id: rust_home + run: | + set -euo pipefail + root="$(mktemp -d "$RUNNER_TEMP/checkmate-rust.XXXXXXXXXX")" + root="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$root")" + chmod 700 "$root" + mkdir -m 700 "$root/cargo" "$root/rustup" "$root/target" + identity="$(python3 -c 'import os,sys; s=os.lstat(sys.argv[1]); print(f"{s.st_dev}:{s.st_ino}")' "$root")" + printf 'CARGO_HOME=%s/cargo\nRUSTUP_HOME=%s/rustup\nCARGO_TARGET_DIR=%s/target\n' "$root" "$root" "$root" >> "$GITHUB_ENV" + printf 'directory=%s\nidentity=%s\n' "$root" "$identity" >> "$GITHUB_OUTPUT" + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 + with: + toolchain: 1.97.1 + target: aarch64-apple-darwin + rustflags: "" + cache: false + - name: Build frontend + working-directory: desktop + run: npm ci && npm exec -- tsc && npm exec -- vite build + - name: Fetch locked public dependencies + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" + GIT_SSH_COMMAND: /usr/bin/false + run: cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + - name: Build ARM64 app and DMG + run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin + - name: Retain ARM64 artifacts before Intel build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-arm64-build-candidate + path: | + ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/dmg/*.dmg + ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Install Intel Rust target + run: rustup target add --toolchain 1.97.1 x86_64-apple-darwin + - name: Build Intel app and DMG + run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-x64-build-candidate + path: | + ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/dmg/*.dmg + ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Remove this job's Rust directories + if: always() + env: + RUST_HOME_DIR: ${{ steps.rust_home.outputs.directory }} + RUST_HOME_IDENTITY: ${{ steps.rust_home.outputs.identity }} + run: python3 scripts/ci/owned-temp-dir.py cleanup "$RUNNER_TEMP" "$RUST_HOME_DIR" checkmate-rust "$RUST_HOME_IDENTITY" + + containers: + name: ${{ matrix.target }} build candidate + if: >- + github.repository == 'LAPKB/Checkerboard' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/launcher-checkmate-support') + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + - target: x86_64-pc-windows-msvc + runner: ubuntu-24.04 + - target: aarch64-pc-windows-msvc + runner: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.CHECKMATE_SOURCE_SHA }} + fetch-depth: 0 + persist-credentials: false + - name: Verify requested source + if: github.event_name == 'workflow_dispatch' + run: bash scripts/ci/validate-source.sh "$CHECKMATE_SOURCE_SHA" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24 + - name: Build and verify packages without private credentials + id: build + run: bash scripts/ci/build-container-candidate.sh "${{ matrix.target }}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-${{ matrix.target }}-build-candidate + path: ${{ steps.build.outputs.artifact_dir }} + if-no-files-found: error + retention-days: 7 + - name: Remove this job's artifact directory + if: always() + env: + ARTIFACT_DIR: ${{ steps.build.outputs.artifact_dir }} + ARTIFACT_IDENTITY: ${{ steps.build.outputs.artifact_identity }} + run: python3 scripts/ci/owned-temp-dir.py cleanup "$RUNNER_TEMP" "$ARTIFACT_DIR" checkmate-artifacts "$ARTIFACT_IDENTITY" diff --git a/scripts/ci/build-container-candidate.sh b/scripts/ci/build-container-candidate.sh new file mode 100755 index 0000000..5f2403f --- /dev/null +++ b/scripts/ci/build-container-candidate.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: build-container-candidate.sh }" +case "$target" in +x86_64-pc-windows-msvc | aarch64-pc-windows-msvc) + kind=windows + platform=linux/amd64 + target_argument=WINDOWS_TARGET + ;; +x86_64-unknown-linux-gnu) + kind=linux + platform=linux/amd64 + target_argument=LINUX_TARGET + [[ "$(uname -m)" == x86_64 ]] || { + printf 'Linux target requires a native x64 runner\n' >&2 + exit 2 + } + ;; +aarch64-unknown-linux-gnu) + kind=linux + platform=linux/arm64 + target_argument=LINUX_TARGET + [[ "$(uname -m)" == aarch64 || "$(uname -m)" == arm64 ]] || { + printf 'Linux target requires a native ARM64 runner\n' >&2 + exit 2 + } + ;; +*) + printf 'Unsupported container target: %s\n' "$target" >&2 + exit 2 + ;; +esac +artifact_prefix=checkmate-artifacts +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +repo_root="$(git rev-parse --show-toplevel)" +runner_temp_real="$(realpath -e -- "$RUNNER_TEMP")" +runner_uid="$(id -u)" +runner_gid="$(id -g)" +: "${CHECKMATE_SOURCE_SHA:?CHECKMATE_SOURCE_SHA is required}" +[[ "$(git rev-parse HEAD)" == "$CHECKMATE_SOURCE_SHA" ]] || { echo 'Unexpected source commit' >&2; exit 1; } +unset DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG BUILDX_CONFIG SSH_AUTH_SOCK SSH_AGENT_PID + +private_config='' +artifact_dir='' +config_identity='' +artifact_identity='' +config_created=0 +artifact_created=0 +config_chown_attempted=0 +artifact_chown_attempted=0 +artifact_may_be_root=0 +build_succeeded=0 +builder_creation_started=0 +builder='' +docker_mode='' +docker_argv=() + +validate_private_dir() { + local path="$1" prefix="$2" identity="$3" owner_mode="${4:-runner}" + python3 "$repo_root/scripts/ci/owned-temp-dir.py" validate \ + "$runner_temp_real" "$path" "$prefix" "$identity" "$owner_mode" +} + +restore_private_dir_owner() { + local path="$1" prefix="$2" identity="$3" + if ! validate_private_dir "$path" "$prefix" "$identity" root-or-runner; then + printf 'Refusing ownership change outside the validated private directory\n' >&2 + return 1 + fi + if ! sudo -n -- /usr/bin/chown -R --no-dereference --from=0 "$runner_uid:$runner_gid" -- "$path"; then + printf 'Scoped ownership restoration was denied for a job-private directory\n' >&2 + return 1 + fi + if ! validate_private_dir "$path" "$prefix" "$identity" || + [[ "$(stat -c '%u:%g' -- "$path")" != "$runner_uid:$runner_gid" ]]; then + printf 'Private directory ownership did not return to the runner\n' >&2 + return 1 + fi +} + +cleanup() { + local status=$? + trap - EXIT INT TERM HUP + set +e + + if [[ "$builder_creation_started" == 1 ]]; then + if ! "${docker_argv[@]}" buildx rm --force "$builder" >/dev/null; then + printf "Could not remove this job's Buildx builder/cache: %s\n" "$builder" >&2 + status=1 + fi + fi + + if [[ "$config_created" == 1 ]]; then + if validate_private_dir "$private_config" checkmate-buildx "$config_identity" root-or-runner; then + if [[ "$docker_mode" == sudo && "$config_chown_attempted" == 0 ]]; then + config_chown_attempted=1 + if ! restore_private_dir_owner "$private_config" checkmate-buildx "$config_identity"; then + status=1 + fi + fi + if ! python3 "$repo_root/scripts/ci/owned-temp-dir.py" cleanup \ + "$runner_temp_real" "$private_config" checkmate-buildx "$config_identity"; then + status=1 + fi + else + printf 'Refusing cleanup of an unexpected Docker config path\n' >&2 + status=1 + fi + fi + + if [[ "$artifact_created" == 1 && "$build_succeeded" != 1 ]]; then + if validate_private_dir "$artifact_dir" "$artifact_prefix" "$artifact_identity" root-or-runner; then + if [[ "$docker_mode" == sudo && "$artifact_may_be_root" == 1 && "$artifact_chown_attempted" == 0 ]]; then + artifact_chown_attempted=1 + if ! restore_private_dir_owner "$artifact_dir" "$artifact_prefix" "$artifact_identity"; then + status=1 + fi + fi + if ! python3 "$repo_root/scripts/ci/owned-temp-dir.py" cleanup \ + "$runner_temp_real" "$artifact_dir" "$artifact_prefix" "$artifact_identity"; then + status=1 + fi + else + printf 'Refusing cleanup of an unexpected container output path\n' >&2 + status=1 + fi + fi + + exit "$status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +private_config="$(mktemp -d "$runner_temp_real/checkmate-buildx.XXXXXXXXXX")" +config_created=1 +config_identity="$(stat -c '%d:%i' -- "$private_config")" +chmod 700 "$private_config" +artifact_dir="$(mktemp -d "$runner_temp_real/$artifact_prefix.XXXXXXXXXX")" +artifact_created=1 +artifact_identity="$(stat -c '%d:%i' -- "$artifact_dir")" +chmod 700 "$artifact_dir" +validate_private_dir "$private_config" checkmate-buildx "$config_identity" +validate_private_dir "$artifact_dir" "$artifact_prefix" "$artifact_identity" + +random_suffix="$(od -An -N16 -tx1 /dev/urandom | tr -d '[:space:]')" +[[ "$random_suffix" =~ ^[0-9a-f]{32}$ ]] || { + printf 'Could not create a unique builder suffix\n' >&2 + exit 1 +} +builder="checkmate-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${target}-${random_suffix}" + +docker_flags=(--host unix:///var/run/docker.sock --config "$private_config") +direct_docker_argv=(/usr/bin/docker "${docker_flags[@]}") +if "${direct_docker_argv[@]}" version >/dev/null 2>&1; then + docker_argv=("${direct_docker_argv[@]}") + docker_mode=direct +else + docker_argv=(sudo -n -- /usr/bin/docker "${docker_flags[@]}") + docker_mode=sudo +fi + +cd "$repo_root" +case "$kind" in + linux) dockerfile=scripts/ci/linux-native.Dockerfile ;; + windows) dockerfile=scripts/ci/windows-cross.Dockerfile ;; +esac +builder_creation_started=1 +"${docker_argv[@]}" buildx create --name "$builder" --driver docker-container >/dev/null +"${docker_argv[@]}" buildx inspect "$builder" --bootstrap >/dev/null +artifact_may_be_root=1 +"${docker_argv[@]}" buildx build \ + --builder "$builder" \ + --platform "$platform" \ + --build-arg "$target_argument=$target" \ + --build-arg CARGO_BUILD_JOBS=2 \ + --output "type=local,dest=$artifact_dir" \ + --file "$dockerfile" \ + "$repo_root" + +if [[ "$docker_mode" == sudo ]]; then + artifact_chown_attempted=1 + restore_private_dir_owner "$artifact_dir" "$artifact_prefix" "$artifact_identity" +fi +if [[ "$kind" == windows ]]; then + node scripts/ci/verify-windows-artifacts.mjs "$target" "$artifact_dir" +else + node scripts/ci/verify-linux-artifacts.mjs "$target" "$artifact_dir" --receipt +fi +case "$artifact_dir" in +*$'\n'* | *$'\r'*) + printf 'Invalid artifact output path\n' >&2 + exit 1 + ;; +esac +printf 'artifact_dir=%s\nartifact_identity=%s\n' "$artifact_dir" "$artifact_identity" >>"$GITHUB_OUTPUT" +build_succeeded=1 diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh new file mode 100644 index 0000000..05afc83 --- /dev/null +++ b/scripts/ci/build-macos-candidate.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: build-macos-candidate.sh }" +case "$target" in + aarch64-apple-darwin) expected_arch=arm64; target_label=ARM64; cross_note='ARM64 build; native runtime not exercised.' ;; + x86_64-apple-darwin) expected_arch=x86_64; target_label=x64; cross_note='Intel build; native runtime not exercised.' ;; + *) printf 'Unsupported macOS target: %s\n' "$target" >&2; exit 2 ;; +esac + +(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg --config '{"build":{"beforeBuildCommand":""}}' --ci --no-sign -- --locked --offline) +bundle="${CARGO_TARGET_DIR:?CARGO_TARGET_DIR is required}/$target/release/bundle" +app="$bundle/macos/Checkmate.app" +plist="$app/Contents/Info.plist" +test -d "$app" +test -s "$plist" +executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$plist")" +if [[ -z "$executable_name" || "$executable_name" == */* || "$executable_name" == . || "$executable_name" == .. ]]; then + printf 'Invalid CFBundleExecutable in %s\n' "$plist" >&2 + exit 1 +fi +executable="$app/Contents/MacOS/$executable_name" +test -s "$executable" +actual_arch="$(/usr/bin/lipo -archs "$executable")" +if [[ "$actual_arch" != "$expected_arch" ]]; then + printf 'Packaged macOS executable architecture mismatch: expected %s, found %s\n' "$expected_arch" "$actual_arch" >&2 + exit 1 +fi +shopt -s nullglob +disks=("$bundle/dmg/"*.dmg) +if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then + printf 'Expected exactly one non-empty macOS DMG in %s/dmg\n' "$bundle" >&2 + exit 1 +fi +tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app +test -s "$bundle/macos/Checkmate.app.tar.gz" +printf '### Unsigned macOS %s build candidate\n\n- **No trusted verifier configuration; not usable as licensed pilots.**\n- No code signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ + "$target_label" "$actual_arch" "$cross_note" >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/ci/linux-native.Dockerfile b/scripts/ci/linux-native.Dockerfile new file mode 100644 index 0000000..adeec67 --- /dev/null +++ b/scripts/ci/linux-native.Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1.7 +FROM node:24-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe AS node-runtime +FROM rust:1.97.1-slim-trixie@sha256:8e8cf8f7fd54a2d23d5a743b3a03f56e26b6c774276c33fa0595111704ebb15c AS linux-builder + +ARG LINUX_TARGET +ARG CARGO_BUILD_JOBS=2 +ENV CARGO_HOME=/tmp/checkmate-cargo-home \ + RUSTUP_HOME=/usr/local/rustup \ + CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} \ + CARGO_NET_GIT_FETCH_WITH_CLI=true \ + APPIMAGE_EXTRACT_AND_RUN=1 \ + PATH="/usr/local/cargo/bin:${PATH}" +COPY --from=node-runtime /usr/local/ /usr/local/ +RUN chmod 700 "$RUSTUP_HOME" \ + && install -d -m 700 "$CARGO_HOME" /root/.ssh +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + build-essential cmake ca-certificates curl file git libarchive-tools libayatana-appindicator3-dev \ + libgtk-3-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev librsvg2-dev \ + openssh-client patchelf pkg-config python3 rpm wget xdg-utils \ + && test -x /usr/bin/xdg-open \ + && rm -rf /var/lib/apt/lists/* +RUN case "$(uname -m):$LINUX_TARGET" in \ + x86_64:x86_64-unknown-linux-gnu|aarch64:aarch64-unknown-linux-gnu) ;; \ + *) echo "Linux candidate requires a matching native container architecture" >&2; exit 2 ;; \ + esac \ + && rustup target add "$LINUX_TARGET" + +WORKDIR /workspace +COPY . . +RUN cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build + +# This CI-only source uses public dependencies; no private credentials are mounted. +RUN GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_SSH_COMMAND=/bin/false \ + cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + +ENV CARGO_NET_OFFLINE=true \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_SSH_COMMAND=/bin/false +RUN --network=none cd desktop && npm run tauri -- build --target "$LINUX_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline +# AppImage packaging can fetch public linuxdeploy helpers; no SSH mount or keys remain. +RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage,deb,rpm --ci --no-sign +RUN set -eu; \ + base="desktop/src-tauri/target/$LINUX_TARGET/release"; \ + install -d /out; \ + cp "$base/checkmate-desktop" /out/checkmate; \ + for kind in appimage deb rpm; do \ + case "$kind" in appimage) extension=AppImage ;; *) extension="$kind" ;; esac; \ + set -- "$base/bundle/$kind/"*."$extension"; \ + if [ "$#" -ne 1 ] || [ ! -s "$1" ]; then echo "Expected one non-empty $kind artifact" >&2; exit 1; fi; \ + cp "$1" "/out/checkmate.$extension"; \ + done; \ + node scripts/ci/verify-linux-artifacts.mjs "$LINUX_TARGET" /out; \ + chmod 644 /out/* + +FROM scratch AS ci-artifacts +COPY --from=linux-builder /out/ / diff --git a/scripts/ci/linux-native.Dockerfile.dockerignore b/scripts/ci/linux-native.Dockerfile.dockerignore new file mode 100644 index 0000000..8b2b5e1 --- /dev/null +++ b/scripts/ci/linux-native.Dockerfile.dockerignore @@ -0,0 +1,6 @@ +.git +.pi +.worktrees +**/node_modules +**/dist +**/target diff --git a/scripts/ci/owned-temp-dir.py b/scripts/ci/owned-temp-dir.py new file mode 100644 index 0000000..38ca8a5 --- /dev/null +++ b/scripts/ci/owned-temp-dir.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +import os +import pathlib +import shutil +import stat +import sys + + +def refuse(reason): + print(f"Refusing temporary-directory operation: {reason}", file=sys.stderr) + return 1 + + +def validated(root_value, path_value, prefix, identity, allow_root_owner=False): + try: + root = pathlib.Path(root_value).resolve(strict=True) + root_info = os.lstat(root) + if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode) + or root_info.st_uid != os.getuid()): + return None, "temporary root is not a runner-owned directory" + path = pathlib.Path(path_value) + if (not path.is_absolute() or path.parent != root + or not path.name.startswith(prefix + ".")): + return None, "path is outside the expected temporary-directory prefix" + info = os.lstat(path) + if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode) + or path.resolve(strict=True) != path): + return None, "path is not a real directory" + if info.st_dev != root_info.st_dev: + return None, "path is on a different device" + if identity != f"{info.st_dev}:{info.st_ino}": + return None, "directory identity changed" + owners = {os.getuid(), 0} if allow_root_owner else {os.getuid()} + if info.st_uid not in owners: + return None, "directory is not owned by this runner" + if stat.S_IMODE(info.st_mode) != 0o700: + return None, "directory permissions changed" + return (root, path, root_info, info), None + except (OSError, ValueError): + return None, "path or identity could not be validated" + + +def main(): + if len(sys.argv) not in (6, 7): + return refuse("usage: owned-temp-dir.py validate|cleanup RUNNER_TEMP PATH PREFIX DEVICE:INODE [runner|root-or-runner]") + operation, root_value, path_value, prefix, identity = sys.argv[1:6] + ownership_mode = sys.argv[6] if len(sys.argv) == 7 else "runner" + if ownership_mode not in ("runner", "root-or-runner"): + return refuse("invalid ownership mode") + allow_root_owner = ownership_mode == "root-or-runner" + if operation == "cleanup" and path_value == "" and identity == "": + return 0 + if operation not in ("validate", "cleanup"): + return refuse("unknown operation") + if not path_value or not identity: + return refuse("path and directory identity must be supplied together") + result, error = validated(root_value, path_value, prefix, identity, allow_root_owner) + if error: + return refuse(error) + if operation == "validate": + return 0 + if allow_root_owner or result[3].st_uid != os.getuid(): + return refuse("cleanup requires runner ownership") + if not getattr(shutil.rmtree, "avoids_symlink_attacks", False): + return refuse("platform cannot safely remove a directory tree") + + root, path, root_info, info = result + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + root_fd = os.open(root, flags) + try: + opened_root = os.fstat(root_fd) + if (opened_root.st_dev, opened_root.st_ino) != (root_info.st_dev, root_info.st_ino): + return refuse("temporary root changed during cleanup") + current = os.stat(path.name, dir_fd=root_fd, follow_symlinks=False) + if (not stat.S_ISDIR(current.st_mode) or stat.S_ISLNK(current.st_mode) + or (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino) + or current.st_uid != os.getuid() or stat.S_IMODE(current.st_mode) != 0o700): + return refuse("directory changed before cleanup") + # Python 3.8 on the approved runners has fd-safe rmtree but no + # dir_fd argument. Anchor its relative path to the opened root in + # this single-threaded helper; never fall back to an absolute path. + previous_cwd = os.open(".", flags) + try: + os.fchdir(root_fd) + shutil.rmtree(path.name) + finally: + os.fchdir(previous_cwd) + os.close(previous_cwd) + finally: + os.close(root_fd) + except OSError: + return refuse("validated directory could not be removed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/validate-source.sh b/scripts/ci/validate-source.sh new file mode 100755 index 0000000..327702b --- /dev/null +++ b/scripts/ci/validate-source.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_sha="${1:?usage: validate-source.sh }" +: "${CHECKMATE_REQUESTED_SOURCE_SHA:?CHECKMATE_REQUESTED_SOURCE_SHA is required}" +: "${GITHUB_REF:?GITHUB_REF is required}" +[[ "$source_sha" == "$CHECKMATE_REQUESTED_SOURCE_SHA" ]] || { printf 'Source SHA does not match the required manual input\n' >&2; exit 1; } +[[ "$GITHUB_REF" == refs/heads/launcher-checkmate-support ]] || { printf 'Manual candidate builds must be dispatched from the launcher-checkmate-support integration branch\n' >&2; exit 1; } +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { printf 'Source SHA must be a full 40-character commit\n' >&2; exit 1; } +head_sha="$(git rev-parse HEAD)" +[[ "$head_sha" == "$source_sha" ]] || { printf 'Checked-out source does not match the requested source SHA\n' >&2; exit 1; } +git show-ref --verify --quiet refs/remotes/origin/launcher-checkmate-support || { printf 'Fetched launcher-checkmate-support integration branch ref is missing\n' >&2; exit 1; } +git merge-base --is-ancestor "$source_sha" refs/remotes/origin/launcher-checkmate-support || { printf 'Requested source SHA is not reachable from origin/launcher-checkmate-support\n' >&2; exit 1; } +printf 'Verified full source commit %s is on origin/launcher-checkmate-support.\n' "$source_sha" diff --git a/scripts/ci/verify-linux-artifacts.mjs b/scripts/ci/verify-linux-artifacts.mjs new file mode 100644 index 0000000..0b7cb59 --- /dev/null +++ b/scripts/ci/verify-linux-artifacts.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + createReadStream, + lstatSync, + openSync, + closeSync, + readSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (cause) { + throw new Error(`Cannot read candidate JSON: ${path}`, { cause }); + } +} + +const version = readJson( + new URL("../../desktop/package.json", import.meta.url), +).version; +assert.equal( + readJson(new URL("../../desktop/src-tauri/tauri.conf.json", import.meta.url)) + .productName, + "Checkmate", +); +// Tauri 2.11.4 derives deb/RPM names with heck::AsKebabCase(productName). +const packageName = "checkmate"; +const targets = { + "x86_64-unknown-linux-gnu": { machine: 62, deb: "amd64", rpm: "x86_64" }, + "aarch64-unknown-linux-gnu": { machine: 183, deb: "arm64", rpm: "aarch64" }, +}; +const files = [ + "checkmate", + "checkmate.AppImage", + "checkmate.deb", + "checkmate.rpm", +]; +const receiptName = "candidate.json"; + +export function assertElf(bytes, machine) { + assert(bytes.length >= 64, "Truncated ELF header"); + assert( + bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])), + "Not an ELF executable", + ); + assert.equal(bytes[4], 2, "Expected a 64-bit ELF executable"); + assert.equal(bytes[5], 1, "Expected little-endian ELF"); + assert( + [2, 3].includes(bytes.readUInt16LE(16)), + "Expected executable or PIE ELF", + ); + assert.equal(bytes.readUInt16LE(18), machine, "Wrong ELF architecture"); +} + +function header(path, size = 64) { + const fd = openSync(path, "r"); + try { + const bytes = Buffer.alloc(size); + assert.equal(readSync(fd, bytes, 0, size, 0), size, "Truncated artifact"); + return bytes; + } finally { + closeSync(fd); + } +} + +async function describe(directory, name) { + const path = join(directory, name); + const info = lstatSync(path); + assert( + info.isFile() && !info.isSymbolicLink() && info.nlink === 1, + "Artifact must be a regular, unlinked file", + ); + assert( + info.size > 0 && info.size <= 1024 * 1024 * 1024, + "Artifact size is invalid", + ); + const hash = createHash("sha256"); + for await (const part of createReadStream(path)) hash.update(part); + return { name, bytes: info.size, sha256: hash.digest("hex") }; +} + +function verifyPackages(target, directory, appHash, run) { + const expected = targets[target]; + const options = { maxBuffer: 512 * 1024 * 1024, timeout: 120000 }; + const deb = join(directory, "checkmate.deb"); + for (const [field, value] of [ + ["Package", packageName], + ["Version", version], + ["Architecture", expected.deb], + ]) { + assert.equal( + run("dpkg-deb", ["--field", deb, field], options).toString().trim(), + value, + `Wrong deb ${field}`, + ); + } + const tar = run("dpkg-deb", ["--fsys-tarfile", deb], options); + const debApp = run("bsdtar", ["-xOf", "-", "./usr/bin/checkmate-desktop"], { + ...options, + input: tar, + }); + const rpm = join(directory, "checkmate.rpm"); + const rpmFields = run( + "rpm", + ["-qp", "--queryformat", "%{NAME}\n%{VERSION}\n%{ARCH}\n", rpm], + options, + ) + .toString() + .trim() + .split("\n"); + assert.deepEqual( + rpmFields, + [packageName, version, expected.rpm], + "Wrong RPM identity, version or architecture", + ); + const rpmApp = run("bsdtar", ["-xOf", rpm, "./usr/bin/checkmate-desktop"], options); + for (const bytes of [debApp, rpmApp]) { + assertElf(bytes, expected.machine); + assert.equal( + createHash("sha256").update(bytes).digest("hex"), + appHash, + "Packaged app differs from the verified executable", + ); + } +} + +export async function verifyLinuxArtifacts( + target, + directory, + { receiptOnly = false, run = execFileSync } = {}, +) { + const expected = targets[target]; + assert(expected, "Unsupported native Linux target"); + assert.deepEqual( + readdirSync(directory).sort(), + [...files, ...(receiptOnly ? [receiptName] : [])].sort(), + "Unexpected candidate output files", + ); + const records = []; + for (const name of files) records.push(await describe(directory, name)); + assertElf(header(join(directory, "checkmate")), expected.machine); + const appImage = header(join(directory, "checkmate.AppImage")); + assertElf(appImage, expected.machine); + assert( + appImage.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 2])), + "Not a type-2 AppImage", + ); + assert( + header(join(directory, "checkmate.deb"), 8).equals( + Buffer.from("!\n"), + ), + "Not a Debian archive", + ); + assert( + header(join(directory, "checkmate.rpm"), 4).equals( + Buffer.from([0xed, 0xab, 0xee, 0xdb]), + ), + "Not an RPM archive", + ); + const receipt = { + appId: "checkerboard", + version, + target, + files: records, + debAndRpmPayloadVerified: true, + appImageRuntimeArchitectureVerified: true, + appImagePayloadVerified: false, + licenceTrustConfigured: false, + nativeRuntimeVerified: false, + }; + const path = join(directory, receiptName); + if (receiptOnly) { + const info = lstatSync(path); + assert( + info.isFile() && !info.isSymbolicLink() && info.size < 65536, + "Invalid candidate receipt", + ); + assert.deepEqual( + readJson(path), + receipt, + "Exported artifacts do not match their container verification receipt", + ); + } else { + verifyPackages(target, directory, records[0].sha256, run); + writeFileSync(path, JSON.stringify(receipt, null, 2) + "\n", { + flag: "wx", + mode: 0o600, + }); + } + return receipt; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + const [target, directory, mode] = process.argv.slice(2); + assert( + target && directory && (mode === undefined || mode === "--receipt"), + "Usage: verify-linux-artifacts.mjs [--receipt]", + ); + await verifyLinuxArtifacts(target, directory, { + receiptOnly: mode === "--receipt", + }); + console.log( + `Verified ${target} build candidate: AppImage, deb and rpm; no configured licence trust, package installation or native runtime acceptance.`, + ); +} diff --git a/scripts/ci/verify-windows-artifacts.mjs b/scripts/ci/verify-windows-artifacts.mjs new file mode 100644 index 0000000..fed9703 --- /dev/null +++ b/scripts/ci/verify-windows-artifacts.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { createReadStream, openSync, readSync, closeSync, statSync, appendFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +const [target, outputDirectory] = process.argv.slice(2); +const expectedMachines = new Map([ + ["x86_64-pc-windows-msvc", 0x8664], + ["aarch64-pc-windows-msvc", 0xaa64], +]); +const expectedMachine = expectedMachines.get(target); +assert(expectedMachine, `Unsupported Windows target: ${target ?? ""}`); +assert(outputDirectory, "Usage: verify-windows-artifacts.mjs "); + +const appPath = join(outputDirectory, "checkmate.exe"); +const installerPath = join(outputDirectory, "checkmate-nsis-installer.exe"); + +function readAppMachine(path) { + const descriptor = openSync(path, "r"); + try { + const dos = Buffer.alloc(64); + assert.equal(readSync(descriptor, dos, 0, dos.length, 0), dos.length, "Truncated DOS header"); + assert.equal(dos.readUInt16LE(0), 0x5a4d, "Checkmate app is not a PE executable"); + const peOffset = dos.readUInt32LE(0x3c); + const pe = Buffer.alloc(6); + assert.equal(readSync(descriptor, pe, 0, pe.length, peOffset), pe.length, "Truncated PE header"); + assert.equal(pe.readUInt32LE(0), 0x00004550, "Invalid PE signature"); + return pe.readUInt16LE(4); + } finally { + closeSync(descriptor); + } +} + +async function sha256(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +async function describe(label, path) { + const stat = statSync(path); + assert(stat.isFile() && stat.size > 0, `${label} is missing or empty: ${path}`); + return { label, size: stat.size, hash: await sha256(path) }; +} + +const machine = readAppMachine(appPath); +assert.equal( + machine, + expectedMachine, + `Checkmate PE machine is 0x${machine.toString(16)}, expected 0x${expectedMachine.toString(16)} for ${target}`, +); + +const records = [ + await describe("Checkmate app", appPath), + await describe("NSIS installer", installerPath), +]; +const lines = [ + `Unsigned Windows build candidate (no trusted verifier configuration; not usable as licensed pilots): ${target}; app PE machine 0x${machine.toString(16)}`, + ...records.map(({ label, size, hash }) => `${label}: ${size} bytes; SHA-256 ${hash}`), +]; +console.log(lines.join("\n")); +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### Unsigned Windows build candidate: ${target}\n\n- **No trusted verifier configuration; not usable as licensed pilots.**\n- Not native Windows execution, installation, or release acceptance.\n- App PE machine: 0x${machine.toString(16)}\n${lines.slice(1).map((line) => `- ${line}`).join("\n")}\n\n`, + ); +} diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile new file mode 100644 index 0000000..b0c226a --- /dev/null +++ b/scripts/ci/windows-cross.Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.7 +FROM messense/cargo-xwin@sha256:9856b895265d4966f212228ba64802cf89337e2a2a537aa2533c1b8784cbc81b AS cargo-xwin +FROM node:24-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe AS windows-builder + +ARG WINDOWS_TARGET +ARG CARGO_BUILD_JOBS=2 +ENV CARGO_HOME=/tmp/checkmate-cargo-home \ + RUSTUP_HOME=/tmp/checkmate-rustup-home \ + CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} \ + CARGO_NET_GIT_FETCH_WITH_CLI=true +ENV PATH="/tmp/checkmate-cargo-home/bin:${PATH}" + +COPY --from=cargo-xwin /usr/local/cargo/ /tmp/checkmate-cargo-home/ +COPY --from=cargo-xwin /usr/local/rustup/ /tmp/checkmate-rustup-home/ +RUN chmod 700 "$CARGO_HOME" "$RUSTUP_HOME" + +RUN apt-get update \ + && apt-get install --no-install-recommends -y build-essential cmake ca-certificates clang git llvm lld nsis openssh-client pkg-config \ + && rm -rf /var/lib/apt/lists/* \ + && cargo xwin --version + +RUN case "$WINDOWS_TARGET" in \ + x86_64-pc-windows-msvc|aarch64-pc-windows-msvc) ;; \ + *) echo "Unsupported Windows target: $WINDOWS_TARGET" >&2; exit 2 ;; \ + esac \ + && rustup toolchain install 1.97.1 --profile minimal \ + && rustup default 1.97.1 \ + && rustup target add "$WINDOWS_TARGET" + +WORKDIR /workspace +COPY . . + +RUN cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build + +RUN install -d -m 700 /root/.ssh + +RUN GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_SSH_COMMAND=/bin/false \ + cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + +ENV CARGO_NET_OFFLINE=true \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_SSH_COMMAND=/bin/false + +# Public CRT/SDK downloads happen without an SSH mount, before offline compilation. +RUN cargo xwin cache xwin + + +RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline +# Tauri may download public NSIS helpers; no source credentials remain in this step. +RUN cd desktop && npm run tauri -- bundle --target "$WINDOWS_TARGET" --bundles nsis --ci --no-sign + +RUN set -eu; \ + app="desktop/src-tauri/target/$WINDOWS_TARGET/release/checkmate-desktop.exe"; \ + nsis_dir="desktop/src-tauri/target/$WINDOWS_TARGET/release/bundle/nsis"; \ + test -s "$app"; \ + set -- "$nsis_dir"/*.exe; \ + if [ "$#" -ne 1 ] || [ ! -s "$1" ]; then \ + echo "Expected exactly one non-empty NSIS installer in $nsis_dir" >&2; exit 1; \ + fi; \ + install -d /out; \ + cp "$app" /out/checkmate.exe; \ + cp "$1" /out/checkmate-nsis-installer.exe; \ + chmod 644 /out/checkmate.exe /out/checkmate-nsis-installer.exe + +FROM scratch AS ci-artifacts +COPY --from=windows-builder /out/ / diff --git a/scripts/ci/windows-cross.Dockerfile.dockerignore b/scripts/ci/windows-cross.Dockerfile.dockerignore new file mode 100644 index 0000000..8b2b5e1 --- /dev/null +++ b/scripts/ci/windows-cross.Dockerfile.dockerignore @@ -0,0 +1,6 @@ +.git +.pi +.worktrees +**/node_modules +**/dist +**/target From 0b201cf94bad67011ab9198e2b5599fd01b5bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Mon, 28 Sep 2026 15:13:44 +0200 Subject: [PATCH 03/27] fix(ci): preserve the compiler binary before AppImage packaging --- scripts/ci/linux-native.Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ci/linux-native.Dockerfile b/scripts/ci/linux-native.Dockerfile index adeec67..d09390f 100644 --- a/scripts/ci/linux-native.Dockerfile +++ b/scripts/ci/linux-native.Dockerfile @@ -39,12 +39,14 @@ ENV CARGO_NET_OFFLINE=true \ GIT_CONFIG_NOSYSTEM=1 \ GIT_SSH_COMMAND=/bin/false RUN --network=none cd desktop && npm run tauri -- build --target "$LINUX_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline -# AppImage packaging can fetch public linuxdeploy helpers; no SSH mount or keys remain. -RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage,deb,rpm --ci --no-sign +# Keep the compiler output independent of AppImage's deployment/ELF rewriting. +RUN install -d /out \ + && cp "desktop/src-tauri/target/$LINUX_TARGET/release/checkmate-desktop" /out/checkmate +RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles deb,rpm --ci --no-sign +# Public linuxdeploy downloads happen without source credentials. +RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage --ci --no-sign RUN set -eu; \ base="desktop/src-tauri/target/$LINUX_TARGET/release"; \ - install -d /out; \ - cp "$base/checkmate-desktop" /out/checkmate; \ for kind in appimage deb rpm; do \ case "$kind" in appimage) extension=AppImage ;; *) extension="$kind" ;; esac; \ set -- "$base/bundle/$kind/"*."$extension"; \ From a2395ea80d86ca553c6e1b4738bcb040274893f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 29 Sep 2026 10:50:23 +0200 Subject: [PATCH 04/27] fix(ci): identify Linux package payload mismatches --- scripts/ci/verify-linux-artifacts.mjs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/ci/verify-linux-artifacts.mjs b/scripts/ci/verify-linux-artifacts.mjs index 0b7cb59..d2f4185 100644 --- a/scripts/ci/verify-linux-artifacts.mjs +++ b/scripts/ci/verify-linux-artifacts.mjs @@ -121,12 +121,29 @@ function verifyPackages(target, directory, appHash, run) { "Wrong RPM identity, version or architecture", ); const rpmApp = run("bsdtar", ["-xOf", rpm, "./usr/bin/checkmate-desktop"], options); - for (const bytes of [debApp, rpmApp]) { + const payloads = [ + { format: "deb", bytes: debApp }, + { format: "rpm", bytes: rpmApp }, + ].map(({ format, bytes }) => { assertElf(bytes, expected.machine); + return { + format, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + }); + console.log("Linux executable verification", { + compiler: { + bytes: lstatSync(join(directory, files[0])).size, + sha256: appHash, + }, + payloads, + }); + for (const payload of payloads) { assert.equal( - createHash("sha256").update(bytes).digest("hex"), + payload.sha256, appHash, - "Packaged app differs from the verified executable", + `${payload.format} packaged app differs from the verified executable`, ); } } From 0579a34bf1f13d0f297a55c1e890719a279a6ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 29 Sep 2026 11:05:42 +0200 Subject: [PATCH 05/27] fix(ci): verify Tauri's exact Linux bundle marker patch --- scripts/ci/linux-native.Dockerfile | 10 +++--- scripts/ci/verify-linux-artifacts.mjs | 48 +++++++++++++++------------ 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/scripts/ci/linux-native.Dockerfile b/scripts/ci/linux-native.Dockerfile index d09390f..adeec67 100644 --- a/scripts/ci/linux-native.Dockerfile +++ b/scripts/ci/linux-native.Dockerfile @@ -39,14 +39,12 @@ ENV CARGO_NET_OFFLINE=true \ GIT_CONFIG_NOSYSTEM=1 \ GIT_SSH_COMMAND=/bin/false RUN --network=none cd desktop && npm run tauri -- build --target "$LINUX_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline -# Keep the compiler output independent of AppImage's deployment/ELF rewriting. -RUN install -d /out \ - && cp "desktop/src-tauri/target/$LINUX_TARGET/release/checkmate-desktop" /out/checkmate -RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles deb,rpm --ci --no-sign -# Public linuxdeploy downloads happen without source credentials. -RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage --ci --no-sign +# AppImage packaging can fetch public linuxdeploy helpers; no SSH mount or keys remain. +RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage,deb,rpm --ci --no-sign RUN set -eu; \ base="desktop/src-tauri/target/$LINUX_TARGET/release"; \ + install -d /out; \ + cp "$base/checkmate-desktop" /out/checkmate; \ for kind in appimage deb rpm; do \ case "$kind" in appimage) extension=AppImage ;; *) extension="$kind" ;; esac; \ set -- "$base/bundle/$kind/"*."$extension"; \ diff --git a/scripts/ci/verify-linux-artifacts.mjs b/scripts/ci/verify-linux-artifacts.mjs index d2f4185..1a83ee8 100644 --- a/scripts/ci/verify-linux-artifacts.mjs +++ b/scripts/ci/verify-linux-artifacts.mjs @@ -86,6 +86,23 @@ async function describe(directory, name) { return { name, bytes: info.size, sha256: hash.digest("hex") }; } +function bundledExecutableHash(compiler, bundleType) { + // Tauri CLI 2.11.4 patches only the first UNK marker for each package, + // then restores the compiler output. Every other byte must stay unchanged. + // https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle.rs + const marker = Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK"); + const offset = compiler.indexOf(marker); + const hash = createHash("sha256"); + if (offset < 0) return hash.update(compiler).digest("hex"); + const replacement = Buffer.from(`__TAURI_BUNDLE_TYPE_VAR_${bundleType}`); + assert.equal(replacement.length, marker.length, "Invalid Tauri bundle type"); + return hash + .update(compiler.subarray(0, offset)) + .update(replacement) + .update(compiler.subarray(offset + marker.length)) + .digest("hex"); +} + function verifyPackages(target, directory, appHash, run) { const expected = targets[target]; const options = { maxBuffer: 512 * 1024 * 1024, timeout: 120000 }; @@ -121,29 +138,18 @@ function verifyPackages(target, directory, appHash, run) { "Wrong RPM identity, version or architecture", ); const rpmApp = run("bsdtar", ["-xOf", rpm, "./usr/bin/checkmate-desktop"], options); - const payloads = [ - { format: "deb", bytes: debApp }, - { format: "rpm", bytes: rpmApp }, - ].map(({ format, bytes }) => { + const compiler = readFileSync(join(directory, files[0])); + assert.equal( + createHash("sha256").update(compiler).digest("hex"), + appHash, + "Compiler executable changed during verification", + ); + for (const [bundleType, bytes] of [["DEB", debApp], ["RPM", rpmApp]]) { assertElf(bytes, expected.machine); - return { - format, - bytes: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - }; - }); - console.log("Linux executable verification", { - compiler: { - bytes: lstatSync(join(directory, files[0])).size, - sha256: appHash, - }, - payloads, - }); - for (const payload of payloads) { assert.equal( - payload.sha256, - appHash, - `${payload.format} packaged app differs from the verified executable`, + createHash("sha256").update(bytes).digest("hex"), + bundledExecutableHash(compiler, bundleType), + `Packaged app differs from the verified executable (${bundleType} marker applied)`, ); } } From 47544151779230e55ccba64f63c8e730e182a1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 29 Sep 2026 11:51:10 +0200 Subject: [PATCH 06/27] fix(ci): use only self-hosted Checkmate runners [skip ci] --- .github/workflows/ci.yml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b74c5f..aadfb61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: Checkmate CI on: push: branches: [launcher-checkmate-support] - pull_request: - branches: [launcher-checkmate-support] workflow_dispatch: inputs: source_sha: @@ -26,9 +24,11 @@ jobs: name: macOS ARM64, then Intel build candidates if: >- github.repository == 'LAPKB/Checkerboard' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/launcher-checkmate-support') - runs-on: macos-15 + && github.ref == 'refs/heads/launcher-checkmate-support' + && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: + group: desktop-macos + labels: [self-hosted, macOS, ARM64] timeout-minutes: 180 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -55,12 +55,10 @@ jobs: identity="$(python3 -c 'import os,sys; s=os.lstat(sys.argv[1]); print(f"{s.st_dev}:{s.st_ino}")' "$root")" printf 'CARGO_HOME=%s/cargo\nRUSTUP_HOME=%s/rustup\nCARGO_TARGET_DIR=%s/target\n' "$root" "$root" "$root" >> "$GITHUB_ENV" printf 'directory=%s\nidentity=%s\n' "$root" "$identity" >> "$GITHUB_OUTPUT" - - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with: toolchain: 1.97.1 - target: aarch64-apple-darwin - rustflags: "" - cache: false + targets: aarch64-apple-darwin - name: Build frontend working-directory: desktop run: npm ci && npm exec -- tsc && npm exec -- vite build @@ -104,22 +102,22 @@ jobs: name: ${{ matrix.target }} build candidate if: >- github.repository == 'LAPKB/Checkerboard' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/launcher-checkmate-support') - runs-on: ${{ matrix.runner }} + && github.ref == 'refs/heads/launcher-checkmate-support' + && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: [self-hosted, Linux, "${{ matrix.runner_arch }}"] timeout-minutes: 180 strategy: fail-fast: false matrix: include: - target: x86_64-unknown-linux-gnu - runner: ubuntu-24.04 + runner_arch: X64 - target: aarch64-unknown-linux-gnu - runner: ubuntu-24.04-arm + runner_arch: ARM64 - target: x86_64-pc-windows-msvc - runner: ubuntu-24.04 + runner_arch: X64 - target: aarch64-pc-windows-msvc - runner: ubuntu-24.04 + runner_arch: X64 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: From 8dd7142e4991ef0c4fc41e78fe511fc69c140d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Tue, 29 Sep 2026 12:33:43 +0200 Subject: [PATCH 07/27] fix(ci): prune superseded candidate artifacts before upload --- .github/workflows/ci.yml | 15 +++ scripts/ci/prune-superseded-artifacts.py | 118 +++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 scripts/ci/prune-superseded-artifacts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aadfb61..e4cb510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ env: jobs: macos: + permissions: + contents: read + actions: write name: macOS ARM64, then Intel build candidates if: >- github.repository == 'LAPKB/Checkerboard' @@ -70,6 +73,11 @@ jobs: run: cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml - name: Build ARM64 app and DMG run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin + - name: Prune superseded candidate artifacts + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ci/prune-superseded-artifacts.py + - name: Retain ARM64 artifacts before Intel build uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: @@ -99,6 +107,9 @@ jobs: run: python3 scripts/ci/owned-temp-dir.py cleanup "$RUNNER_TEMP" "$RUST_HOME_DIR" checkmate-rust "$RUST_HOME_IDENTITY" containers: + permissions: + contents: read + actions: write name: ${{ matrix.target }} build candidate if: >- github.repository == 'LAPKB/Checkerboard' @@ -133,6 +144,10 @@ jobs: - name: Build and verify packages without private credentials id: build run: bash scripts/ci/build-container-candidate.sh "${{ matrix.target }}" + - name: Prune superseded candidate artifacts + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ci/prune-superseded-artifacts.py - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: checkmate-${{ matrix.target }}-build-candidate diff --git a/scripts/ci/prune-superseded-artifacts.py b/scripts/ci/prune-superseded-artifacts.py new file mode 100644 index 0000000..0d58552 --- /dev/null +++ b/scripts/ci/prune-superseded-artifacts.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Delete candidate artifacts left by superseded completed workflow runs. + +GitHub artifact storage is a small allowance shared by every application in +this organization, so each repository keeps only the newest wave of build +candidates. Before a run stores its artifacts it removes artifacts belonging +to older completed runs of the same repository. + +Artifacts of the current run and of any unfinished run are never touched, so +a build that fails before uploading cannot destroy the previous verified +wave. +""" + +import json +import os +import time +import urllib.error +import urllib.request + +API = "https://api.github.com" + + +def api(token, url, method="GET", tries=5): + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "lapkb-ci-artifact-prune", + } + last_error = None + for attempt in range(1, tries + 1): + try: + call = urllib.request.Request(url, headers=headers, method=method) + with urllib.request.urlopen(call, timeout=30) as response: + payload = response.read() + return json.loads(payload) if payload else None + except urllib.error.HTTPError: + raise + except Exception as error: # transient network failure + last_error = error + time.sleep(3) + raise RuntimeError(f"GitHub API request failed: {url}") from last_error + + +def api_object(token, url, method="GET"): + data = api(token, url, method=method) + if not isinstance(data, dict): + raise RuntimeError(f"Unexpected GitHub API response for {url}") + return data + + +def completed_runs(token, base, current_run): + runs = set() + page = 1 + while True: + data = api_object(token, f"{base}/actions/runs?per_page=100&page={page}") + batch = data["workflow_runs"] + runs.update( + run["id"] + for run in batch + if run["status"] == "completed" and run["id"] != current_run + ) + if len(batch) < 100: + return runs + page += 1 + + +def superseded_artifacts(token, base, runs): + artifacts = [] + page = 1 + while True: + data = api_object(token, f"{base}/actions/artifacts?per_page=100&page={page}") + batch = data["artifacts"] + artifacts.extend( + artifact + for artifact in batch + if not artifact["expired"] and artifact["workflow_run"]["id"] in runs + ) + if len(batch) < 100: + return artifacts + page += 1 + + +def main(): + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + repository = os.environ.get("GITHUB_REPOSITORY") + raw_run_id = os.environ.get("GITHUB_RUN_ID") + if not token or not repository or not raw_run_id: + raise SystemExit("GITHUB_TOKEN, GITHUB_REPOSITORY and GITHUB_RUN_ID are required") + try: + current_run = int(raw_run_id) + except ValueError as error: + raise SystemExit(f"GITHUB_RUN_ID is not an integer: {raw_run_id!r}") from error + base = f"{API}/repos/{repository}" + + runs = completed_runs(token, base, current_run) + candidates = superseded_artifacts(token, base, runs) + deleted = 0 + freed = 0 + for artifact in candidates: + try: + api(token, f"{base}/actions/artifacts/{artifact['id']}", method="DELETE") + except urllib.error.HTTPError as error: + if error.code != 404: # 404 means a sibling job pruned it first + raise + continue + deleted += 1 + freed += artifact["size_in_bytes"] + print(f"Deleted superseded artifact {artifact['name']!r} ({artifact['size_in_bytes']} bytes)") + + print( + f"Pruned {deleted} of {len(candidates)} superseded artifact(s), " + f"freeing {freed / 1048576:.1f} MB; the current run keeps its own candidates." + ) + + +if __name__ == "__main__": + main() From 99f239fa7e94ee1dcd38b93d1c03ad6236f03899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 1 Oct 2026 14:50:04 +0100 Subject: [PATCH 08/27] Protect Checkmate work with captured Launcher authority --- .github/workflows/ci.yml | 24 +- desktop/package-lock.json | 542 +++++++++++++++ desktop/package.json | 1 + desktop/src-tauri/Cargo.lock | 543 ++++++++++++++- desktop/src-tauri/Cargo.toml | 12 + desktop/src-tauri/build.rs | 19 + desktop/src-tauri/src/auth.rs | 658 ++++++++++++++++++ desktop/src-tauri/src/commands.rs | 525 ++++++++------ desktop/src-tauri/src/lib.rs | 335 ++++++++- .../src-tauri/src/protected_dispatch_tests.rs | 564 +++++++++++++++ desktop/src-tauri/src/protected_output.rs | 185 +++++ desktop/src-tauri/src/services/atomic_file.rs | 169 +++++ desktop/src-tauri/src/services/mod.rs | 1 + desktop/src-tauri/src/services/snapshot.rs | 25 +- desktop/src-tauri/src/services/workbook.rs | 22 +- desktop/src/App.css | 6 + desktop/src/App.tsx | 26 + desktop/src/AuthGate.dom.test.tsx | 342 +++++++++ desktop/src/AuthGate.tsx | 128 ++++ desktop/src/types.ts | 15 + scripts/ci/build-container-candidate.sh | 44 +- scripts/ci/build-macos-candidate.sh | 4 +- scripts/ci/configure-private-git.mjs | 99 +++ scripts/ci/linux-native.Dockerfile | 29 +- scripts/ci/repo-ssh.mjs | 31 + scripts/ci/validate-pilot-inputs.mjs | 43 ++ scripts/ci/windows-cross.Dockerfile | 27 +- scripts/ci/with-private-ssh-agent.sh | 137 ++++ 28 files changed, 4309 insertions(+), 247 deletions(-) create mode 100644 desktop/src-tauri/src/auth.rs create mode 100644 desktop/src-tauri/src/protected_dispatch_tests.rs create mode 100644 desktop/src-tauri/src/protected_output.rs create mode 100644 desktop/src-tauri/src/services/atomic_file.rs create mode 100644 desktop/src/AuthGate.dom.test.tsx create mode 100644 desktop/src/AuthGate.tsx create mode 100755 scripts/ci/configure-private-git.mjs create mode 100755 scripts/ci/repo-ssh.mjs create mode 100644 scripts/ci/validate-pilot-inputs.mjs create mode 100755 scripts/ci/with-private-ssh-agent.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4cb510..b3c1475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,11 @@ env: CHECKMATE_SOURCE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.source_sha || github.sha }} CHECKMATE_REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} CARGO_BUILD_JOBS: "2" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" GIT_TERMINAL_PROMPT: "0" + # Existing public staging trust; no issuer or archive-signing secret is embedded. + LAPKB_LOCAL_SIGNING_KID: local-staging-1 + LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64: 0KsmsPwJpk64Iq2RTjYS65RsH_WfE0aMP8K6F7p4FeM jobs: macos: @@ -64,13 +68,20 @@ jobs: targets: aarch64-apple-darwin - name: Build frontend working-directory: desktop - run: npm ci && npm exec -- tsc && npm exec -- vite build - - name: Fetch locked public dependencies + run: npm ci && npm test && npm exec -- tsc && npm exec -- vite build + - name: Validate public verifier inputs + run: node scripts/ci/validate-pilot-inputs.mjs + - name: Fetch pinned private dependencies with scoped read-only identities + env: + LAPKB_SDK_READ_KEY: ${{ secrets.LAPKB_SDK_READ_KEY }} + LAPKB_PROTOCOL_READ_KEY: ${{ secrets.LAPKB_PROTOCOL_READ_KEY }} + run: bash scripts/ci/with-private-ssh-agent.sh cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + - name: Test native licensing and scientific core offline env: GIT_CONFIG_GLOBAL: /dev/null GIT_CONFIG_NOSYSTEM: "1" GIT_SSH_COMMAND: /usr/bin/false - run: cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + run: cargo test --workspace --locked --offline --features local-staging --target aarch64-apple-darwin --manifest-path desktop/src-tauri/Cargo.toml - name: Build ARM64 app and DMG run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin - name: Prune superseded candidate artifacts @@ -141,9 +152,12 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 - - name: Build and verify packages without private credentials + - name: Build and verify packages with credentials confined to fetch id: build - run: bash scripts/ci/build-container-candidate.sh "${{ matrix.target }}" + env: + LAPKB_SDK_READ_KEY: ${{ secrets.LAPKB_SDK_READ_KEY }} + LAPKB_PROTOCOL_READ_KEY: ${{ secrets.LAPKB_PROTOCOL_READ_KEY }} + run: bash scripts/ci/with-private-ssh-agent.sh bash scripts/ci/build-container-candidate.sh "${{ matrix.target }}" - name: Prune superseded candidate artifacts env: GITHUB_TOKEN: ${{ github.token }} diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8c92654..2f1ad07 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -24,11 +24,65 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "30.1.1", "typescript": "~5.8.3", "vite": "^7.0.4", "vitest": "^3.2.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -311,6 +365,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@choojs/findup": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", @@ -324,6 +391,146 @@ "findup": "bin/findup.js" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -766,6 +973,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2169,6 +2394,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-search-bounds": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", @@ -2526,6 +2761,20 @@ "license": "MIT", "peer": true }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csscolorparser": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", @@ -2702,6 +2951,35 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2720,6 +2998,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2822,6 +3107,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -3555,6 +3853,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3679,6 +3990,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-string-blank": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", @@ -3717,6 +4035,56 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3966,6 +4334,13 @@ "node": ">=0.10.0" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -4138,6 +4513,19 @@ "license": "MIT", "peer": true }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4357,6 +4745,16 @@ "license": "MIT", "peer": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", @@ -4527,6 +4925,16 @@ "regl-scatter2d": "^3.2.3" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -4650,6 +5058,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4982,6 +5403,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-float32": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", @@ -5014,6 +5455,32 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5060,6 +5527,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -5295,6 +5772,19 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/weak-map": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", @@ -5312,6 +5802,41 @@ "get-canvas-context": "^1.0.1" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -5362,6 +5887,23 @@ "license": "ISC", "peer": true }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/desktop/package.json b/desktop/package.json index d4e7391..83d7535 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -29,6 +29,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "30.1.1", "typescript": "~5.8.3", "vite": "^7.0.4", "vitest": "^3.2.4" diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ef79f10..ec77a0a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -8,6 +8,52 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -60,6 +106,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "approx" version = "0.5.1" @@ -298,6 +355,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -337,6 +400,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -514,6 +595,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.4.3" @@ -586,8 +676,11 @@ dependencies = [ "calamine", "checkerboard-core", "csv", + "ed25519-dalek", "faer", "flate2", + "lapkb-authorization-protocol", + "lapkb-desktop-session", "pmcore", "rand 0.9.5", "rand_distr 0.5.1", @@ -601,6 +694,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-store", "thiserror 2.0.20", + "tokio", ] [[package]] @@ -615,6 +709,32 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codepage" version = "0.1.2" @@ -643,6 +763,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -693,6 +825,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -789,9 +927,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -852,6 +1000,51 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -940,6 +1133,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1017,8 +1220,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1170,6 +1385,31 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -1410,6 +1650,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1865,6 +2111,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -2095,6 +2351,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2144,6 +2418,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2363,6 +2646,25 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "interpol" version = "0.2.1" @@ -2569,6 +2871,64 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lapkb-authorization-protocol" +version = "0.1.1" +source = "git+ssh://git@github.com/LAPKB/desktop-authorization.git?rev=bcca349d7ea3d448c85a177fed3b1af4d3f941a1#bcca349d7ea3d448c85a177fed3b1af4d3f941a1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "hex", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "lapkb-desktop-session" +version = "0.1.0" +source = "git+ssh://git@github.com/LAPKB/Launcher.git?rev=daba4ce5add38c9147f185e9caec6defa65a22a6#daba4ce5add38c9147f185e9caec6defa65a22a6" +dependencies = [ + "aes-gcm", + "base64 0.22.1", + "ed25519-dalek", + "keyring", + "lapkb-authorization-protocol", + "libc", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2940,6 +3300,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -2976,6 +3350,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -3231,6 +3615,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.1" @@ -3497,6 +3887,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3577,6 +3977,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3836,6 +4248,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4185,6 +4600,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.3", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -4387,7 +4844,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4415,6 +4883,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simba" version = "0.10.2" @@ -4528,6 +5005,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4577,6 +5064,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4814,7 +5307,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.20", @@ -5542,6 +6035,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -5986,6 +6489,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -6363,7 +6879,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.20", @@ -6456,6 +6972,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.19.0" @@ -6532,6 +7059,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 70ca020..c06f440 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -9,6 +9,11 @@ edition = "2024" members = [".", "crates/checkerboard-core"] resolver = "2" +[features] +# Pilot builds can embed public lease-verification material; absent or invalid +# trust configuration leaves the native session locked. +local-staging = [] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -26,6 +31,7 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-store = "2" +tokio = { version = "1", features = ["time"] } checkerboard-core = { path = "crates/checkerboard-core" } pmcore = "0.27.1" faer = "0.24" @@ -40,6 +46,12 @@ rust_xlsxwriter = "0.96" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "daba4ce5add38c9147f185e9caec6defa65a22a6", features = ["client"] } + +[dev-dependencies] +ed25519-dalek = "2.1.1" +lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "bcca349d7ea3d448c85a177fed3b1af4d3f941a1" } +tauri = { version = "2", features = ["test"] } [patch.crates-io] pharmsol = { path = "../../vendor/pharmsol" } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e..86fd032 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,22 @@ +use std::{env, fs, path::PathBuf}; + fn main() { + println!("cargo:rerun-if-env-changed=LAPKB_LOCAL_SIGNING_KID"); + println!("cargo:rerun-if-env-changed=LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64"); + + if env::var_os("CARGO_FEATURE_LOCAL_STAGING").is_some() { + let kid = env::var("LAPKB_LOCAL_SIGNING_KID").ok(); + let public_key = env::var("LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64").ok(); + let output = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo output directory")); + fs::write( + output.join("local_staging_config.rs"), + format!( + "pub const SIGNING_KID: Option<&str> = {kid:?};\n\ + pub const SIGNING_PUBLIC_KEY_B64: Option<&str> = {public_key:?};\n" + ), + ) + .expect("write local staging verification configuration"); + } + tauri_build::build() } diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs new file mode 100644 index 0000000..a32bea5 --- /dev/null +++ b/desktop/src-tauri/src/auth.rs @@ -0,0 +1,658 @@ +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[cfg(target_os = "macos")] +use std::process::Command; + +use lapkb_desktop_session::Permit; +#[cfg(all(feature = "local-staging", any(unix, windows)))] +use lapkb_desktop_session::VerificationKeySet; +#[cfg(any(unix, windows))] +use lapkb_desktop_session::{LeaseVerifier, ProtectedApp}; +#[cfg(any(unix, windows))] +use lapkb_desktop_session::{client::Client as LauncherSessionClient, client_store::ClientStore}; +use serde::Serialize; +use tauri::{AppHandle, Manager, Runtime, State}; + +const LAUNCHER_BUNDLE_ID: &str = "org.lapkb.launcher"; +const ACCESS_REFRESH_INTERVAL: Duration = Duration::from_secs(1); +const TRUST_CONFIGURATION_MESSAGE: &str = "Checkmate could not initialize shared access. Check this build's trusted configuration and OS secure storage, then reopen Checkmate."; +const AUTH_REQUIRED_MESSAGE: &str = "Open LAPKB Launcher to sign in or manage the active account."; +const ACCESS_LOCKED_MESSAGE: &str = + "Checkmate access is locked. Open LAPKB Launcher to restore access."; +const ACCOUNT_SWITCHED_MESSAGE: &str = + "The LAPKB account changed. Checkmate is locked until the new account is verified."; +#[cfg(target_os = "macos")] +const LAUNCHER_OPEN_ERROR: &str = "Could not open LAPKB Launcher."; +#[cfg(not(target_os = "macos"))] +const LAUNCHER_OPEN_UNSUPPORTED_MESSAGE: &str = + "Opening LAPKB Launcher from Checkmate is not supported on this platform."; + +#[cfg(all(feature = "local-staging", any(unix, windows)))] +mod local_staging { + include!(concat!(env!("OUT_DIR"), "/local_staging_config.rs")); +} + +#[cfg(any(unix, windows))] +enum SessionRuntime { + Ready(Arc), + Locked, +} + +#[cfg(not(any(unix, windows)))] +enum SessionRuntime { + Locked, +} + +impl SessionRuntime { + fn new(app: &AppHandle) -> Self { + #[cfg(any(unix, windows))] + { + build_session_client(app) + .map(Arc::new) + .map(Self::Ready) + .unwrap_or(Self::Locked) + } + #[cfg(not(any(unix, windows)))] + { + let _ = app; + Self::Locked + } + } + + fn is_ready(&self) -> bool { + #[cfg(any(unix, windows))] + { + matches!(self, Self::Ready(_)) + } + #[cfg(not(any(unix, windows)))] + { + false + } + } +} + +type AccessRevokedHook = Arc; + +#[cfg(test)] +struct DispatchPause { + entered: std::sync::mpsc::SyncSender<()>, + release: Mutex>>, +} + +#[cfg(test)] +pub(crate) struct TestDispatchControl { + entered: std::sync::mpsc::Receiver<()>, + release: std::sync::mpsc::SyncSender<()>, +} + +#[cfg(test)] +impl TestDispatchControl { + pub(crate) fn wait_until_reached(&self) { + self.entered + .recv_timeout(Duration::from_secs(10)) + .expect("protected IPC reached the queued-handler boundary"); + } + + pub(crate) fn release(self) { + self.release + .send(()) + .expect("queued-handler boundary is still paused"); + } +} + +#[derive(Clone)] +pub struct AuthState { + runtime: Arc, + inner: Arc>, + access_revoked: AccessRevokedHook, + #[cfg(target_os = "macos")] + startup_launcher_attempted: Arc, + #[cfg(test)] + dispatch_pause: Arc>>>, +} + +#[derive(Clone)] +pub(crate) struct CapturedAuthorization { + auth: AuthState, + permit: Permit, + account_id: String, +} + +impl CapturedAuthorization { + pub(crate) fn auth(&self) -> &AuthState { + &self.auth + } + + pub(crate) fn is_valid(&self) -> bool { + self.auth + .check_captured_permit(self.permit, &self.account_id) + } +} + +#[derive(Default)] +struct AuthInner { + restoring: bool, + authorized: bool, + account_id: Option, + last_account_id: Option, + message: Option<&'static str>, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthUser { + subject: String, + display_name: String, + email: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AuthPhase { + Unconfigured, + Restoring, + SignedOut, + Authenticated, + Suspended, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthView { + phase: AuthPhase, + user: Option, + account_id: Option, + message: Option, +} + +impl AuthState { + #[cfg(test)] + pub(crate) fn test_pause_next_dispatch(&self) -> TestDispatchControl { + let (entered, wait_until_reached) = std::sync::mpsc::sync_channel(0); + let (release, wait_for_release) = std::sync::mpsc::sync_channel(0); + *self + .dispatch_pause + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(DispatchPause { + entered, + release: Mutex::new(Some(wait_for_release)), + })); + TestDispatchControl { + entered: wait_until_reached, + release, + } + } + + #[cfg(test)] + pub(crate) fn pause_test_dispatch(&self) { + let pause = self + .dispatch_pause + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(pause) = pause { + let _ = pause.entered.send(()); + let release = pause + .release + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(release) = release { + let _ = release.recv(); + } + } + } + + /// Synthetic locked state for native tests. It never opens an OS vault or user store. + #[cfg(test)] + pub(crate) fn test_locked() -> Self { + Self { + runtime: Arc::new(SessionRuntime::Locked), + inner: Arc::new(Mutex::new(AuthInner::default())), + access_revoked: Arc::new(|| {}), + #[cfg(target_os = "macos")] + startup_launcher_attempted: Arc::new(AtomicBool::new(false)), + dispatch_pause: Arc::new(Mutex::new(None)), + } + } + + #[cfg(all(test, unix))] + pub(crate) fn test_authorized(client: Arc) -> Option { + let account_id = account_id_string(&client)?; + client.permit()?; + Some(Self { + runtime: Arc::new(SessionRuntime::Ready(client)), + inner: Arc::new(Mutex::new(AuthInner { + authorized: true, + account_id: Some(account_id.clone()), + last_account_id: Some(account_id), + ..AuthInner::default() + })), + access_revoked: Arc::new(|| {}), + #[cfg(target_os = "macos")] + startup_launcher_attempted: Arc::new(AtomicBool::new(false)), + dispatch_pause: Arc::new(Mutex::new(None)), + }) + } + + #[cfg(all(test, unix))] + pub(crate) fn test_record_client_access(&self) -> bool { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return false, + }; + self.record_access(client.permit().is_some(), account_id_string(&client)) + } + + /// Load the shared Launcher client. The child stores no sign-in token and + /// fails closed if the vault, broker, or embedded verifier is unavailable. + pub fn load( + app: &AppHandle, + access_revoked: impl Fn() + Send + Sync + 'static, + ) -> Self { + let runtime = Arc::new(SessionRuntime::new(app)); + let ready = runtime.is_ready(); + let state = Self { + runtime, + inner: Arc::new(Mutex::new(AuthInner { + restoring: ready, + message: (!ready).then_some(TRUST_CONFIGURATION_MESSAGE), + ..AuthInner::default() + })), + access_revoked: Arc::new(access_revoked), + #[cfg(target_os = "macos")] + startup_launcher_attempted: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + dispatch_pause: Arc::new(Mutex::new(None)), + }; + if ready { + tauri::async_runtime::spawn(state.clone().refresh_loop()); + } + state + } + + pub fn denial_message(&self) -> &'static str { + if self.runtime.is_ready() { + AUTH_REQUIRED_MESSAGE + } else { + TRUST_CONFIGURATION_MESSAGE + } + } + + /// Return a permit only while native status and the SDK client agree on the + /// current verified opaque account. + pub fn acquire_permit(&self) -> Option { + #[cfg(any(unix, windows))] + { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return None, + }; + let account_id = account_id_string(&client)?; + let inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !inner.authorized || inner.account_id.as_deref() != Some(account_id.as_str()) { + return None; + } + drop(inner); + client.permit() + } + #[cfg(not(any(unix, windows)))] + { + None + } + } + + /// Capture the original SDK permit and verified account before the Tauri + /// dispatcher queues the asynchronous command handler. + pub(crate) fn capture_authorization(&self) -> Option { + #[cfg(any(unix, windows))] + { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return None, + }; + if !self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .authorized + { + return None; + } + + let account_id = account_id_string(&client)?; + let permit = client.permit()?; + if account_id_string(&client).as_deref() != Some(account_id.as_str()) + || client.permit() != Some(permit) + || client.check_permit().is_err() + || client.permit() != Some(permit) + { + return None; + } + let inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !inner.authorized || inner.account_id.as_deref() != Some(account_id.as_str()) { + return None; + } + drop(inner); + if account_id_string(&client).as_deref() != Some(account_id.as_str()) + || client.permit() != Some(permit) + { + return None; + } + Some(CapturedAuthorization { + auth: self.clone(), + permit, + account_id, + }) + } + #[cfg(not(any(unix, windows)))] + { + None + } + } + + pub fn check_permit(&self, permit: Permit) -> bool { + #[cfg(any(unix, windows))] + { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return false, + }; + self.acquire_permit() == Some(permit) && client.check_permit().is_ok() + } + #[cfg(not(any(unix, windows)))] + { + let _ = permit; + false + } + } + + fn check_captured_permit(&self, permit: Permit, account_id: &str) -> bool { + #[cfg(any(unix, windows))] + { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return false, + }; + account_id_string(&client).as_deref() == Some(account_id) + && self.check_permit(permit) + && client.check_permit().is_ok() + && client.permit() == Some(permit) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (permit, account_id); + false + } + } + + #[cfg(any(unix, windows))] + async fn refresh_once(&self) { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return, + }; + let _ = client.refresh().await; + self.record_access(client.permit().is_some(), account_id_string(&client)); + } + + #[cfg(not(any(unix, windows)))] + async fn refresh_once(&self) {} + + async fn refresh_loop(self) { + self.refresh_once().await; + loop { + tokio::time::sleep(ACCESS_REFRESH_INTERVAL).await; + self.refresh_once().await; + } + } + + fn record_access(&self, authorized: bool, account_id: Option) -> bool { + let (accepted, revoke) = { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let account_changed = authorized + && account_id.as_ref().is_some_and(|current| { + inner + .last_account_id + .as_ref() + .is_some_and(|previous| previous != current) + }); + if authorized { + if let Some(account_id) = account_id.as_ref() { + inner.account_id = Some(account_id.clone()); + inner.last_account_id = Some(account_id.clone()); + } + } else { + inner.account_id = None; + } + let accepted = authorized && account_id.is_some() && !account_changed; + let revoke = inner.authorized && !accepted; + inner.authorized = accepted; + inner.restoring = false; + inner.message = if account_changed { + Some(ACCOUNT_SWITCHED_MESSAGE) + } else if accepted { + None + } else if inner.last_account_id.is_some() { + Some(ACCESS_LOCKED_MESSAGE) + } else { + Some(AUTH_REQUIRED_MESSAGE) + }; + (accepted, revoke) + }; + if revoke { + (self.access_revoked)(); + } + accepted + } + + fn view(&self) -> AuthView { + let fresh = self.acquire_permit().is_some(); + let inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let account_id = inner + .account_id + .clone() + .or_else(|| inner.last_account_id.clone()); + let (phase, user) = if !self.runtime.is_ready() { + (AuthPhase::Unconfigured, None) + } else if inner.restoring { + (AuthPhase::Restoring, None) + } else if inner.authorized && fresh { + let user = account_id.as_ref().map(|account_id| AuthUser { + subject: account_id.clone(), + display_name: "LAPKB account".into(), + email: None, + }); + if user.is_some() { + (AuthPhase::Authenticated, user) + } else { + (AuthPhase::Suspended, None) + } + } else if inner.last_account_id.is_some() { + (AuthPhase::Suspended, None) + } else { + (AuthPhase::SignedOut, None) + }; + AuthView { + phase, + user, + account_id, + message: if self.runtime.is_ready() { + inner.message.map(str::to_owned) + } else { + Some(TRUST_CONFIGURATION_MESSAGE.into()) + }, + } + } + + pub(crate) fn open_launcher(&self, startup: bool) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + if startup && self.startup_launcher_attempted.swap(true, Ordering::AcqRel) { + return Ok(()); + } + let mut command = Command::new("/usr/bin/open"); + if startup { + command.arg("-g"); + } + let status = command + .arg("-b") + .arg(LAUNCHER_BUNDLE_ID) + .status() + .map_err(|_| LAUNCHER_OPEN_ERROR.to_string())?; + if status.success() { + Ok(()) + } else { + Err(LAUNCHER_OPEN_ERROR.into()) + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (startup, LAUNCHER_BUNDLE_ID); + Err(LAUNCHER_OPEN_UNSUPPORTED_MESSAGE.into()) + } + } +} + +#[cfg(any(unix, windows))] +fn account_id_string(client: &LauncherSessionClient) -> Option { + client + .account_id() + .map(|account_id| account_id.as_str().to_owned()) +} + +#[cfg(any(unix, windows))] +fn prepare_app_data_directory(path: &std::path::Path) -> Result<(), ()> { + if !path.is_absolute() + || path.components().any(|component| { + matches!(component, std::path::Component::CurDir | std::path::Component::ParentDir) + }) + { + return Err(()); + } + // Prepare only absent components of Tauri's fixed app-data path. Existing + // permissions and the SDK's private-directory validation remain unchanged. + let ancestors: Vec<_> = path.ancestors().collect(); + for directory in ancestors.into_iter().rev() { + match std::fs::symlink_metadata(directory) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(()), + } + } + Err(_) => return Err(()), + } + let metadata = std::fs::symlink_metadata(directory).map_err(|_| ())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(()); + } + } + } + Ok(()) +} + +fn build_session_client(app: &AppHandle) -> Result { + let verifier = pinned_verifier()?; + let broker_root = app.path().local_data_dir().map_err(|_| ())?; + let broker_dir = broker_root.join("org.lapkb.launcher").join("broker"); + let app_data = app.path().app_data_dir().map_err(|_| ())?; + prepare_app_data_directory(&app_data)?; + let store = + ClientStore::open(ProtectedApp::Checkerboard, app_data.join("session")).map_err(|_| ())?; + LauncherSessionClient::new(ProtectedApp::Checkerboard, broker_dir, verifier, store) + .map_err(|_| ()) +} + +#[cfg(any(unix, windows))] +fn pinned_verifier() -> Result { + #[cfg(feature = "local-staging")] + { + let kid = local_staging::SIGNING_KID.ok_or(())?; + let public_key = local_staging::SIGNING_PUBLIC_KEY_B64.ok_or(())?; + let keys = + VerificationKeySet::from_base64([(kid.to_owned(), public_key)]).map_err(|_| ())?; + Ok(LeaseVerifier::new(keys)) + } + #[cfg(not(feature = "local-staging"))] + { + Err(()) + } +} + +#[tauri::command] +pub fn auth_status(state: State<'_, AuthState>) -> AuthView { + state.view() +} + +#[tauri::command] +pub fn auth_open_launcher( + state: State<'_, AuthState>, + startup: Option, +) -> Result<(), String> { + state.open_launcher(startup.unwrap_or(false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn missing_runtime_trust_stays_locked() { + let state = AuthState::test_locked(); + assert!(state.acquire_permit().is_none()); + assert_eq!(state.denial_message(), TRUST_CONFIGURATION_MESSAGE); + } + + #[test] + fn account_switch_closes_authority_once_per_transition() { + let count = Arc::new(AtomicUsize::new(0)); + let calls = Arc::clone(&count); + let state = AuthState { + runtime: Arc::new(SessionRuntime::Locked), + inner: Arc::new(Mutex::new(AuthInner::default())), + access_revoked: Arc::new(move || { + calls.fetch_add(1, Ordering::SeqCst); + }), + #[cfg(target_os = "macos")] + startup_launcher_attempted: Arc::new(AtomicBool::new(false)), + dispatch_pause: Arc::new(Mutex::new(None)), + }; + assert!(state.record_access(true, Some("account-a".into()))); + assert!(!state.record_access(true, Some("account-b".into()))); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(state.record_access(true, Some("account-b".into()))); + assert!(!state.record_access(false, None)); + assert_eq!(count.load(Ordering::SeqCst), 2); + } +} diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index ccfcc79..981439c 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ use checkerboard_core::{ - AnalysisPolicy, AnalysisResult, ColumnMapping, ConcentrationRange, ResponseType, analyze_with_progress, - assay_from_rows, + AnalysisPolicy, AnalysisResult, ColumnMapping, ConcentrationRange, ResponseType, + analyze_with_progress, assay_from_rows, diamond::{DiamondPolicy, DiamondResult}, drusano_greco::{ DrusanoCensorLimitSuggestion, DrusanoDataSet, DrusanoDataSettings, build_equation_dataset, @@ -11,7 +11,9 @@ use serde::{Deserialize, Serialize}; use tauri::ipc::Channel; use crate::{ + auth::CapturedAuthorization, error::AppError, + protected_output::ProtectedOutput, services::{ drusano_greco::{ self, DrusanoAssayErrorSettings, DrusanoFitContinuation, DrusanoFitResult, @@ -188,8 +190,12 @@ fn default_musyc_max_iterations() -> usize { 5_000 } -fn default_musyc_bootstrap_iterations() -> usize { 500 } -fn default_musyc_bootstrap_seed() -> u64 { 123 } +fn default_musyc_bootstrap_iterations() -> usize { + 500 +} +fn default_musyc_bootstrap_seed() -> u64 { + 123 +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -211,8 +217,8 @@ pub struct ExportResultsRequest { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AnalysisProgress { - completed_iterations: usize, - total_iterations: usize, + pub(crate) completed_iterations: usize, + pub(crate) total_iterations: usize, } #[derive(Debug, Clone, Serialize)] @@ -235,18 +241,19 @@ pub struct MusycFitProgress { pub total_bootstraps: usize, } -#[tauri::command] -pub fn list_worksheets(path: String) -> Result, AppError> { +pub(crate) fn list_worksheets(path: String) -> Result, AppError> { importer::list_worksheets(&path) } -#[tauri::command] -pub fn import_preview(request: ImportRequest) -> Result { +pub(crate) fn import_preview(request: ImportRequest) -> Result { let table = importer::read_table(&request)?; let total_rows = table.rows.len(); let total_columns = table.headers.len(); let mut suggested_roles = suggest_roles(&table.headers); - if let Some(column) = request.organism_column.filter(|column| *column < suggested_roles.len()) { + if let Some(column) = request + .organism_column + .filter(|column| *column < suggested_roles.len()) + { for role in &mut suggested_roles { if role == "organism" { *role = "ignore".into(); @@ -271,21 +278,17 @@ pub fn import_preview(request: ImportRequest) -> Result let suffix = normalized.strip_prefix("conc"); if let Some(suffix) = suffix.filter(|value| value.chars().all(|character| character.is_ascii_digit())) - { - if let Some(drug_column) = table + && let Some(drug_column) = table .headers .iter() .position(|candidate| normalize_header(candidate) == format!("drug{suffix}")) - { - if let Some(name) = table - .rows - .iter() - .filter_map(|row| row.get(drug_column)) - .find(|value| !value.trim().is_empty()) - { - suggested_drug_names[index] = name.trim().to_string(); - } - } + && let Some(name) = table + .rows + .iter() + .filter_map(|row| row.get(drug_column)) + .find(|value| !value.trim().is_empty()) + { + suggested_drug_names[index] = name.trim().to_string(); } } let drug_columns = ["drugA", "drugB", "drugC"] @@ -298,12 +301,7 @@ pub fn import_preview(request: ImportRequest) -> Result .collect::>(); let response_column = suggested_roles.iter().position(|role| role == "response"); let organism_column = suggested_roles.iter().position(|role| role == "organism"); - let mut regimens = describe_regimens( - &table, - &drug_columns, - response_column, - organism_column, - ); + let mut regimens = describe_regimens(&table, &drug_columns, response_column, organism_column); if regimens.is_empty() { regimens.push(generic_regimen_preview( &table, @@ -324,8 +322,7 @@ pub fn import_preview(request: ImportRequest) -> Result }) } -#[tauri::command] -pub fn infer_mics(request: InferMicsRequest) -> Result, AppError> { +pub(crate) fn infer_mics(request: InferMicsRequest) -> Result, AppError> { if !request.zero_tolerance.is_finite() || request.zero_tolerance < 0.0 { return Err(AppError::new( "invalidMicTolerance", @@ -404,15 +401,13 @@ pub fn infer_mics(request: InferMicsRequest) -> Result, AppErro .collect() } -#[tauri::command] -pub fn prepare_drusano_data( +pub(crate) fn prepare_drusano_data( request: PrepareDrusanoDataRequest, ) -> Result { prepare_drusano_data_inner(request) } -#[tauri::command] -pub fn suggest_drusano_censor_limit( +pub(crate) fn suggest_drusano_censor_limit( request: SuggestDrusanoCensorLimitRequest, ) -> Result, AppError> { let table = importer::read_table(&request.import)?; @@ -444,155 +439,225 @@ fn prepare_drusano_data_inner( Ok(build_equation_dataset(&assay, &request.settings)?) } -#[tauri::command] -pub async fn fit_drusano_greco( +pub(crate) async fn fit_drusano_greco_protected( + authorization: CapturedAuthorization, request: PrepareDrusanoDataRequest, - on_progress: Channel, + on_progress: Channel>, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - let assay_error = request.assay_error.clone(); - let max_cycles = request.max_cycles; - let continuation = request.continuation.clone(); - let bootstrap_iterations = request.bootstrap_iterations; - let bootstrap_seed = request.bootstrap_seed; - let data = prepare_drusano_data_inner(request)?; - drusano_greco::fit_npag_with_options( - data, - assay_error, - max_cycles, - continuation, - bootstrap_iterations, - bootstrap_seed, - |phase, cycle, objective_function, completed_bootstraps, total_bootstraps| { - let _ = on_progress.send(DrusanoFitProgress { - phase: phase.into(), - cycle, - objective_function, - completed_bootstraps, - total_bootstraps, - }); - }, - ) - .map_err(|error| AppError::new("drusanoFitError", error.to_string())) + crate::run_protected_work(authorization, |authorization| { + run_drusano_fit(request, move |progress| { + let _ = crate::protected_output::send(&authorization, &on_progress, progress); + }) + }) }) .await .map_err(|error| AppError::new("drusanoWorkerError", error.to_string()))? } -#[tauri::command] -pub async fn simulate_drusano_regimen( +fn run_drusano_fit( + request: PrepareDrusanoDataRequest, + send_progress: impl Fn(DrusanoFitProgress) + Send + Sync + 'static, +) -> Result { + let assay_error = request.assay_error.clone(); + let max_cycles = request.max_cycles; + let continuation = request.continuation.clone(); + let bootstrap_iterations = request.bootstrap_iterations; + let bootstrap_seed = request.bootstrap_seed; + let data = prepare_drusano_data_inner(request)?; + drusano_greco::fit_npag_with_options( + data, + assay_error, + max_cycles, + continuation, + bootstrap_iterations, + bootstrap_seed, + |phase, cycle, objective_function, completed_bootstraps, total_bootstraps| { + send_progress(DrusanoFitProgress { + phase: phase.into(), + cycle, + objective_function, + completed_bootstraps, + total_bootstraps, + }); + }, + ) + .map_err(|error| AppError::new("drusanoFitError", error.to_string())) +} + +pub(crate) async fn simulate_drusano_regimen_protected( + authorization: CapturedAuthorization, request: DrusanoRegimenSimulationRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || drusano_greco::simulate_regimen(request)) - .await - .map_err(|error| AppError::new("drusanoSimulationWorkerError", error.to_string()))? - .map_err(|error| AppError::new("drusanoSimulationError", error.to_string())) + tauri::async_runtime::spawn_blocking(move || { + crate::run_protected_work(authorization, |_| { + drusano_greco::simulate_regimen(request) + .map_err(|error| AppError::new("drusanoSimulationError", error.to_string())) + }) + }) + .await + .map_err(|error| AppError::new("drusanoSimulationWorkerError", error.to_string()))? } -#[tauri::command] -pub async fn fit_musyc( +pub(crate) async fn fit_musyc_protected( + authorization: CapturedAuthorization, request: FitMusycRequest, - on_progress: Channel, + on_progress: Channel>, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - let table = importer::read_table(&request.import)?; - let rows = select_regimen_rows( - &table, - &request.regimen_drug_names, - request.organism.as_deref(), - request.organism_column, - )?; - let assay = assay_from_rows(&rows, &request.mapping)?; - let data = build_equation_dataset(&assay, &request.settings)?; - musyc::fit_with_bootstrap( - data, - request.max_iterations, - request.bootstrap_iterations, - request.bootstrap_seed, - |phase, iteration, objective_function, completed_bootstraps, total_bootstraps| { - let _ = on_progress.send(MusycFitProgress { - phase: phase.into(), iteration, objective_function, - completed_bootstraps, total_bootstraps, - }); - }, - ) - .map_err(|error| AppError::new("musycFitError", error.to_string())) + crate::run_protected_work(authorization, |authorization| { + run_musyc_fit(request, move |progress| { + let _ = crate::protected_output::send(&authorization, &on_progress, progress); + }) + }) }) .await .map_err(|error| AppError::new("musycWorkerError", error.to_string()))? } -#[tauri::command] -pub async fn save_project_snapshot(path: String, snapshot_json: String) -> Result<(), AppError> { - tauri::async_runtime::spawn_blocking(move || snapshot::save(&path, &snapshot_json)) - .await - .map_err(|error| AppError::new("projectSaveWorkerError", error.to_string()))? - .map_err(|error| AppError::new("projectSaveError", error.to_string())) +fn run_musyc_fit( + request: FitMusycRequest, + send_progress: impl Fn(MusycFitProgress) + Send + Sync + 'static, +) -> Result { + let table = importer::read_table(&request.import)?; + let rows = select_regimen_rows( + &table, + &request.regimen_drug_names, + request.organism.as_deref(), + request.organism_column, + )?; + let assay = assay_from_rows(&rows, &request.mapping)?; + let data = build_equation_dataset(&assay, &request.settings)?; + musyc::fit_with_bootstrap( + data, + request.max_iterations, + request.bootstrap_iterations, + request.bootstrap_seed, + |phase, iteration, objective_function, completed_bootstraps, total_bootstraps| { + send_progress(MusycFitProgress { + phase: phase.into(), + iteration, + objective_function, + completed_bootstraps, + total_bootstraps, + }); + }, + ) + .map_err(|error| AppError::new("musycFitError", error.to_string())) } -#[tauri::command] -pub async fn load_project_snapshot(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || snapshot::load(&path)) - .await - .map_err(|error| AppError::new("projectLoadWorkerError", error.to_string()))? - .map_err(|error| AppError::new("projectLoadError", error.to_string())) +pub(crate) async fn save_project_snapshot_protected( + authorization: CapturedAuthorization, + path: String, + snapshot_json: String, +) -> Result<(), AppError> { + tauri::async_runtime::spawn_blocking(move || { + crate::run_protected_work(authorization, |authorization| { + snapshot::save(&path, &snapshot_json, &|| authorization.is_valid()) + .map_err(|error| AppError::new("projectSaveError", error.to_string())) + }) + }) + .await + .map_err(|error| AppError::new("projectSaveWorkerError", error.to_string()))? +} + +pub(crate) async fn load_project_snapshot_protected( + authorization: CapturedAuthorization, + path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::run_protected_work(authorization, |_| { + snapshot::load(&path) + .map_err(|error| AppError::new("projectLoadError", error.to_string())) + }) + }) + .await + .map_err(|error| AppError::new("projectLoadWorkerError", error.to_string()))? } -#[tauri::command] -pub async fn analyze_table( +pub(crate) async fn analyze_table_protected( + authorization: CapturedAuthorization, request: AnalyzeTableRequest, - on_progress: Channel, + on_progress: Channel>, ) -> Result { - tauri::async_runtime::spawn_blocking(move || analyze_table_inner(request, Some(on_progress))) - .await - .map_err(|error| AppError::new("analysisWorkerError", error.to_string()))? + tauri::async_runtime::spawn_blocking(move || { + crate::run_protected_work(authorization, |authorization| { + analyze_table_inner_with_progress(request, move |progress| { + let _ = crate::protected_output::send(&authorization, &on_progress, progress); + }) + }) + }) + .await + .map_err(|error| AppError::new("analysisWorkerError", error.to_string()))? } -#[tauri::command] -pub async fn analyze_diamond( +pub(crate) async fn analyze_diamond_protected( + authorization: CapturedAuthorization, request: AnalyzeDiamondRequest, - on_progress: Channel, + on_progress: Channel>, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - let table = importer::read_table(&request.import)?; - let rows = select_regimen_rows( - &table, - &request.regimen_drug_names, - request.organism.as_deref(), - request.organism_column, - )?; - validate_regimen_units( - &table.headers, - &rows, - &request.mapping, - &request.concentration_units, - )?; - let assay = assay_from_rows(&rows, &request.mapping)?; - let mut result = checkerboard_core::diamond::analyze( - &assay, - &request.dose_anchors, - request.policy, - |completed, total| { - let _ = on_progress.send(AnalysisProgress { - completed_iterations: completed, - total_iterations: total, - }); - }, - )?; - result.concentration_units = if request.concentration_units.len() == assay.drug_names.len() { - request.concentration_units - } else { - vec![String::new(); assay.drug_names.len()] - }; - Ok(result) + crate::run_protected_work(authorization, |authorization| { + analyze_diamond_inner(request, move |progress| { + let _ = crate::protected_output::send(&authorization, &on_progress, progress); + }) + }) }) .await .map_err(|error| AppError::new("diamondWorkerError", error.to_string()))? } +fn analyze_diamond_inner( + request: AnalyzeDiamondRequest, + mut send_progress: impl FnMut(AnalysisProgress), +) -> Result { + let table = importer::read_table(&request.import)?; + let rows = select_regimen_rows( + &table, + &request.regimen_drug_names, + request.organism.as_deref(), + request.organism_column, + )?; + validate_regimen_units( + &table.headers, + &rows, + &request.mapping, + &request.concentration_units, + )?; + let assay = assay_from_rows(&rows, &request.mapping)?; + let mut result = checkerboard_core::diamond::analyze( + &assay, + &request.dose_anchors, + request.policy, + |completed, total| { + send_progress(AnalysisProgress { + completed_iterations: completed, + total_iterations: total, + }); + }, + )?; + result.concentration_units = if request.concentration_units.len() == assay.drug_names.len() { + request.concentration_units + } else { + vec![String::new(); assay.drug_names.len()] + }; + Ok(result) +} + fn analyze_table_inner( request: AnalyzeTableRequest, on_progress: Option>, +) -> Result { + analyze_table_inner_with_progress(request, move |progress| { + if let Some(channel) = &on_progress { + let _ = channel.send(progress); + } + }) +} + +fn analyze_table_inner_with_progress( + request: AnalyzeTableRequest, + mut send_progress: impl FnMut(AnalysisProgress), ) -> Result { let table = importer::read_table(&request.import)?; let rows = select_regimen_rows( @@ -684,25 +749,21 @@ fn analyze_table_inner( let mut restricted_total = 0; let mut result = analyze_with_progress(&assay, policy, |completed, total| { restricted_total = total; - if let Some(channel) = &on_progress { - let _ = channel.send(AnalysisProgress { - completed_iterations: completed, - total_iterations: if has_clinical_window { - total * 2 - } else { - total - }, - }); - } + send_progress(AnalysisProgress { + completed_iterations: completed, + total_iterations: if has_clinical_window { + total * 2 + } else { + total + }, + }); })?; if has_clinical_window { let full_result = analyze_with_progress(&full_assay, policy, |completed, total| { - if let Some(channel) = &on_progress { - let _ = channel.send(AnalysisProgress { - completed_iterations: restricted_total + completed, - total_iterations: restricted_total + total, - }); - } + send_progress(AnalysisProgress { + completed_iterations: restricted_total + completed, + total_iterations: restricted_total + total, + }); })?; let restricted_rows = result.processed.clone(); result.processed = full_result @@ -761,8 +822,7 @@ fn select_regimen_rows( .filter(|row| { let regimen_matches = regimen_drug_names.is_empty() || name_columns.as_ref().is_none_or(|columns| { - row_drug_names(row, columns) - .is_some_and(|names| names == regimen_drug_names) + row_drug_names(row, columns).is_some_and(|names| names == regimen_drug_names) }); let organism_matches = organism.is_none_or(|selected_organism| { organism_column @@ -929,7 +989,10 @@ fn generic_regimen_preview( .iter() .filter(|row| row_organism(row, organism_column) == organism) .collect::>(); - let id = organism_entry.as_ref().map_or(1, |(index, _)| *index).to_string(); + let id = organism_entry + .as_ref() + .map_or(1, |(index, _)| *index) + .to_string(); let regimen_label = drug_names.join(" + "); RegimenPreview { id: id.clone(), @@ -943,7 +1006,11 @@ fn generic_regimen_preview( concentration_units, suggested_response_type: infer_response_type(&row_refs, drug_columns, response_column), drug_names, - rows: row_refs.iter().take(100).map(|row| (*row).clone()).collect(), + rows: row_refs + .iter() + .take(100) + .map(|row| (*row).clone()) + .collect(), total_rows: row_refs.len(), } } @@ -1147,18 +1214,36 @@ fn validate_response_values( Ok(()) } -#[tauri::command] -pub fn export_results(request: ExportResultsRequest) -> Result<(), AppError> { - crate::services::workbook::export_results( - &request.path, - &request.analysis, - request.stratify_index, - ) +pub(crate) async fn export_results_protected( + authorization: CapturedAuthorization, + request: ExportResultsRequest, +) -> Result<(), AppError> { + tauri::async_runtime::spawn_blocking(move || { + crate::run_protected_work(authorization, |authorization| { + crate::services::workbook::export_results( + &request.path, + &request.analysis, + request.stratify_index, + &|| authorization.is_valid(), + ) + }) + }) + .await + .map_err(|error| AppError::new("workbookExportWorkerError", error.to_string()))? } -#[tauri::command] -pub fn quit_application(app: tauri::AppHandle) { +pub(crate) fn quit_application_protected( + authorization: &CapturedAuthorization, + app: tauri::AppHandle, +) -> Result<(), AppError> { + if !authorization.is_valid() { + return Err(AppError::new( + "accessDenied", + authorization.auth().denial_message(), + )); + } app.exit(0); + Ok(()) } fn default_role(header: &str, _index: usize) -> String { @@ -1248,11 +1333,10 @@ fn suggest_roles(headers: &[String]) -> Vec { && (normalized.contains("concentration") || normalized.contains("conc") || parse_concentration_header(header).is_some()) + && let Some(drug_index) = used_drugs.iter().position(|used| !used) { - if let Some(drug_index) = used_drugs.iter().position(|used| !used) { - roles[index] = format!("drug{}", (b'A' + drug_index as u8) as char); - used_drugs[drug_index] = true; - } + roles[index] = format!("drug{}", (b'A' + drug_index as u8) as char); + used_drugs[drug_index] = true; } } @@ -1431,16 +1515,51 @@ mod tests { "Response".into(), ], rows: vec![ - vec!["Org 1".into(), "A".into(), "B".into(), "0".into(), "0".into(), "1".into()], - vec!["Org 1".into(), "A".into(), "B".into(), "1".into(), "1".into(), "0.5".into()], - vec!["Org 2".into(), "A".into(), "B".into(), "0".into(), "0".into(), "1".into()], - vec!["Org 2".into(), "A".into(), "B".into(), "1".into(), "1".into(), "0.25".into()], + vec![ + "Org 1".into(), + "A".into(), + "B".into(), + "0".into(), + "0".into(), + "1".into(), + ], + vec![ + "Org 1".into(), + "A".into(), + "B".into(), + "1".into(), + "1".into(), + "0.5".into(), + ], + vec![ + "Org 2".into(), + "A".into(), + "B".into(), + "0".into(), + "0".into(), + "1".into(), + ], + vec![ + "Org 2".into(), + "A".into(), + "B".into(), + "1".into(), + "1".into(), + "0.25".into(), + ], ], }; assert_eq!( suggest_roles(&table.headers), - ["organism", "drugNameA", "drugNameB", "drugA", "drugB", "response"] + [ + "organism", + "drugNameA", + "drugNameB", + "drugA", + "drugB", + "response" + ] ); let regimens = describe_regimens(&table, &[3, 4], Some(5), Some(0)); assert_eq!(regimens.len(), 2); @@ -1448,13 +1567,8 @@ mod tests { assert_eq!(regimens[0].organism.as_deref(), Some("Org 1")); assert_eq!(regimens[1].organism.as_deref(), Some("Org 2")); - let selected = select_regimen_rows( - &table, - &["A".into(), "B".into()], - Some("Org 2"), - Some(0), - ) - .unwrap(); + let selected = + select_regimen_rows(&table, &["A".into(), "B".into()], Some("Org 2"), Some(0)).unwrap(); assert_eq!(selected.len(), 2); assert!(selected.iter().all(|row| row[0] == "Org 2")); } @@ -1956,8 +2070,14 @@ mod tests { concentration_units: vec!["mg/L".into(), "mg/L".into()], clinically_relevant_concentrations: Vec::new(), concentration_ranges: vec![ - ConcentrationRange { minimum: Some(0.75), maximum: Some(1.25) }, - ConcentrationRange { minimum: Some(0.75), maximum: Some(1.25) }, + ConcentrationRange { + minimum: Some(0.75), + maximum: Some(1.25), + }, + ConcentrationRange { + minimum: Some(0.75), + maximum: Some(1.25), + }, ], }, None, @@ -1968,8 +2088,14 @@ mod tests { assert_eq!( result.concentration_ranges, vec![ - ConcentrationRange { minimum: Some(0.75), maximum: Some(1.25) }, - ConcentrationRange { minimum: Some(0.75), maximum: Some(1.25) }, + ConcentrationRange { + minimum: Some(0.75), + maximum: Some(1.25) + }, + ConcentrationRange { + minimum: Some(0.75), + maximum: Some(1.25) + }, ] ); assert_eq!(result.concentration_units, vec!["mg/L", "mg/L"]); @@ -1990,10 +2116,8 @@ mod tests { assert_eq!(inferred("-0.02", "0.75"), ResponseType::InhibitionFraction); let mut noisy_fractional_rows = vec![vec!["0".into(), "0".into(), "1".into()]]; - noisy_fractional_rows - .extend((0..4).map(|_| vec!["1".into(), "1".into(), "1.4".into()])); - noisy_fractional_rows - .extend((0..36).map(|_| vec!["1".into(), "1".into(), "0.25".into()])); + noisy_fractional_rows.extend((0..4).map(|_| vec!["1".into(), "1".into(), "1.4".into()])); + noisy_fractional_rows.extend((0..36).map(|_| vec!["1".into(), "1".into(), "0.25".into()])); assert_eq!( infer_response_type( &noisy_fractional_rows.iter().collect::>(), @@ -2154,7 +2278,10 @@ mod tests { concentration_ranges: vec![ ConcentrationRange::default(), ConcentrationRange::default(), - ConcentrationRange { minimum: Some(1.0), maximum: Some(16.0) }, + ConcentrationRange { + minimum: Some(1.0), + maximum: Some(16.0), + }, ], }, None, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8da9afc..5a7ec75 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,29 +1,330 @@ +mod auth; mod commands; mod error; +mod protected_output; pub mod services; +use auth::{AuthState, CapturedAuthorization}; +use commands::{ + AnalysisProgress, AnalyzeDiamondRequest, AnalyzeTableRequest, DrusanoFitProgress, + FitMusycRequest, MusycFitProgress, PrepareDrusanoDataRequest, +}; +use error::AppError; +use protected_output::{ProtectedOutput, ProtectedResult, protect_result}; +use serde::Serialize; +use std::future::Future; +use tauri::{AppHandle, Manager, Runtime}; + +fn access_denied(authorization: &CapturedAuthorization) -> AppError { + AppError::new("accessDenied", authorization.auth().denial_message()) +} + +/// Run queued/blocking work only with the exact permit captured at the +/// synchronous dispatcher boundary, then recheck before publishing its result. +pub(crate) fn run_protected_work( + authorization: CapturedAuthorization, + work: F, +) -> Result +where + F: FnOnce(CapturedAuthorization) -> Result, +{ + if !authorization.is_valid() { + return Err(access_denied(&authorization)); + } + let result = work(authorization.clone()); + if !authorization.is_valid() { + return Err(access_denied(&authorization)); + } + result +} + +async fn protected_blocking( + label: &'static str, + authorization: CapturedAuthorization, + work: F, +) -> Result +where + T: Send + 'static, + F: FnOnce(CapturedAuthorization) -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(move || run_protected_work(authorization, work)) + .await + .map_err(|error| AppError::new("workerError", format!("{label} task failed: {error}")))? +} + +fn command_arg<'de, R, T>( + invoke: &'de tauri::ipc::Invoke, + command: &'static str, + key: &'static str, +) -> Result +where + R: Runtime, + T: tauri::ipc::CommandArg<'de, R>, +{ + T::from_command(tauri::ipc::CommandItem { + plugin: None, + name: command, + key, + message: &invoke.message, + acl: &invoke.acl, + }) +} + +fn reject_protected( + invoke: tauri::ipc::Invoke, + authorization: CapturedAuthorization, + error: tauri::ipc::InvokeError, +) -> bool { + invoke + .resolver + .reject(ProtectedOutput::new(authorization, error.0)); + true +} + +fn queue_protected( + invoke: tauri::ipc::Invoke, + authorization: CapturedAuthorization, + handler: F, +) -> bool +where + R: Runtime, + T: Serialize + Send + 'static, + F: FnOnce(CapturedAuthorization) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + invoke.resolver.respond_async(async move { + #[cfg(test)] + authorization.auth().pause_test_dispatch(); + handler(authorization) + .await + .map_err(tauri::ipc::InvokeError::from) + }); + true +} + +macro_rules! dispatch_blocking { + ($invoke:ident, $authorization:ident, $command:literal, $label:literal, $handler:path $(, $argument:ident: $ty:ty => $key:literal)* $(,)?) => {{ + $( + let $argument: $ty = match command_arg::(&$invoke, $command, $key) { + Ok(value) => value, + Err(error) => return reject_protected($invoke, $authorization, error), + }; + )* + queue_protected($invoke, $authorization, move |authorization| async move { + let result = protected_blocking($label, authorization.clone(), move |_| { + $handler($($argument),*) + }).await; + protect_result(authorization, result) + }) + }}; +} + +macro_rules! dispatch_async { + ($invoke:ident, $authorization:ident, $command:literal, $handler:path $(, $argument:ident: $ty:ty => $key:literal)* $(,)?) => {{ + $( + let $argument: $ty = match command_arg::(&$invoke, $command, $key) { + Ok(value) => value, + Err(error) => return reject_protected($invoke, $authorization, error), + }; + )* + queue_protected($invoke, $authorization, move |authorization| async move { + let result = $handler(authorization.clone(), $($argument),*).await; + protect_result(authorization, result) + }) + }}; +} + +fn is_protected_command(command: &str) -> bool { + matches!( + command, + "list_worksheets" + | "import_preview" + | "infer_mics" + | "prepare_drusano_data" + | "suggest_drusano_censor_limit" + | "fit_drusano_greco" + | "fit_musyc" + | "simulate_drusano_regimen" + | "analyze_table" + | "analyze_diamond" + | "export_results" + | "save_project_snapshot" + | "load_project_snapshot" + | "quit_application" + ) +} + +fn register_commands(builder: tauri::Builder) -> tauri::Builder { + let auth_handlers: Box) -> bool + Send + Sync> = + Box::new(tauri::generate_handler![ + auth::auth_status, + auth::auth_open_launcher, + ]); + + builder.invoke_handler(move |invoke| { + let command = invoke.message.command().to_owned(); + if !is_protected_command(&command) { + return auth_handlers(invoke); + } + + // Capture the original SDK capability and its verified account before + // Tauri queues any asynchronous command handler or blocking work. + let state = invoke.message.state_ref().try_get::(); + let denial_message = state.as_ref().map_or_else( + || "Checkmate access is locked.".to_string(), + |auth| auth.denial_message().to_string(), + ); + let authorization = state + .as_ref() + .and_then(|auth| auth.capture_authorization()); + drop(state); + let Some(authorization) = authorization else { + invoke + .resolver + .reject(AppError::new("accessDenied", denial_message)); + return true; + }; + + match command.as_str() { + "list_worksheets" => dispatch_blocking!( + invoke, + authorization, + "list_worksheets", + "worksheet listing", + commands::list_worksheets, + path: String => "path" + ), + "import_preview" => dispatch_blocking!( + invoke, + authorization, + "import_preview", + "import preview", + commands::import_preview, + request: services::importer::ImportRequest => "request" + ), + "infer_mics" => dispatch_blocking!( + invoke, + authorization, + "infer_mics", + "MIC inference", + commands::infer_mics, + request: commands::InferMicsRequest => "request" + ), + "prepare_drusano_data" => dispatch_blocking!( + invoke, + authorization, + "prepare_drusano_data", + "Drusano data preparation", + commands::prepare_drusano_data, + request: PrepareDrusanoDataRequest => "request" + ), + "suggest_drusano_censor_limit" => dispatch_blocking!( + invoke, + authorization, + "suggest_drusano_censor_limit", + "Drusano censor suggestion", + commands::suggest_drusano_censor_limit, + request: commands::SuggestDrusanoCensorLimitRequest => "request" + ), + "fit_drusano_greco" => dispatch_async!( + invoke, + authorization, + "fit_drusano_greco", + commands::fit_drusano_greco_protected, + request: PrepareDrusanoDataRequest => "request", + on_progress: tauri::ipc::Channel> => "onProgress" + ), + "fit_musyc" => dispatch_async!( + invoke, + authorization, + "fit_musyc", + commands::fit_musyc_protected, + request: FitMusycRequest => "request", + on_progress: tauri::ipc::Channel> => "onProgress" + ), + "simulate_drusano_regimen" => dispatch_async!( + invoke, + authorization, + "simulate_drusano_regimen", + commands::simulate_drusano_regimen_protected, + request: services::drusano_greco::DrusanoRegimenSimulationRequest => "request" + ), + "analyze_table" => dispatch_async!( + invoke, + authorization, + "analyze_table", + commands::analyze_table_protected, + request: AnalyzeTableRequest => "request", + on_progress: tauri::ipc::Channel> => "onProgress" + ), + "analyze_diamond" => dispatch_async!( + invoke, + authorization, + "analyze_diamond", + commands::analyze_diamond_protected, + request: AnalyzeDiamondRequest => "request", + on_progress: tauri::ipc::Channel> => "onProgress" + ), + "export_results" => dispatch_async!( + invoke, + authorization, + "export_results", + commands::export_results_protected, + request: commands::ExportResultsRequest => "request" + ), + "save_project_snapshot" => dispatch_async!( + invoke, + authorization, + "save_project_snapshot", + commands::save_project_snapshot_protected, + path: String => "path", + snapshot_json: String => "snapshotJson" + ), + "load_project_snapshot" => dispatch_async!( + invoke, + authorization, + "load_project_snapshot", + commands::load_project_snapshot_protected, + path: String => "path" + ), + "quit_application" => { + let app: AppHandle = match command_arg::>( + &invoke, + "quit_application", + "app", + ) { + Ok(value) => value, + Err(error) => return reject_protected(invoke, authorization, error), + }; + queue_protected(invoke, authorization, move |authorization| async move { + let result = commands::quit_application_protected(&authorization, app); + protect_result(authorization, result) + }) + } + _ => { + invoke + .resolver + .reject(AppError::new("unknownCommand", "Unknown protected command.")); + true + } + } + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![ - commands::list_worksheets, - commands::import_preview, - commands::infer_mics, - commands::prepare_drusano_data, - commands::suggest_drusano_censor_limit, - commands::fit_drusano_greco, - commands::fit_musyc, - commands::simulate_drusano_regimen, - commands::analyze_table, - commands::analyze_diamond, - commands::export_results, - commands::save_project_snapshot, - commands::load_project_snapshot, - commands::quit_application, - ]) + .setup(|app| { + app.manage(AuthState::load(app.handle(), || {})); + Ok(()) + }); + register_commands(builder) .run(tauri::generate_context!()) .expect("error while running Checkmate"); } + +#[cfg(test)] +mod protected_dispatch_tests; diff --git a/desktop/src-tauri/src/protected_dispatch_tests.rs b/desktop/src-tauri/src/protected_dispatch_tests.rs new file mode 100644 index 0000000..94f6410 --- /dev/null +++ b/desktop/src-tauri/src/protected_dispatch_tests.rs @@ -0,0 +1,564 @@ +use super::*; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tauri::{App, WebviewWindow, test::MockRuntime}; + +fn mock_app(auth: AuthState) -> (App, WebviewWindow) { + let app = register_commands(tauri::test::mock_builder().manage(auth)) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds"); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .expect("mock webview builds"); + (app, webview) +} + +fn request(command: &str, payload: Value) -> tauri::webview::InvokeRequest { + let url = if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost" + } else { + "tauri://localhost" + }; + tauri::webview::InvokeRequest { + cmd: command.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: url.parse().expect("mock URL parses"), + body: tauri::ipc::InvokeBody::Json(payload), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.to_string(), + } +} + +fn invoke( + webview: &WebviewWindow, + command: &str, + payload: Value, +) -> Result { + tauri::test::get_ipc_response(webview, request(command, payload)).map(|body| { + body.deserialize::() + .expect("native IPC response is JSON") + }) +} + +#[cfg(unix)] +mod broker_fixture { + use ed25519_dalek::{Signer, SigningKey}; + use lapkb_authorization_protocol::{ + EnvelopeSigner, LeaseIssue, OriginBinding, SigningError, issue_online_lease, + }; + use lapkb_desktop_session::{ + Challenge, DeviceKey, LeaseVerifier, Nonce, OpaqueId, Proof, ProtectedApp, Reply, + VerificationKeySet, client::Client, client_store::ClientStore, transport, + }; + use std::{ + fs, io, + path::PathBuf, + sync::{Arc, Mutex as StdMutex, atomic::AtomicU64}, + time::{SystemTime, UNIX_EPOCH}, + }; + + const DEVICE_SEED: [u8; 32] = [7; 32]; + const SERVICE_SEED: [u8; 32] = [11; 32]; + const BROKER_INSTANCE: Nonce = Nonce::from_bytes([9; 32]); + const GENERATION: u64 = 1; + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + pub(super) struct PrivateTempDir(PathBuf); + + impl PrivateTempDir { + pub(super) fn new() -> io::Result { + for _ in 0..128 { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = NEXT_DIRECTORY.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = PathBuf::from("/tmp").join(format!( + "checkmate-native-{}-{nonce}-{sequence}", + std::process::id() + )); + match fs::create_dir(&path) { + Ok(()) => { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + return Ok(Self(path)); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a private test directory", + )) + } + + pub(super) fn path(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for PrivateTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + struct TestSigner(SigningKey); + + impl EnvelopeSigner for TestSigner { + fn key_id(&self) -> &str { + "checkmate-native-test" + } + + fn sign(&self, signing_input: &[u8]) -> Result<[u8; 64], SigningError> { + Ok(self.0.sign(signing_input).to_bytes()) + } + } + + struct BrokerState { + account_id: String, + sequence: u64, + issued_at: i64, + } + + impl BrokerState { + fn new() -> Self { + Self::account("account-a", 1) + } + + fn account(account_id: &str, sequence: u64) -> Self { + let issued_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_secs() as i64; + Self { + account_id: account_id.to_owned(), + sequence, + issued_at, + } + } + } + + fn signed_reply(state: &BrokerState, challenge: Challenge) -> Reply { + let device = DeviceKey::from_seed(DEVICE_SEED); + let lease_id = match (state.account_id.as_str(), state.sequence) { + ("account-a", 1) => "00000000-0000-0000-0000-000000000001", + ("account-b", 1) => "00000000-0000-0000-0000-000000000002", + _ => "00000000-0000-0000-0000-000000000003", + } + .parse() + .expect("lease id"); + let issue = LeaseIssue { + account_id: OpaqueId::new(state.account_id.clone()).expect("account id"), + license_id: OpaqueId::new(format!("license-{}", state.account_id)).expect("license id"), + lease_id, + device_id: OpaqueId::new("native-test-device").expect("device id"), + device_public_key: device.public_key(), + app_ids: vec![ProtectedApp::Checkerboard], + sequence: state.sequence, + issued_at: state.issued_at, + origin: OriginBinding::new( + "00000000-0000-0000-0000-000000000010" + .parse() + .expect("origin request id"), + Nonce::from_bytes([8; 32]), + ) + .expect("origin binding"), + }; + let signer = TestSigner(SigningKey::from_bytes(&SERVICE_SEED)); + let Ok((envelope, _)) = issue_online_lease(&signer, &issue) else { + return Reply::Locked; + }; + let Ok(proof) = Proof::issue( + challenge, + &envelope, + &device, + BROKER_INSTANCE, + GENERATION, + state.issued_at, + ) else { + return Reply::Locked; + }; + Reply::Granted { proof } + } + + pub(super) struct SignedBroker { + pub(super) client: Arc, + state: Arc>, + task: tokio::task::JoinHandle<()>, + _broker_directory: PrivateTempDir, + _store_directory: PrivateTempDir, + } + + impl SignedBroker { + pub(super) fn new() -> Self { + let service_key = SigningKey::from_bytes(&SERVICE_SEED); + let verifier = LeaseVerifier::new( + VerificationKeySet::new(vec![( + "checkmate-native-test".to_owned(), + service_key.verifying_key().to_bytes(), + )]) + .expect("verification key"), + ); + let broker_directory = PrivateTempDir::new().expect("broker directory"); + let broker_path = + fs::canonicalize(broker_directory.path()).expect("canonical broker directory"); + let listener = transport::Listener::bind(&broker_path).expect("broker listener"); + let state = Arc::new(StdMutex::new(BrokerState::new())); + let state_for_task = Arc::clone(&state); + let task = tokio::spawn(async move { + loop { + let Ok(mut stream) = listener.accept().await else { + break; + }; + loop { + let Ok(challenge) = transport::read_frame::(&mut stream).await + else { + break; + }; + let reply = { + let state = state_for_task.lock().expect("broker state lock"); + signed_reply(&state, challenge) + }; + if transport::write_frame(&mut stream, &reply).await.is_err() { + break; + } + } + } + }); + let store_directory = PrivateTempDir::new().expect("client store directory"); + let store_path = + fs::canonicalize(store_directory.path()).expect("canonical client store directory"); + let store = + ClientStore::from_key_for_testing(ProtectedApp::Checkerboard, store_path, [8; 32]) + .expect("synthetic encrypted client store"); + let client = Arc::new( + Client::new(ProtectedApp::Checkerboard, broker_path, verifier, store) + .expect("native SDK client"), + ); + Self { + client, + state, + task, + _broker_directory: broker_directory, + _store_directory: store_directory, + } + } + + pub(super) fn set_account(&self, account_id: &str, sequence: u64) { + *self.state.lock().expect("broker state lock") = + BrokerState::account(account_id, sequence); + } + + pub(super) async fn stop(&mut self) { + self.task.abort(); + let _ = (&mut self.task).await; + } + } + + impl Drop for SignedBroker { + fn drop(&mut self) { + self.task.abort(); + } + } +} + +#[test] +fn locked_dispatch_allows_only_auth_controls_and_preserves_known_command_names() { + let (app, webview) = mock_app(AuthState::test_locked()); + let commands = [ + "list_worksheets", + "import_preview", + "infer_mics", + "prepare_drusano_data", + "suggest_drusano_censor_limit", + "fit_drusano_greco", + "fit_musyc", + "simulate_drusano_regimen", + "analyze_table", + "analyze_diamond", + "export_results", + "save_project_snapshot", + "load_project_snapshot", + "quit_application", + ]; + assert_eq!(commands.len(), 14); + for command in commands { + assert!(is_protected_command(command)); + assert_eq!( + invoke(&webview, command, json!({})), + Err(json!({ + "code": "accessDenied", + "message": AuthState::test_locked().denial_message() + })), + "{command} is denied before argument parsing while locked" + ); + } + assert!(!is_protected_command("auth_status")); + assert!(!is_protected_command("auth_open_launcher")); + assert!(!is_protected_command("unknown_command")); + assert!(invoke(&webview, "unknown_command", json!({})).is_err()); + let status = invoke(&webview, "auth_status", json!({})).expect("auth status is allowed"); + assert_eq!(status["phase"], "unconfigured"); + assert!(status["user"].is_null()); + drop(app); +} + +#[cfg(unix)] +#[tokio::test] +async fn registered_dispatch_rejects_account_a_work_after_switch_to_b_and_preserves_snapshot() { + use broker_fixture::SignedBroker; + use std::thread; + + let broker = SignedBroker::new(); + broker + .client + .refresh() + .await + .expect("genuine account A permit"); + let auth = + AuthState::test_authorized(Arc::clone(&broker.client)).expect("verified Checkmate permit"); + let directory = broker_fixture::PrivateTempDir::new().expect("snapshot directory"); + let path = directory.path().join("previous.ckm"); + std::fs::write(&path, b"previous account A snapshot").expect("previous snapshot"); + let (app, webview) = mock_app(auth.clone()); + + let pause = auth.test_pause_next_dispatch(); + let pending_webview = webview.clone(); + let pending_path = path.display().to_string(); + let pending = thread::spawn(move || { + invoke( + &pending_webview, + "save_project_snapshot", + json!({"path": pending_path, "snapshotJson": "new account A data"}), + ) + }); + pause.wait_until_reached(); + + broker.set_account("account-b", 1); + broker + .client + .refresh() + .await + .expect("genuine account B permit"); + assert!( + !auth.test_record_client_access(), + "first B observation locks A" + ); + assert!( + auth.test_record_client_access(), + "verified B becomes current" + ); + assert!(auth.acquire_permit().is_some()); + + pause.release(); + assert!( + pending + .join() + .expect("queued invocation completes") + .is_err() + ); + assert_eq!( + std::fs::read(&path).expect("previous snapshot remains intact"), + b"previous account A snapshot" + ); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + drop(app); +} + +#[cfg(unix)] +#[tokio::test] +async fn revoked_permit_blocks_a_queued_snapshot_commit() { + use broker_fixture::{PrivateTempDir, SignedBroker}; + use std::thread; + + let broker = SignedBroker::new(); + broker + .client + .refresh() + .await + .expect("genuine signed permit"); + let auth = AuthState::test_authorized(Arc::clone(&broker.client)).expect("authorized state"); + let directory = PrivateTempDir::new().expect("snapshot directory"); + let path = directory.path().join("previous.ckm"); + std::fs::write(&path, b"previous snapshot").expect("previous file"); + let (app, webview) = mock_app(auth.clone()); + + let pause = auth.test_pause_next_dispatch(); + let pending_webview = webview.clone(); + let pending_path = path.display().to_string(); + let pending = thread::spawn(move || { + invoke( + &pending_webview, + "save_project_snapshot", + json!({"path": pending_path, "snapshotJson": "revoked data"}), + ) + }); + pause.wait_until_reached(); + + broker.client.disconnect(); + assert!(!auth.test_record_client_access()); + pause.release(); + + assert!( + pending + .join() + .expect("queued invocation completes") + .is_err() + ); + assert_eq!(std::fs::read(&path).unwrap(), b"previous snapshot"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + drop(app); +} + +#[cfg(unix)] +#[tokio::test] +async fn revoked_signed_permit_blocks_progress_channel_and_snapshot_commit() { + use broker_fixture::{PrivateTempDir, SignedBroker}; + let broker = SignedBroker::new(); + broker + .client + .refresh() + .await + .expect("genuine signed permit"); + let auth = AuthState::test_authorized(Arc::clone(&broker.client)).expect("authorized state"); + let authorization = auth + .capture_authorization() + .expect("capture original permit"); + + let events = Arc::new(Mutex::new(Vec::::new())); + let events_in_channel = Arc::clone(&events); + let channel = tauri::ipc::Channel::>::new(move |body| { + events_in_channel + .lock() + .expect("event list lock") + .push(body.deserialize::().expect("progress is JSON")); + Ok(()) + }); + protected_output::send( + &authorization, + &channel, + AnalysisProgress { + completed_iterations: 1, + total_iterations: 10, + }, + ) + .expect("valid permit emits progress"); + assert_eq!(events.lock().unwrap().len(), 1); + + let directory = PrivateTempDir::new().expect("snapshot directory"); + let path = directory.path().join("previous.ckm"); + std::fs::write(&path, b"previous snapshot").expect("previous file"); + broker.client.disconnect(); + assert!( + protected_output::send( + &authorization, + &channel, + AnalysisProgress { + completed_iterations: 2, + total_iterations: 10, + }, + ) + .is_err(), + "revoked event serialization must fail" + ); + assert_eq!( + events.lock().unwrap().len(), + 1, + "revoked progress was not emitted" + ); + assert!( + services::snapshot::save( + path.to_str().expect("UTF-8 temporary path"), + "new snapshot", + &|| authorization.is_valid(), + ) + .is_err() + ); + assert_eq!(std::fs::read(&path).unwrap(), b"previous snapshot"); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn captured_work_does_not_start_after_the_sdk_changes_accounts() { + use broker_fixture::SignedBroker; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc; + use std::thread; + + let broker = SignedBroker::new(); + broker + .client + .refresh() + .await + .expect("genuine account A permit"); + let auth = AuthState::test_authorized(Arc::clone(&broker.client)).expect("account A state"); + let authorization = auth.capture_authorization().expect("captured account A"); + let entered = Arc::new(AtomicBool::new(false)); + let entered_by_work = Arc::clone(&entered); + let (release, wait) = mpsc::sync_channel(0); + let worker = thread::spawn(move || { + wait.recv().expect("queued work released"); + run_protected_work(authorization, move |_| { + entered_by_work.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + }); + + broker.set_account("account-b", 1); + broker.client.refresh().await.expect("account B proof"); + assert!(!auth.test_record_client_access()); + assert!(auth.test_record_client_access()); + release.send(()).expect("release queued work"); + assert!(worker.join().expect("worker completes").is_err()); + assert!(!entered.load(Ordering::SeqCst)); +} + +#[cfg(unix)] +#[tokio::test] +async fn broker_transport_failure_clears_captured_authorization_and_registered_status() { + use broker_fixture::SignedBroker; + + let mut broker = SignedBroker::new(); + broker + .client + .refresh() + .await + .expect("genuine signed permit before broker failure"); + let auth = + AuthState::test_authorized(Arc::clone(&broker.client)).expect("verified Checkmate permit"); + let original_authorization = auth + .capture_authorization() + .expect("capture the original signed permit"); + let (_app, webview) = mock_app(auth.clone()); + let initial_status = invoke(&webview, "auth_status", json!({})) + .expect("registered auth_status is available before failure"); + assert_eq!(initial_status["phase"], "authenticated"); + + broker.stop().await; + assert!( + broker.client.refresh().await.is_err(), + "closed broker transport makes SDK refresh fail" + ); + assert!( + broker.client.permit().is_none(), + "SDK discarded the prior permit" + ); + assert!( + !auth.test_record_client_access(), + "AuthState records the SDK's missing permit as locked" + ); + assert!(auth.acquire_permit().is_none()); + assert!( + !original_authorization.is_valid(), + "the captured original permit is rejected after transport failure" + ); + + let status = invoke(&webview, "auth_status", json!({})) + .expect("registered auth_status remains available while locked"); + assert_eq!(status["phase"], "suspended"); + assert!(status["user"].is_null()); +} diff --git a/desktop/src-tauri/src/protected_output.rs b/desktop/src-tauri/src/protected_output.rs new file mode 100644 index 0000000..62154a0 --- /dev/null +++ b/desktop/src-tauri/src/protected_output.rs @@ -0,0 +1,185 @@ +use crate::{auth::CapturedAuthorization, error::AppError}; +use serde::{Serialize, Serializer, ser::Error as _}; +use std::sync::Arc; + +const REDACTED_SERIALIZATION_ERROR: &str = "Protected output unavailable"; + +struct OutputGuard { + authorization: Option, + denied: bool, +} + +pub(crate) struct ProtectedOutput { + guard: Arc, + value: T, + #[cfg(test)] + permit_checker: Option bool + Send + Sync>>, +} + +impl Clone for ProtectedOutput { + fn clone(&self) -> Self { + Self { + guard: Arc::clone(&self.guard), + value: self.value.clone(), + #[cfg(test)] + permit_checker: self.permit_checker.as_ref().map(Arc::clone), + } + } +} + +pub(crate) type ProtectedResult = Result, ProtectedOutput>; + +impl ProtectedOutput { + pub(crate) fn new(authorization: CapturedAuthorization, value: T) -> Self { + Self { + guard: Arc::new(OutputGuard { + authorization: Some(authorization), + denied: false, + }), + value, + #[cfg(test)] + permit_checker: None, + } + } + + fn denied(authorization: CapturedAuthorization, value: T) -> Self { + Self { + guard: Arc::new(OutputGuard { + authorization: Some(authorization), + denied: true, + }), + value, + #[cfg(test)] + permit_checker: None, + } + } + + #[cfg(test)] + fn new_with_checker(value: T, checker: impl Fn() -> bool + Send + Sync + 'static) -> Self { + Self { + guard: Arc::new(OutputGuard { + authorization: None, + denied: false, + }), + value, + permit_checker: Some(Arc::new(checker)), + } + } + + fn permit_is_valid(&self) -> bool { + #[cfg(test)] + if let Some(checker) = self.permit_checker.as_ref() { + return checker(); + } + + !self.guard.denied + && self + .guard + .authorization + .as_ref() + .is_some_and(CapturedAuthorization::is_valid) + } +} + +pub(crate) fn send( + authorization: &CapturedAuthorization, + channel: &tauri::ipc::Channel>, + value: T, +) -> tauri::Result<()> { + channel.send(ProtectedOutput::new(authorization.clone(), value)) +} + +pub(crate) fn denied(authorization: &CapturedAuthorization) -> ProtectedOutput { + ProtectedOutput::denied( + authorization.clone(), + AppError::new( + "accessDenied", + authorization.auth().denial_message().to_string(), + ), + ) +} + +impl std::fmt::Debug for ProtectedOutput { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProtectedOutput") + .finish_non_exhaustive() + } +} + +impl Serialize for ProtectedOutput { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if !self.permit_is_valid() { + return Err(S::Error::custom(REDACTED_SERIALIZATION_ERROR)); + } + let result = self.value.serialize(serializer); + if !self.permit_is_valid() { + return Err(S::Error::custom(REDACTED_SERIALIZATION_ERROR)); + } + result + } +} + +pub(crate) fn protect_result( + authorization: CapturedAuthorization, + result: Result, +) -> ProtectedResult { + if !authorization.is_valid() { + return Err(denied(&authorization)); + } + match result { + Ok(value) => Ok(ProtectedOutput::new(authorization, value)), + Err(error) => Err(ProtectedOutput::new(authorization, error)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }; + + struct RevokeDuringSerialize(Arc); + + impl Serialize for RevokeDuringSerialize { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.store(false, Ordering::SeqCst); + serializer.serialize_str("secret payload") + } + } + + #[test] + fn output_checks_authority_before_and_after_serialization() { + let valid = Arc::new(AtomicBool::new(true)); + let output = + ProtectedOutput::new_with_checker(RevokeDuringSerialize(Arc::clone(&valid)), { + let valid = Arc::clone(&valid); + move || valid.load(Ordering::SeqCst) + }); + let error = serde_json::to_string(&output).expect_err("revoked output must fail"); + assert_eq!(error.to_string(), REDACTED_SERIALIZATION_ERROR); + } + + #[test] + fn revoked_output_is_rejected_before_private_values_are_serialized() { + let checks = Arc::new(AtomicUsize::new(0)); + let output = ProtectedOutput::new_with_checker(json!({"private": "value"}), { + let checks = Arc::clone(&checks); + move || { + checks.fetch_add(1, Ordering::SeqCst); + false + } + }); + assert!(serde_json::to_string(&output).is_err()); + assert_eq!(checks.load(Ordering::SeqCst), 1); + } +} diff --git a/desktop/src-tauri/src/services/atomic_file.rs b/desktop/src-tauri/src/services/atomic_file.rs new file mode 100644 index 0000000..1d9477f --- /dev/null +++ b/desktop/src-tauri/src/services/atomic_file.rs @@ -0,0 +1,169 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(0); + +/// Write into a unique same-directory staging file and replace the destination +/// only while the captured authorization remains current. A failed guard or +/// commit leaves any previous destination intact. +pub(crate) fn write_authorized( + path: &Path, + contents: &[u8], + authorized: &dyn Fn() -> bool, +) -> io::Result<()> { + if !authorized() { + return Err(permission_denied()); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let (staging_path, mut staging_file) = create_staging_file(parent)?; + let result = (|| { + staging_file.write_all(contents)?; + staging_file.sync_all()?; + drop(staging_file); + if !authorized() { + return Err(permission_denied()); + } + replace_file(&staging_path, path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&staging_path); + } + result +} + +fn create_staging_file(parent: &Path) -> io::Result<(PathBuf, File)> { + for _ in 0..128 { + let id = NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!(".checkmate-{}-{id}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(file) => return Ok((path, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not create unique Checkmate staging file", + )) +} + +fn permission_denied() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "protected write was revoked", + ) +} + +#[cfg(not(windows))] +fn replace_file(staging: &Path, destination: &Path) -> io::Result<()> { + fs::rename(staging, destination) +} + +#[cfg(windows)] +fn replace_file(staging: &Path, destination: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x0000_0001; + const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; + + fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() + } + + #[link(name = "Kernel32")] + unsafe extern "system" { + fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32; + } + + let staging = wide(staging); + let destination = wide(destination); + // Both paths are in the same directory and staging is exclusively owned. + // SAFETY: the buffers are nul-terminated and remain alive for this call. + let replaced = unsafe { + MoveFileExW( + staging.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn private_temp_dir() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "checkmate-atomic-write-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("make test directory private"); + } + path + } + + #[test] + fn revoked_commit_keeps_the_previous_file_and_removes_staging() { + let directory = private_temp_dir(); + let destination = directory.join("snapshot.ckm"); + fs::write(&destination, b"previous snapshot").expect("create old snapshot"); + let checks = Arc::new(AtomicUsize::new(0)); + let result = write_authorized(&destination, b"new snapshot", &|| { + checks.fetch_add(1, Ordering::SeqCst) == 0 + }); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + assert_eq!( + fs::read(&destination).expect("previous file remains"), + b"previous snapshot" + ); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authorized_commit_replaces_the_previous_file() { + let directory = private_temp_dir(); + let destination = directory.join("export.xlsx"); + fs::write(&destination, b"old workbook").expect("create previous workbook"); + write_authorized(&destination, b"complete new workbook", &|| true).unwrap(); + assert_eq!(fs::read(&destination).unwrap(), b"complete new workbook"); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 1); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/desktop/src-tauri/src/services/mod.rs b/desktop/src-tauri/src/services/mod.rs index fb9570b..6ba5fd6 100644 --- a/desktop/src-tauri/src/services/mod.rs +++ b/desktop/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod atomic_file; pub mod drusano_greco; pub mod importer; pub mod musyc; diff --git a/desktop/src-tauri/src/services/snapshot.rs b/desktop/src-tauri/src/services/snapshot.rs index 59d6862..50e48d6 100644 --- a/desktop/src-tauri/src/services/snapshot.rs +++ b/desktop/src-tauri/src/services/snapshot.rs @@ -6,14 +6,16 @@ use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; const MAGIC: &[u8; 8] = b"CKMATE01"; const MAX_DECOMPRESSED_BYTES: u64 = 512 * 1024 * 1024; -pub fn save(path: &str, snapshot_json: &str) -> anyhow::Result<()> { +pub fn save(path: &str, snapshot_json: &str, authorized: &dyn Fn() -> bool) -> anyhow::Result<()> { anyhow::ensure!(!snapshot_json.is_empty(), "project snapshot is empty"); let encoded = encode(snapshot_json.as_bytes())?; - std::fs::write(path, encoded).with_context(|| format!("could not write project snapshot {path}")) + super::atomic_file::write_authorized(std::path::Path::new(path), &encoded, authorized) + .with_context(|| format!("could not write project snapshot {path}")) } pub fn load(path: &str) -> anyhow::Result { - let encoded = std::fs::read(path).with_context(|| format!("could not read project snapshot {path}"))?; + let encoded = + std::fs::read(path).with_context(|| format!("could not read project snapshot {path}"))?; let decoded = decode(&encoded)?; String::from_utf8(decoded).context("project snapshot does not contain valid UTF-8 state") } @@ -32,7 +34,10 @@ fn decode(value: &[u8]) -> anyhow::Result> { let mut limited = decoder.take(MAX_DECOMPRESSED_BYTES + 1); let mut output = Vec::new(); limited.read_to_end(&mut output)?; - anyhow::ensure!(output.len() as u64 <= MAX_DECOMPRESSED_BYTES, "project snapshot exceeds the 512 MiB safety limit"); + anyhow::ensure!( + output.len() as u64 <= MAX_DECOMPRESSED_BYTES, + "project snapshot exceeds the 512 MiB safety limit" + ); Ok(output) } @@ -42,11 +47,19 @@ mod tests { #[test] fn compressed_binary_snapshot_round_trips_and_rejects_other_files() { - let json = format!(r#"{{"schemaVersion":1,"effects":[{}]}}"#, "0.123456,".repeat(10_000)); + let json = format!( + r#"{{"schemaVersion":1,"effects":[{}]}}"#, + "0.123456,".repeat(10_000) + ); let encoded = encode(json.as_bytes()).unwrap(); assert!(encoded.starts_with(MAGIC)); assert!(encoded.len() < json.len() / 10); assert_eq!(decode(&encoded).unwrap(), json.as_bytes()); - assert!(decode(b"ordinary json").unwrap_err().to_string().contains("not a Checkmate")); + assert!( + decode(b"ordinary json") + .unwrap_err() + .to_string() + .contains("not a Checkmate") + ); } } diff --git a/desktop/src-tauri/src/services/workbook.rs b/desktop/src-tauri/src/services/workbook.rs index 7038a09..3084ecc 100644 --- a/desktop/src-tauri/src/services/workbook.rs +++ b/desktop/src-tauri/src/services/workbook.rs @@ -11,6 +11,7 @@ pub fn export_results( path: &str, analysis: &AnalysisResult, stratify_index: Option, + authorized: &dyn Fn() -> bool, ) -> Result<(), AppError> { if analysis.mic_values.len() != analysis.drug_names.len() || analysis @@ -121,10 +122,19 @@ pub fn export_results( .map(|unit| format!(" {unit}")) .unwrap_or_default(); if let Some(range) = analysis.concentration_ranges.get(index) { - let minimum = range.minimum.map_or_else(|| "unbounded".into(), |value| value.to_string()); - let maximum = range.maximum.map_or_else(|| "unbounded".into(), |value| value.to_string()); + let minimum = range + .minimum + .map_or_else(|| "unbounded".into(), |value| value.to_string()); + let maximum = range + .maximum + .map_or_else(|| "unbounded".into(), |value| value.to_string()); format!("{drug}={minimum}–{maximum}{unit}") - } else if let Some(target) = analysis.clinically_relevant_concentrations.get(index).copied().flatten() { + } else if let Some(target) = analysis + .clinically_relevant_concentrations + .get(index) + .copied() + .flatten() + { format!("{drug}={}–{}{unit}", target / 4.0, target * 4.0) } else { format!("{drug}=unrestricted") @@ -335,7 +345,9 @@ pub fn export_results( .map_err(xlsx_error)?; } worksheet.set_freeze_panes(1, 0).map_err(xlsx_error)?; - workbook.save(path).map_err(xlsx_error) + let contents = workbook.save_to_buffer().map_err(xlsx_error)?; + super::atomic_file::write_authorized(Path::new(path), &contents, authorized) + .map_err(|error| AppError::new("workbookExportError", error.to_string())) } fn write_aggregate_interpretation( @@ -440,7 +452,7 @@ mod tests { "checkerboard-workbook-test-{}.xlsx", std::process::id() )); - export_results(path.to_string_lossy().as_ref(), &analysis, None).unwrap(); + export_results(path.to_string_lossy().as_ref(), &analysis, None, &|| true).unwrap(); assert!(std::fs::metadata(&path).unwrap().len() > 1_000); std::fs::remove_file(path).unwrap(); } diff --git a/desktop/src/App.css b/desktop/src/App.css index e2cfbbb..07324d4 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -14,6 +14,12 @@ button { cursor: pointer; } button:disabled { cursor: not-allowed; opacity: .5; } .app-shell { min-height: 100vh; background: #f3f6f8; } +.access-gate { min-height: calc(100vh - 58px); display: grid; place-items: center; padding: 24px; } +.access-card { width: min(520px, 100%); padding: 28px; border: 1px solid #d9e1e8; border-radius: 10px; background: white; box-shadow: 0 8px 30px rgba(35, 55, 75, .1); } +.access-card h1 { margin: 0 0 12px; color: #235789; font-size: 1.35rem; } +.access-card p { line-height: 1.5; } +.access-card .primary-button { margin-top: 8px; } +.access-error { color: #8c2020; } .app-header { height: 58px; display: grid; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 375a4d0..1beb2bf 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { Channel, invoke } from "@tauri-apps/api/core"; import { open, save as saveDialog } from "@tauri-apps/plugin-dialog"; +import { AuthGate } from "./AuthGate"; import { aggregateBliss, buildMapping, compareRegimens, concentrationRangeFor, exceedanceDomain, formatNumber, formatPValue, groupAnalysisUnits, hasConcentrationRanges, inactiveDrugPairSummary, isClinicalWindowCell, micAssignmentKey, propagateSharedDrugConcentrations, roleLabel, stratificationIndexFor, suggestMicsByOrganismDrug, validateRoles, withinClinicalWindow } from "./analysis"; import { DrusanoComparisonWorkspace, DrusanoFitWorkspace, DrusanoRegimenWorkspace, InputTypeControls, ProjectWorkspace } from "./DrusanoGreco"; import { MusycComparisonWorkspace, MusycFitWorkspace } from "./Musyc"; @@ -149,6 +150,31 @@ const initialMusycModelSettings: MusycModelSettings = { }; function App() { + const [launcherError, setLauncherError] = useState(null); + + async function openLauncher() { + setLauncherError(null); + try { + await invoke("auth_open_launcher", { startup: false }); + } catch (reason) { + setLauncherError(errorMessage(reason)); + } + } + + return ( + + {() => } + + ); +} + +function ProtectedWorkspace() { const [page, setPage] = useState("project"); const [analysisType, setAnalysisType] = useState("bliss"); const [inputSettings, setInputSettings] = useState(initialInputSettings); diff --git a/desktop/src/AuthGate.dom.test.tsx b/desktop/src/AuthGate.dom.test.tsx new file mode 100644 index 0000000..110ffb4 --- /dev/null +++ b/desktop/src/AuthGate.dom.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment jsdom +import { StrictMode, act, useEffect, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; +import { invoke } from "@tauri-apps/api/core"; + +import { AuthGate } from "./AuthGate"; +import type { AuthView } from "./types"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const statusMock = vi.mocked(invoke) as unknown as Mock< + (...args: unknown[]) => Promise +>; +const launcherMock = vi.fn<() => Promise>(); +const mountedAccounts: string[] = []; +const unmountedAccounts: string[] = []; + +const authenticated = (accountId: string | null = "account-a"): AuthView => ({ + phase: "authenticated", + user: accountId + ? { subject: accountId, displayName: accountId, email: null } + : null, + accountId, + message: null, +}); + +const signedOut = (): AuthView => ({ + phase: "signed_out", + user: null, + accountId: null, + message: "Open LAPKB Launcher to sign in.", +}); + +const formatError = (reason: unknown) => + reason instanceof Error ? reason.message : "unexpected error"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const Probe = ({ accountId }: { accountId: string }) => { + const [draft, setDraft] = useState(""); + useEffect(() => { + mountedAccounts.push(accountId); + return () => { + unmountedAccounts.push(accountId); + }; + }, [accountId]); + + return ( +
+ setDraft(event.currentTarget.value)} + /> +
+ ); +}; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function renderGate(strict = false) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const gate = ( + + {(accountId) => } + + ); + await act(async () => { + root?.render(strict ? {gate} : gate); + }); + await settle(); +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +function getInput(): HTMLInputElement { + const input = container?.querySelector( + 'input[aria-label="Unsaved draft"]', + ); + if (!input) throw new Error("Protected draft input is not mounted"); + return input; +} + +async function editDraft(value: string) { + const input = getInput(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + if (!setter) throw new Error("Input value setter is unavailable"); + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function unmountGate() { + if (!root) return; + await act(async () => root?.unmount()); + root = null; +} + +beforeEach(() => { + vi.useFakeTimers(); + statusMock.mockReset(); + launcherMock.mockReset().mockResolvedValue(undefined); + mountedAccounts.length = 0; + unmountedAccounts.length = 0; + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, + }); +}); + +afterEach(async () => { + await unmountGate(); + container?.remove(); + container = null; + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("Checkmate AuthGate rendered behavior", () => { + it("locks and unmounts an authenticated workspace when status rejects", async () => { + const rejectedStatus = deferred(); + statusMock + .mockReturnValueOnce(Promise.resolve(authenticated("account-a"))) + .mockReturnValueOnce(rejectedStatus.promise); + + await renderGate(); + expect(mountedAccounts).toEqual(["account-a"]); + + await advance(1000); + rejectedStatus.reject(new Error("broker unavailable")); + await settle(); + + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(unmountedAccounts).toEqual(["account-a"]); + expect(container?.textContent).toContain( + "Could not check LAPKB access: broker unavailable", + ); + }); + + it("locks a hung status request at its two-second deadline and keeps polling singleflight", async () => { + statusMock + .mockReturnValueOnce(Promise.resolve(authenticated("account-a"))) + .mockImplementation(() => new Promise(() => undefined)); + + await renderGate(); + expect( + container?.querySelector("[data-account='account-a']"), + ).not.toBeNull(); + expect(mountedAccounts).toEqual(["account-a"]); + + await advance(1000); + await advance(1000); + expect(statusMock).toHaveBeenCalledTimes(2); + expect( + container?.querySelector("[data-account='account-a']"), + ).not.toBeNull(); + expect(mountedAccounts).toEqual(["account-a"]); + + await advance(1000); + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(unmountedAccounts).toEqual(["account-a"]); + expect(container?.textContent).toContain("access check timed out"); + }); + + it("ignores a positive reply that arrives after the status deadline", async () => { + const delayedStatus = deferred(); + statusMock.mockReturnValue(delayedStatus.promise); + const intervalSpy = vi.spyOn(window, "setInterval"); + + await renderGate(); + const interval = intervalSpy.mock.results.find( + (_, index) => intervalSpy.mock.calls[index]?.[1] === 1000, + )?.value; + if (interval === undefined) + throw new Error("AuthGate polling interval was not installed"); + window.clearInterval(interval); + + await advance(2000); + expect(container?.querySelector("[data-account]")).toBeNull(); + delayedStatus.resolve(authenticated("late-account")); + await settle(); + + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(mountedAccounts).toEqual([]); + }); + + it("accepts B after A times out and ignores A when its positive reply arrives late", async () => { + const requestA = deferred(); + const requestB = deferred(); + statusMock + .mockReturnValueOnce(requestA.promise) + .mockReturnValueOnce(requestB.promise) + .mockResolvedValue(signedOut()); + + await renderGate(); + await advance(2000); + expect(container?.querySelector("[data-account]")).toBeNull(); + await advance(1000); + expect(statusMock).toHaveBeenCalledTimes(2); + + requestB.resolve(authenticated("account-b")); + await settle(); + expect( + container?.querySelector("[data-account='account-b']"), + ).not.toBeNull(); + expect(mountedAccounts).toEqual(["account-b"]); + + requestA.resolve(authenticated("account-a")); + await settle(); + expect( + container?.querySelector("[data-account='account-b']"), + ).not.toBeNull(); + expect(container?.querySelector("[data-account='account-a']")).toBeNull(); + expect(mountedAccounts).toEqual(["account-b"]); + expect(unmountedAccounts).toEqual([]); + }); + + it("never mounts an authenticated status without a verified accountId", async () => { + statusMock.mockResolvedValue(authenticated(null)); + + await renderGate(); + + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(mountedAccounts).toEqual([]); + expect(container?.textContent).toContain("Checkmate access is locked"); + }); + + it("unmounts cleanly and cannot resurrect a workspace from a pending reply", async () => { + const delayedStatus = deferred(); + statusMock.mockReturnValue(delayedStatus.promise); + + await renderGate(); + expect(vi.getTimerCount()).toBe(2); + await unmountGate(); + expect(vi.getTimerCount()).toBe(0); + + delayedStatus.resolve(authenticated("account-a")); + await settle(); + + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(mountedAccounts).toEqual([]); + expect(launcherMock).not.toHaveBeenCalled(); + }); + + it("disposes A's workspace and draft when the verified account changes to B", async () => { + let current = authenticated("account-a"); + statusMock.mockImplementation(() => Promise.resolve(current)); + + await renderGate(); + await editDraft("private A draft"); + current = authenticated("account-b"); + await advance(1000); + + expect(unmountedAccounts).toEqual(["account-a"]); + expect(container?.querySelector("[data-account='account-a']")).toBeNull(); + expect( + container?.querySelector("[data-account='account-b']"), + ).not.toBeNull(); + expect(getInput().value).toBe(""); + expect(mountedAccounts).toEqual(["account-a", "account-b"]); + }); + + it("ignores the disposed StrictMode effect and accepts only its active request", async () => { + const disposedStatus = deferred(); + const activeStatus = deferred(); + statusMock + .mockReturnValueOnce(disposedStatus.promise) + .mockReturnValueOnce(activeStatus.promise); + + await renderGate(true); + expect(statusMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(2); + + disposedStatus.resolve(authenticated("disposed-account")); + await settle(); + expect(container?.querySelector("[data-account]")).toBeNull(); + + activeStatus.resolve(authenticated("active-account")); + await settle(); + expect( + container?.querySelector("[data-account='active-account']"), + ).not.toBeNull(); + expect(mountedAccounts).toEqual(["active-account", "active-account"]); + expect(unmountedAccounts).toEqual(["active-account"]); + }); + + it("keeps the locked view's Open Launcher action wired to the app callback", async () => { + statusMock.mockResolvedValue(signedOut()); + + await renderGate(); + const button = container?.querySelector( + ".access-card button", + ); + if (!button) throw new Error("Open Launcher button is not rendered"); + await act(async () => button.click()); + + expect(launcherMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/desktop/src/AuthGate.tsx b/desktop/src/AuthGate.tsx new file mode 100644 index 0000000..da86700 --- /dev/null +++ b/desktop/src/AuthGate.tsx @@ -0,0 +1,128 @@ +import { Fragment, useEffect, useState, type ReactNode } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +import type { AuthView } from "./types"; +const AUTH_STATUS_POLL_INTERVAL_MS = 1000; +const AUTH_STATUS_TIMEOUT_MS = 2000; + +interface AuthGateProps { + buildVersion: string; + children: (accountId: string) => ReactNode; + formatError: (reason: unknown) => string; + launcherError: string | null; + logo: string; + openLauncher: () => Promise; +} + +export function AuthGate({ + buildVersion, + children, + formatError, + launcherError, + logo, + openLauncher, +}: AuthGateProps) { + const [authView, setAuthView] = useState(null); + const [authStatusError, setAuthStatusError] = useState(null); + + useEffect(() => { + let active = true; + let pending = false; + let requestGeneration = 0; + let pendingDeadline: number | undefined; + + const refreshStatus = () => { + if (!active || pending) return; + + pending = true; + const requestId = ++requestGeneration; + const deadline = window.setTimeout(() => { + if (!active || requestId !== requestGeneration) return; + requestGeneration += 1; + pending = false; + pendingDeadline = undefined; + setAuthView(null); + setAuthStatusError("The LAPKB access check timed out."); + }, AUTH_STATUS_TIMEOUT_MS); + pendingDeadline = deadline; + + void invoke("auth_status") + .then((view) => { + if (!active || requestId !== requestGeneration) return; + setAuthView(view); + setAuthStatusError(null); + }) + .catch((reason: unknown) => { + if (!active || requestId !== requestGeneration) return; + setAuthView(null); + setAuthStatusError(formatError(reason)); + }) + .finally(() => { + window.clearTimeout(deadline); + if (pendingDeadline === deadline) pendingDeadline = undefined; + if (requestId === requestGeneration) pending = false; + }); + }; + + refreshStatus(); + const timer = window.setInterval( + refreshStatus, + AUTH_STATUS_POLL_INTERVAL_MS, + ); + return () => { + active = false; + requestGeneration += 1; + window.clearInterval(timer); + if (pendingDeadline !== undefined) { + window.clearTimeout(pendingDeadline); + } + }; + }, [formatError]); + + if (authView?.phase === "authenticated" && authView.accountId) { + return ( + + {children(authView.accountId)} + + ); + } + + return ( +
+
+
+ Checkmate logo + + Checkmate v{buildVersion} + +
+
+
+
+

Checkmate access is locked

+

+ {authView?.message ?? + (authStatusError + ? `Could not check LAPKB access: ${authStatusError}` + : "Checking shared LAPKB access…")} +

+

+ Sign in or restore your Checkmate license in LAPKB Launcher. + Checkmate does not store sign-in credentials. +

+ + {launcherError && ( +

+ {launcherError} +

+ )} +
+
+
+ ); +} diff --git a/desktop/src/types.ts b/desktop/src/types.ts index ed4d383..1d9c9ff 100644 --- a/desktop/src/types.ts +++ b/desktop/src/types.ts @@ -1,3 +1,18 @@ +export type AuthPhase = "unconfigured" | "restoring" | "signed_out" | "authenticated" | "suspended"; + +export interface AuthUser { + subject: string; + displayName: string; + email: string | null; +} + +export interface AuthView { + phase: AuthPhase; + user: AuthUser | null; + accountId: string | null; + message: string | null; +} + export type ColumnRole = | "ignore" | "drugNameA" | "drugNameB" | "drugNameC" diff --git a/scripts/ci/build-container-candidate.sh b/scripts/ci/build-container-candidate.sh index 5f2403f..71972d4 100755 --- a/scripts/ci/build-container-candidate.sh +++ b/scripts/ci/build-container-candidate.sh @@ -36,13 +36,50 @@ artifact_prefix=checkmate-artifacts : "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" : "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" : "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${SSH_AUTH_SOCK:?SSH_AUTH_SOCK is required}" +: "${LAPKB_SDK_PUBLIC_KEY_FILE:?LAPKB_SDK_PUBLIC_KEY_FILE is required}" +: "${LAPKB_PROTOCOL_PUBLIC_KEY_FILE:?LAPKB_PROTOCOL_PUBLIC_KEY_FILE is required}" +: "${LAPKB_LOCAL_SIGNING_KID:?Public verifier key ID is required}" +: "${LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64:?Public verifier key is required}" +node scripts/ci/validate-pilot-inputs.mjs repo_root="$(git rev-parse --show-toplevel)" runner_temp_real="$(realpath -e -- "$RUNNER_TEMP")" runner_uid="$(id -u)" runner_gid="$(id -g)" : "${CHECKMATE_SOURCE_SHA:?CHECKMATE_SOURCE_SHA is required}" [[ "$(git rev-parse HEAD)" == "$CHECKMATE_SOURCE_SHA" ]] || { echo 'Unexpected source commit' >&2; exit 1; } -unset DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG BUILDX_CONFIG SSH_AUTH_SOCK SSH_AGENT_PID +ssh_socket="$SSH_AUTH_SOCK" +sdk_public_key_file="$LAPKB_SDK_PUBLIC_KEY_FILE" +protocol_public_key_file="$LAPKB_PROTOCOL_PUBLIC_KEY_FILE" +python3 - "$runner_temp_real" "$ssh_socket" "$sdk_public_key_file" "$protocol_public_key_file" <<'PY' +import os, pathlib, stat, sys +root = pathlib.Path(sys.argv[1]).resolve(strict=True) +socket, sdk, protocol = map(pathlib.Path, sys.argv[2:]) +try: + directory = sdk.parent + info = os.lstat(socket) + if (not stat.S_ISSOCK(info.st_mode) or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.getuid() or socket.name != 'a' + or socket.parent != directory or socket.resolve(strict=True) != socket): + raise ValueError() + parent = os.lstat(directory) + if (not stat.S_ISDIR(parent.st_mode) or stat.S_ISLNK(parent.st_mode) + or parent.st_uid != os.getuid() or stat.S_IMODE(parent.st_mode) != 0o700 + or directory.parent != root or not directory.name.startswith('checkmate-ssh.') + or directory.resolve(strict=True) != directory): + raise ValueError() + for path, name in ((sdk, 'sdk.pub'), (protocol, 'protocol.pub')): + item = os.lstat(path) + if (path.parent != directory or path.name != name or not stat.S_ISREG(item.st_mode) + or stat.S_ISLNK(item.st_mode) or item.st_uid != os.getuid() + or stat.S_IMODE(item.st_mode) != 0o600 or path.resolve(strict=True) != path): + raise ValueError() +except (OSError, ValueError): + raise SystemExit("Public SSH identities or agent socket are not in this job's private directory") +PY +unset DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG BUILDX_CONFIG SSH_AUTH_SOCK SSH_AGENT_PID \ + GIT_SSH_COMMAND GIT_SSH GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM \ + LAPKB_SDK_PUBLIC_KEY_FILE LAPKB_PROTOCOL_PUBLIC_KEY_FILE LAPKB_PRIVATE_SSH_CONFIG LAPKB_SSH_BINARY private_config='' artifact_dir='' @@ -177,8 +214,13 @@ artifact_may_be_root=1 "${docker_argv[@]}" buildx build \ --builder "$builder" \ --platform "$platform" \ + --ssh "default=$ssh_socket" \ + --secret "id=sdk-public,src=$sdk_public_key_file" \ + --secret "id=protocol-public,src=$protocol_public_key_file" \ --build-arg "$target_argument=$target" \ --build-arg CARGO_BUILD_JOBS=2 \ + --build-arg "LAPKB_LOCAL_SIGNING_KID=$LAPKB_LOCAL_SIGNING_KID" \ + --build-arg "LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64=$LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64" \ --output "type=local,dest=$artifact_dir" \ --file "$dockerfile" \ "$repo_root" diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh index 05afc83..fbd0c3d 100644 --- a/scripts/ci/build-macos-candidate.sh +++ b/scripts/ci/build-macos-candidate.sh @@ -8,7 +8,7 @@ case "$target" in *) printf 'Unsupported macOS target: %s\n' "$target" >&2; exit 2 ;; esac -(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg --config '{"build":{"beforeBuildCommand":""}}' --ci --no-sign -- --locked --offline) +(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg --features local-staging --config '{"build":{"beforeBuildCommand":""}}' --ci --no-sign -- --locked --offline) bundle="${CARGO_TARGET_DIR:?CARGO_TARGET_DIR is required}/$target/release/bundle" app="$bundle/macos/Checkmate.app" plist="$app/Contents/Info.plist" @@ -34,5 +34,5 @@ if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then fi tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app test -s "$bundle/macos/Checkmate.app.tar.gz" -printf '### Unsigned macOS %s build candidate\n\n- **No trusted verifier configuration; not usable as licensed pilots.**\n- No code signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ +printf '### Unsigned macOS %s build candidate\n\n- Existing public staging verifier configuration is embedded; live licensing and runtime acceptance are not established.\n- No code signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ "$target_label" "$actual_arch" "$cross_note" >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/ci/configure-private-git.mjs b/scripts/ci/configure-private-git.mjs new file mode 100755 index 0000000..9ba143a --- /dev/null +++ b/scripts/ci/configure-private-git.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, lstatSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const [sdkPublic, protocolPublic, agentIdentities, sshConfig, gitConfig, knownHosts, agentSocket] = process.argv.slice(2); + +function fail(message) { + throw new Error(message); +} + +function safeAbsolutePath(value) { + return typeof value === "string" && value.startsWith("/") && /^[A-Za-z0-9_./:-]+$/.test(value); +} + +function readPublicKey(path) { + if (!safeAbsolutePath(path)) fail("identity paths must be absolute and shell-safe"); + const stat = lstatSync(path); + if (!stat.isFile()) fail("identity file is not a regular file"); + const text = readFileSync(path, "utf8"); + const lines = text.trimEnd().split(/\r?\n/); + if (lines.length !== 1) fail("identity file must contain one public key"); + const [type, blob] = lines[0].trim().split(/\s+/, 3); + if (!type || !blob || !/^(ssh-|ecdsa-|sk-)/.test(type)) fail("invalid public key"); + const decoded = Buffer.from(blob, "base64"); + if (decoded.length === 0 || decoded.toString("base64").replace(/=+$/, "") !== blob.replace(/=+$/, "")) { + fail("invalid public key encoding"); + } + const checked = spawnSync("ssh-keygen", ["-l", "-f", path], { stdio: "ignore" }); + if (checked.status !== 0) fail("public key failed validation"); + return `${type} ${blob}`; +} + +function readAgentKeys(path) { + if (!safeAbsolutePath(path)) fail("agent identity listing path is invalid"); + const stat = lstatSync(path); + if (!stat.isFile()) fail("agent identity listing is not a regular file"); + return readFileSync(path, "utf8").trim().split(/\r?\n/).filter(Boolean).map((line) => { + const [type, blob] = line.trim().split(/\s+/, 3); + if (!type || !blob) fail("invalid agent identity listing"); + return `${type} ${blob}`; + }); +} + +try { + const paths = [sdkPublic, protocolPublic, agentIdentities, sshConfig, gitConfig, knownHosts, agentSocket]; + if (paths.some((path) => !safeAbsolutePath(path))) fail("all private Git paths must be absolute and shell-safe"); + + const sdkKey = readPublicKey(sdkPublic); + const protocolKey = readPublicKey(protocolPublic); + if (sdkKey === protocolKey) fail("repository identities must be distinct"); + + const expected = new Set([sdkKey, protocolKey]); + const actual = readAgentKeys(agentIdentities); + if (actual.length !== expected.size) fail(`SSH agent must contain exactly two identities (found ${actual.length})`); + if (new Set(actual).size !== actual.length) fail("SSH agent contains duplicate identities"); + if (actual.some((key) => !expected.has(key))) fail("SSH agent contains an identity not associated with either repository"); + + const config = (alias, identity) => `Host ${alias}\n` + + ` HostName github.com\n` + + ` HostKeyAlias github.com\n` + + ` User git\n` + + ` IdentityFile ${identity}\n` + + ` IdentityAgent ${agentSocket}\n` + + ` IdentitiesOnly yes\n` + + ` StrictHostKeyChecking yes\n` + + ` UserKnownHostsFile ${knownHosts}\n` + + ` BatchMode yes\n` + + ` HostKeyAlgorithms ssh-ed25519\n` + + ` PreferredAuthentications publickey\n` + + ` PasswordAuthentication no\n` + + ` KbdInteractiveAuthentication no\n\n`; + const sshText = config("checkmate-sdk", sdkPublic) + + config("checkmate-protocol", protocolPublic) + + `Host *\n` + + ` IdentityAgent none\n` + + ` IdentityFile /dev/null\n` + + ` IdentitiesOnly yes\n` + + ` StrictHostKeyChecking yes\n` + + ` UserKnownHostsFile ${knownHosts}\n` + + ` BatchMode yes\n` + + ` HostKeyAlgorithms ssh-ed25519\n` + + ` PasswordAuthentication no\n` + + ` KbdInteractiveAuthentication no\n`; + const gitText = [ + '[url "ssh://git@checkmate-sdk/LAPKB/Launcher.git"]', + '\tinsteadOf = ssh://git@github.com/LAPKB/Launcher.git', + '[url "ssh://git@checkmate-protocol/LAPKB/desktop-authorization.git"]', + '\tinsteadOf = ssh://git@github.com/LAPKB/desktop-authorization.git', + "", + ].join("\n"); + const hostKey = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n"; + + for (const [path, contents] of [[sshConfig, sshText], [gitConfig, gitText], [knownHosts, hostKey]]) { + writeFileSync(path, contents, { flag: "wx", mode: 0o600 }); + } +} catch (error) { + console.error(`Private Git setup refused: ${error instanceof Error ? error.message : "invalid configuration"}`); + process.exitCode = 1; +} diff --git a/scripts/ci/linux-native.Dockerfile b/scripts/ci/linux-native.Dockerfile index adeec67..42ae96a 100644 --- a/scripts/ci/linux-native.Dockerfile +++ b/scripts/ci/linux-native.Dockerfile @@ -4,6 +4,10 @@ FROM rust:1.97.1-slim-trixie@sha256:8e8cf8f7fd54a2d23d5a743b3a03f56e26b6c774276c ARG LINUX_TARGET ARG CARGO_BUILD_JOBS=2 +ARG LAPKB_LOCAL_SIGNING_KID +ARG LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64 +ENV LAPKB_LOCAL_SIGNING_KID=${LAPKB_LOCAL_SIGNING_KID} \ + LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64=${LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64} ENV CARGO_HOME=/tmp/checkmate-cargo-home \ RUSTUP_HOME=/usr/local/rustup \ CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} \ @@ -28,17 +32,34 @@ RUN case "$(uname -m):$LINUX_TARGET" in \ WORKDIR /workspace COPY . . -RUN cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build +RUN node scripts/ci/validate-pilot-inputs.mjs \ + && cd desktop && npm ci && npm test && npm exec -- tsc && npm exec -- vite build -# This CI-only source uses public dependencies; no private credentials are mounted. -RUN GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_SSH_COMMAND=/bin/false \ +# Fetch only the two pinned private repositories; no private key enters a layer. +RUN --mount=type=ssh,id=default,required=true \ + --mount=type=secret,id=sdk-public,target=/root/.ssh/sdk.pub,required=true \ + --mount=type=secret,id=protocol-public,target=/root/.ssh/protocol.pub,required=true \ + set -eu; \ + test -S "$SSH_AUTH_SOCK"; \ + agent_identities=/tmp/checkmate-agent-identities; \ + git_config=/tmp/checkmate-private-gitconfig; \ + ssh-add -L > "$agent_identities"; \ + chmod 600 "$agent_identities"; \ + trap 'rm -f "$agent_identities" "$git_config" /root/.ssh/config /root/.ssh/known_hosts' EXIT; \ + node scripts/ci/configure-private-git.mjs \ + /root/.ssh/sdk.pub /root/.ssh/protocol.pub "$agent_identities" \ + /root/.ssh/config "$git_config" /root/.ssh/known_hosts "$SSH_AUTH_SOCK"; \ + export GIT_CONFIG_GLOBAL="$git_config" GIT_CONFIG_NOSYSTEM=1 GIT_SSH_VARIANT=ssh; \ + export GIT_SSH_COMMAND='node /workspace/scripts/ci/repo-ssh.mjs'; \ + export LAPKB_PRIVATE_SSH_CONFIG=/root/.ssh/config LAPKB_SSH_BINARY=/usr/bin/ssh; \ cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml ENV CARGO_NET_OFFLINE=true \ GIT_CONFIG_GLOBAL=/dev/null \ GIT_CONFIG_NOSYSTEM=1 \ GIT_SSH_COMMAND=/bin/false -RUN --network=none cd desktop && npm run tauri -- build --target "$LINUX_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline +RUN --network=none cargo test --workspace --locked --offline --features local-staging --manifest-path desktop/src-tauri/Cargo.toml +RUN --network=none cd desktop && npm run tauri -- build --target "$LINUX_TARGET" --features local-staging --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline # AppImage packaging can fetch public linuxdeploy helpers; no SSH mount or keys remain. RUN cd desktop && npm run tauri -- bundle --target "$LINUX_TARGET" --bundles appimage,deb,rpm --ci --no-sign RUN set -eu; \ diff --git a/scripts/ci/repo-ssh.mjs b/scripts/ci/repo-ssh.mjs new file mode 100755 index 0000000..c586eee --- /dev/null +++ b/scripts/ci/repo-ssh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; + +function refuse() { + console.error("Refusing SSH access for an unapproved private Git repository"); + process.exit(1); +} + +const routes = new Map([ + ["git@checkmate-sdk", "git-upload-pack '/LAPKB/Launcher.git'"], + ["git@checkmate-protocol", "git-upload-pack '/LAPKB/desktop-authorization.git'"], +]); +const args = process.argv.slice(2); +const options = []; +while (args[0] === "-o" && args[1] === "SendEnv=GIT_PROTOCOL") { + options.push(args.shift(), args.shift()); +} +if (args.length !== 2) refuse(); +const [host, command] = args; +if (!routes.has(host) || routes.get(host) !== command) refuse(); + +const config = process.env.LAPKB_PRIVATE_SSH_CONFIG; +const ssh = process.env.LAPKB_SSH_BINARY; +if (!config?.startsWith("/") || !ssh?.startsWith("/")) refuse(); +const result = spawnSync(ssh, ["-F", config, ...options, host, command], { stdio: "inherit" }); +if (result.error) { + console.error("Could not start the private-source SSH client"); + process.exit(1); +} +if (result.signal) process.exit(1); +process.exit(result.status ?? 1); diff --git a/scripts/ci/validate-pilot-inputs.mjs b/scripts/ci/validate-pilot-inputs.mjs new file mode 100644 index 0000000..07d025c --- /dev/null +++ b/scripts/ci/validate-pilot-inputs.mjs @@ -0,0 +1,43 @@ +import { Buffer } from "node:buffer"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export function validatePilotInputs(kid, publicKeyB64) { + // Match the pinned protocol's validate_key_id and decode_b64 contracts. + if ( + typeof kid !== "string" || + kid.length === 0 || + kid.length > 64 || + /[^A-Za-z0-9_.-]/.test(kid) + ) { + throw new Error( + "LAPKB_LOCAL_SIGNING_KID must be a valid SDK key ID (1-64 ASCII letters, digits, '.', '_' or '-')", + ); + } + const keyError = + "LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64 must be canonical unpadded base64url for a 32-byte public key"; + if (typeof publicKeyB64 !== "string" || publicKeyB64.length !== 43) { + throw new Error(keyError); + } + const decoded = Buffer.from(publicKeyB64, "base64url"); + if (decoded.length !== 32 || decoded.toString("base64url") !== publicKeyB64) { + throw new Error(keyError); + } + return { kid, publicKeyB64 }; +} + +if ( + process.argv[1] && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + try { + validatePilotInputs( + process.env.LAPKB_LOCAL_SIGNING_KID, + process.env.LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64, + ); + console.log("Public pilot verification inputs have valid shape."); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile index b0c226a..f1c2f39 100644 --- a/scripts/ci/windows-cross.Dockerfile +++ b/scripts/ci/windows-cross.Dockerfile @@ -4,6 +4,10 @@ FROM node:24-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca ARG WINDOWS_TARGET ARG CARGO_BUILD_JOBS=2 +ARG LAPKB_LOCAL_SIGNING_KID +ARG LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64 +ENV LAPKB_LOCAL_SIGNING_KID=${LAPKB_LOCAL_SIGNING_KID} \ + LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64=${LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64} ENV CARGO_HOME=/tmp/checkmate-cargo-home \ RUSTUP_HOME=/tmp/checkmate-rustup-home \ CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} \ @@ -30,11 +34,28 @@ RUN case "$WINDOWS_TARGET" in \ WORKDIR /workspace COPY . . -RUN cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build +RUN node scripts/ci/validate-pilot-inputs.mjs \ + && cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build RUN install -d -m 700 /root/.ssh -RUN GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_SSH_COMMAND=/bin/false \ +# Fetch only the two pinned private repositories; no private key enters a layer. +RUN --mount=type=ssh,id=default,required=true \ + --mount=type=secret,id=sdk-public,target=/root/.ssh/sdk.pub,required=true \ + --mount=type=secret,id=protocol-public,target=/root/.ssh/protocol.pub,required=true \ + set -eu; \ + test -S "$SSH_AUTH_SOCK"; \ + agent_identities=/tmp/checkmate-agent-identities; \ + git_config=/tmp/checkmate-private-gitconfig; \ + ssh-add -L > "$agent_identities"; \ + chmod 600 "$agent_identities"; \ + trap 'rm -f "$agent_identities" "$git_config" /root/.ssh/config /root/.ssh/known_hosts' EXIT; \ + node scripts/ci/configure-private-git.mjs \ + /root/.ssh/sdk.pub /root/.ssh/protocol.pub "$agent_identities" \ + /root/.ssh/config "$git_config" /root/.ssh/known_hosts "$SSH_AUTH_SOCK"; \ + export GIT_CONFIG_GLOBAL="$git_config" GIT_CONFIG_NOSYSTEM=1 GIT_SSH_VARIANT=ssh; \ + export GIT_SSH_COMMAND='node /workspace/scripts/ci/repo-ssh.mjs'; \ + export LAPKB_PRIVATE_SSH_CONFIG=/root/.ssh/config LAPKB_SSH_BINARY=/usr/bin/ssh; \ cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml ENV CARGO_NET_OFFLINE=true \ @@ -46,7 +67,7 @@ ENV CARGO_NET_OFFLINE=true \ RUN cargo xwin cache xwin -RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline +RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --features local-staging --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline # Tauri may download public NSIS helpers; no source credentials remain in this step. RUN cd desktop && npm run tauri -- bundle --target "$WINDOWS_TARGET" --bundles nsis --ci --no-sign diff --git a/scripts/ci/with-private-ssh-agent.sh b/scripts/ci/with-private-ssh-agent.sh new file mode 100755 index 0000000..7ecd92f --- /dev/null +++ b/scripts/ci/with-private-ssh-agent.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${LAPKB_SDK_READ_KEY:?LAPKB_SDK_READ_KEY is required}" +: "${LAPKB_PROTOCOL_READ_KEY:?LAPKB_PROTOCOL_READ_KEY is required}" +(( $# > 0 )) || { printf 'usage: with-private-ssh-agent.sh [args...]\n' >&2; exit 2; } + +repo_root="$(git rev-parse --show-toplevel)" +ssh_binary="$(command -v ssh)" +runner_temp="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).resolve(strict=True))' "$RUNNER_TEMP")" +ssh_dir="$(mktemp -d "$runner_temp/checkmate-ssh.XXXXXXXXXX")" +chmod 700 "$ssh_dir" +ssh_dir="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).resolve(strict=True))' "$ssh_dir")" +ssh_dir_identity="$(python3 - "$ssh_dir" <<'PY' +import os, stat, sys +path = sys.argv[1] +info = os.lstat(path) +if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise SystemExit('private SSH directory failed its ownership or mode check') +print(f'{info.st_dev}:{info.st_ino}') +PY +)" +agent_started=0 +dir_created=1 + +validate_ssh_dir() { + python3 - "$RUNNER_TEMP" "$ssh_dir" "$ssh_dir_identity" <<'PY' +import os, pathlib, stat, sys +root = pathlib.Path(sys.argv[1]).resolve(strict=True) +path = pathlib.Path(sys.argv[2]) +try: + info = os.lstat(path) + resolved = path.resolve(strict=True) +except OSError: + raise SystemExit(1) +if (stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode) + and str(resolved) == str(path) and path.parent == root + and path.name.startswith('checkmate-ssh.') + and info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700 + and f'{info.st_dev}:{info.st_ino}' == sys.argv[3]): + raise SystemExit(0) +raise SystemExit(1) +PY +} + +cleanup() { + local status=$? + trap - EXIT INT TERM HUP + set +e + if [[ "$agent_started" == 1 ]]; then + if ! python3 - "$ssh_dir/a" "$SSH_AUTH_SOCK" <<'PY' +import os, pathlib, stat, sys +expected, actual = sys.argv[1:] +try: + info = os.lstat(actual) + valid = (not stat.S_ISLNK(info.st_mode) and stat.S_ISSOCK(info.st_mode) + and info.st_uid == os.getuid() + and pathlib.Path(actual).resolve(strict=True) == pathlib.Path(expected).resolve(strict=True)) +except OSError: + valid = False +if not valid: + raise SystemExit(1) +PY + then + printf "Refusing to stop an unexpected SSH agent\n" >&2 + status=1 + elif ! ssh-agent -k >/dev/null 2>&1; then + printf "Could not stop this job's private SSH agent\n" >&2 + status=1 + fi + fi + if [[ "$dir_created" == 1 ]]; then + if validate_ssh_dir; then + if ! rm -rf -- "$ssh_dir"; then + printf "Could not remove this job's private SSH directory\n" >&2 + status=1 + fi + else + printf 'Refusing cleanup of an unexpected private SSH directory\n' >&2 + status=1 + fi + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +printf '%s\n' "$LAPKB_SDK_READ_KEY" > "$ssh_dir/sdk" +printf '%s\n' "$LAPKB_PROTOCOL_READ_KEY" > "$ssh_dir/protocol" +unset LAPKB_SDK_READ_KEY LAPKB_PROTOCOL_READ_KEY +chmod 600 "$ssh_dir/sdk" "$ssh_dir/protocol" +ssh-keygen -y -P '' -f "$ssh_dir/sdk" > "$ssh_dir/sdk.pub" +ssh-keygen -y -P '' -f "$ssh_dir/protocol" > "$ssh_dir/protocol.pub" +chmod 600 "$ssh_dir/sdk.pub" "$ssh_dir/protocol.pub" + +unset SSH_AUTH_SOCK SSH_AGENT_PID +agent_output="$(ssh-agent -a "$ssh_dir/a" -s 2>/dev/null)" +eval "$agent_output" >/dev/null +agent_started=1 +if [[ "${SSH_AUTH_SOCK:-}" != "$ssh_dir/a" ]]; then + printf 'SSH agent did not bind its private socket\n' >&2 + exit 1 +fi +python3 - "$SSH_AUTH_SOCK" <<'PY' +import os, stat, sys +path = sys.argv[1] +info = os.lstat(path) +if not stat.S_ISSOCK(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid(): + raise SystemExit('SSH agent socket failed its ownership check') +PY +ssh-add -q "$ssh_dir/sdk" "$ssh_dir/protocol" +ssh-add -L > "$ssh_dir/agent-identities" +chmod 600 "$ssh_dir/agent-identities" +python3 - "$SSH_AUTH_SOCK" "$ssh_dir" "$RUNNER_TEMP" "$repo_root" "$ssh_binary" <<'PY' +import re, sys +for value in sys.argv[1:]: + if not re.fullmatch(r'[A-Za-z0-9_./:-]+', value): + raise SystemExit('runner paths contain unsupported characters') +PY +node "$repo_root/scripts/ci/configure-private-git.mjs" \ + "$ssh_dir/sdk.pub" "$ssh_dir/protocol.pub" "$ssh_dir/agent-identities" \ + "$ssh_dir/config" "$ssh_dir/gitconfig" "$ssh_dir/known_hosts" "$SSH_AUTH_SOCK" + +export LAPKB_SDK_PUBLIC_KEY_FILE="$ssh_dir/sdk.pub" +export LAPKB_PROTOCOL_PUBLIC_KEY_FILE="$ssh_dir/protocol.pub" +export LAPKB_PRIVATE_SSH_CONFIG="$ssh_dir/config" +export LAPKB_SSH_BINARY="$ssh_binary" +export GIT_SSH_COMMAND="node \"$repo_root/scripts/ci/repo-ssh.mjs\"" +export GIT_SSH_VARIANT=ssh +export GIT_CONFIG_GLOBAL="$ssh_dir/gitconfig" +export GIT_CONFIG_NOSYSTEM=1 +unset GIT_SSH +"$@" From 2d653a3a7a5f1f20e553ac1e1943d30e9a3384d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 1 Oct 2026 19:52:47 +0100 Subject: [PATCH 09/27] Provide pinned scientific parity oracle in a private CI library --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c1475..747bded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,10 @@ jobs: root="$(mktemp -d "$RUNNER_TEMP/checkmate-rust.XXXXXXXXXX")" root="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$root")" chmod 700 "$root" - mkdir -m 700 "$root/cargo" "$root/rustup" "$root/target" + mkdir -m 700 "$root/cargo" "$root/rustup" "$root/target" \ + "$root/r-library" "$root/r-downloads" identity="$(python3 -c 'import os,sys; s=os.lstat(sys.argv[1]); print(f"{s.st_dev}:{s.st_ino}")' "$root")" - printf 'CARGO_HOME=%s/cargo\nRUSTUP_HOME=%s/rustup\nCARGO_TARGET_DIR=%s/target\n' "$root" "$root" "$root" >> "$GITHUB_ENV" + printf 'CARGO_HOME=%s/cargo\nRUSTUP_HOME=%s/rustup\nCARGO_TARGET_DIR=%s/target\nR_LIBS_USER=%s/r-library\n' "$root" "$root" "$root" "$root" >> "$GITHUB_ENV" printf 'directory=%s\nidentity=%s\n' "$root" "$identity" >> "$GITHUB_OUTPUT" - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with: @@ -76,6 +77,51 @@ jobs: LAPKB_SDK_READ_KEY: ${{ secrets.LAPKB_SDK_READ_KEY }} LAPKB_PROTOCOL_READ_KEY: ${{ secrets.LAPKB_PROTOCOL_READ_KEY }} run: bash scripts/ci/with-private-ssh-agent.sh cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + - name: Install the pinned development-only R parity oracle + timeout-minutes: 30 + env: + R_JOB_DIRECTORY: ${{ steps.rust_home.outputs.directory }} + R_JOB_IDENTITY: ${{ steps.rust_home.outputs.identity }} + run: | + set -euo pipefail + umask 077 + python3 scripts/ci/owned-temp-dir.py validate \ + "$RUNNER_TEMP" "$R_JOB_DIRECTORY" checkmate-rust "$R_JOB_IDENTITY" + export TMPDIR="$R_JOB_DIRECTORY/r-downloads" + archive="$TMPDIR/synergyfinder_3.20.0.tar.gz" + mirror=https://bioconductor.statistik.tu-dortmund.de + package=packages/3.23/bioc/src/contrib/synergyfinder_3.20.0.tar.gz + curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 180 --max-filesize 33554432 \ + --retry 3 --output "$archive" "$mirror/$package" + printf '%s %s\n' \ + a23f757272bffeee7d729e1f6d3bfba062e8ecd25a7597dc34421a7409fdb1f1 \ + "$archive" | shasum -a 256 --check + Rscript --vanilla - "$R_LIBS_USER" "$archive" <<'RS' + args <- commandArgs(trailingOnly = TRUE) + library <- normalizePath(args[[1]], mustWork = TRUE) + .libPaths(c(library, .Library), include.site = FALSE) + options(repos = c(CRAN = "https://cloud.r-project.org"), + timeout = 180) + # Use native binaries, not system/compiler installs. All writes stay + # in this job's library. The shipped app has no R dependency. + dependencies <- c( + "drc", "reshape2", "tidyverse", "dplyr", "tidyr", "purrr", "furrr", + "ggplot2", "ggforce", "vegan", "gstat", "sp", "SpatialExtremes", + "ggrepel", "kriging", "plotly", "stringr", "future", "mice", + "nleqslv", "magrittr", "pbapply", "metR", "jsonlite" + ) + install.packages(dependencies, lib = library, type = "binary", + Ncpus = 2L, + dependencies = c("Depends", "Imports", "LinkingTo")) + install.packages(args[[2]], repos = NULL, type = "source", + lib = library, Ncpus = 2L) + stopifnot(requireNamespace("synergyfinder", quietly = TRUE), + requireNamespace("jsonlite", quietly = TRUE), + identical(as.character(packageVersion("synergyfinder")), + "3.20.0")) + RS - name: Test native licensing and scientific core offline env: GIT_CONFIG_GLOBAL: /dev/null From 49582de2663d022441623d333e254f715baab4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Thu, 1 Oct 2026 21:01:21 +0100 Subject: [PATCH 10/27] Enable verified ad-hoc macOS Checkmate CI bundles --- scripts/ci/build-macos-candidate.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh index fbd0c3d..d18f4d1 100644 --- a/scripts/ci/build-macos-candidate.sh +++ b/scripts/ci/build-macos-candidate.sh @@ -8,7 +8,11 @@ case "$target" in *) printf 'Unsupported macOS target: %s\n' "$target" >&2; exit 2 ;; esac -(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg --features local-staging --config '{"build":{"beforeBuildCommand":""}}' --ci --no-sign -- --locked --offline) +node scripts/ci/validate-pilot-inputs.mjs +(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg \ + --features local-staging \ + --config '{"build":{"beforeBuildCommand":""},"bundle":{"macOS":{"signingIdentity":"-"}}}' \ + --ci -- --locked --offline) bundle="${CARGO_TARGET_DIR:?CARGO_TARGET_DIR is required}/$target/release/bundle" app="$bundle/macos/Checkmate.app" plist="$app/Contents/Info.plist" @@ -26,6 +30,7 @@ if [[ "$actual_arch" != "$expected_arch" ]]; then printf 'Packaged macOS executable architecture mismatch: expected %s, found %s\n' "$expected_arch" "$actual_arch" >&2 exit 1 fi +/usr/bin/codesign --verify --deep --strict "$app" shopt -s nullglob disks=("$bundle/dmg/"*.dmg) if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then @@ -34,5 +39,5 @@ if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then fi tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app test -s "$bundle/macos/Checkmate.app.tar.gz" -printf '### Unsigned macOS %s build candidate\n\n- Existing public staging verifier configuration is embedded; live licensing and runtime acceptance are not established.\n- No code signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ +printf '### macOS %s build candidate\n\n- Existing public staging verifier configuration is embedded; live licensing and runtime acceptance are not established.\n- Ad-hoc bundle signature verified; no Developer ID signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ "$target_label" "$actual_arch" "$cross_note" >> "$GITHUB_STEP_SUMMARY" From 607c4f31d343632359439c1e39d72753e4b59da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 07:58:01 +0100 Subject: [PATCH 11/27] Install the pinned R oracle dependencies in the supported private source library --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747bded..59de760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,15 +104,15 @@ jobs: .libPaths(c(library, .Library), include.site = FALSE) options(repos = c(CRAN = "https://cloud.r-project.org"), timeout = 180) - # Use native binaries, not system/compiler installs. All writes stay - # in this job's library. The shipped app has no R dependency. + # Homebrew R supports source packages, not CRAN macOS binary installs. + # All package writes stay in this job's library; the app needs no R. dependencies <- c( "drc", "reshape2", "tidyverse", "dplyr", "tidyr", "purrr", "furrr", "ggplot2", "ggforce", "vegan", "gstat", "sp", "SpatialExtremes", "ggrepel", "kriging", "plotly", "stringr", "future", "mice", "nleqslv", "magrittr", "pbapply", "metR", "jsonlite" ) - install.packages(dependencies, lib = library, type = "binary", + install.packages(dependencies, lib = library, type = "source", Ncpus = 2L, dependencies = c("Depends", "Imports", "LinkingTo")) install.packages(args[[2]], repos = NULL, type = "source", From e26ea0df54475f3c041b5a45f8f7572d3f078e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 08:28:59 +0100 Subject: [PATCH 12/27] Retain installable macOS candidates before unchanged existing checks --- .github/workflows/ci.yml | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59de760..e2e8da4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,34 @@ jobs: LAPKB_SDK_READ_KEY: ${{ secrets.LAPKB_SDK_READ_KEY }} LAPKB_PROTOCOL_READ_KEY: ${{ secrets.LAPKB_PROTOCOL_READ_KEY }} run: bash scripts/ci/with-private-ssh-agent.sh cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml + - name: Build ARM64 app and DMG + run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin + - name: Prune superseded candidate artifacts + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ci/prune-superseded-artifacts.py + + - name: Retain ARM64 artifacts before Intel build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-arm64-build-candidate + path: | + ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/dmg/*.dmg + ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Install Intel Rust target + run: rustup target add --toolchain 1.97.1 x86_64-apple-darwin + - name: Build Intel app and DMG + run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-x64-build-candidate + path: | + ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/dmg/*.dmg + ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz + if-no-files-found: error + retention-days: 7 - name: Install the pinned development-only R parity oracle timeout-minutes: 30 env: @@ -128,34 +156,6 @@ jobs: GIT_CONFIG_NOSYSTEM: "1" GIT_SSH_COMMAND: /usr/bin/false run: cargo test --workspace --locked --offline --features local-staging --target aarch64-apple-darwin --manifest-path desktop/src-tauri/Cargo.toml - - name: Build ARM64 app and DMG - run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin - - name: Prune superseded candidate artifacts - env: - GITHUB_TOKEN: ${{ github.token }} - run: python3 scripts/ci/prune-superseded-artifacts.py - - - name: Retain ARM64 artifacts before Intel build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: checkmate-macos-arm64-build-candidate - path: | - ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/dmg/*.dmg - ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz - if-no-files-found: error - retention-days: 7 - - name: Install Intel Rust target - run: rustup target add --toolchain 1.97.1 x86_64-apple-darwin - - name: Build Intel app and DMG - run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: checkmate-macos-x64-build-candidate - path: | - ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/dmg/*.dmg - ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz - if-no-files-found: error - retention-days: 7 - name: Remove this job's Rust directories if: always() env: From 1027d520ba0eb1093131efdab6bffe4efe365f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 12:15:01 +0100 Subject: [PATCH 13/27] ci: split macOS app and DMG packaging phases Retain each install archive before the separate DMG packaging phase. --- .github/workflows/ci.yml | 32 +++++++++++++++++-------- scripts/ci/build-macos-candidate.sh | 36 ++++++++++++++++++----------- 2 files changed, 45 insertions(+), 23 deletions(-) mode change 100644 => 100755 scripts/ci/build-macos-candidate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e8da4..a8859a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,8 +77,8 @@ jobs: LAPKB_SDK_READ_KEY: ${{ secrets.LAPKB_SDK_READ_KEY }} LAPKB_PROTOCOL_READ_KEY: ${{ secrets.LAPKB_PROTOCOL_READ_KEY }} run: bash scripts/ci/with-private-ssh-agent.sh cargo fetch --locked --manifest-path desktop/src-tauri/Cargo.toml - - name: Build ARM64 app and DMG - run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin + - name: Build ARM64 app and install archive + run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin app - name: Prune superseded candidate artifacts env: GITHUB_TOKEN: ${{ github.token }} @@ -88,21 +88,33 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: checkmate-macos-arm64-build-candidate - path: | - ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/dmg/*.dmg - ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz + path: ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz if-no-files-found: error retention-days: 7 - name: Install Intel Rust target run: rustup target add --toolchain 1.97.1 x86_64-apple-darwin - - name: Build Intel app and DMG - run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin + - name: Build Intel app and install archive + run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin app - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: checkmate-macos-x64-build-candidate - path: | - ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/dmg/*.dmg - ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz + path: ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Build ARM64 DMG + run: bash scripts/ci/build-macos-candidate.sh aarch64-apple-darwin dmg + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-arm64-dmg-build-candidate + path: ${{ env.CARGO_TARGET_DIR }}/aarch64-apple-darwin/release/bundle/dmg/*.dmg + if-no-files-found: error + retention-days: 7 + - name: Build Intel DMG + run: bash scripts/ci/build-macos-candidate.sh x86_64-apple-darwin dmg + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: checkmate-macos-x64-dmg-build-candidate + path: ${{ env.CARGO_TARGET_DIR }}/x86_64-apple-darwin/release/bundle/dmg/*.dmg if-no-files-found: error retention-days: 7 - name: Install the pinned development-only R parity oracle diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh old mode 100644 new mode 100755 index d18f4d1..6e280a7 --- a/scripts/ci/build-macos-candidate.sh +++ b/scripts/ci/build-macos-candidate.sh @@ -1,15 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -target="${1:?usage: build-macos-candidate.sh }" +target="${1:?usage: build-macos-candidate.sh [app|dmg]}" +phase="${2:-app}" case "$target" in - aarch64-apple-darwin) expected_arch=arm64; target_label=ARM64; cross_note='ARM64 build; native runtime not exercised.' ;; - x86_64-apple-darwin) expected_arch=x86_64; target_label=x64; cross_note='Intel build; native runtime not exercised.' ;; + aarch64-apple-darwin) expected_arch=arm64; target_label=ARM64 ;; + x86_64-apple-darwin) expected_arch=x86_64; target_label=x64 ;; *) printf 'Unsupported macOS target: %s\n' "$target" >&2; exit 2 ;; esac +case "$phase" in + app|dmg) ;; + *) printf 'Unsupported macOS build phase: %s\n' "$phase" >&2; exit 2 ;; +esac node scripts/ci/validate-pilot-inputs.mjs -(cd desktop && npm run tauri -- build --target "$target" --bundles app,dmg \ +(cd desktop && npm run tauri -- build --target "$target" --bundles "$phase" \ --features local-staging \ --config '{"build":{"beforeBuildCommand":""},"bundle":{"macOS":{"signingIdentity":"-"}}}' \ --ci -- --locked --offline) @@ -31,13 +36,18 @@ if [[ "$actual_arch" != "$expected_arch" ]]; then exit 1 fi /usr/bin/codesign --verify --deep --strict "$app" -shopt -s nullglob -disks=("$bundle/dmg/"*.dmg) -if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then - printf 'Expected exactly one non-empty macOS DMG in %s/dmg\n' "$bundle" >&2 - exit 1 +if [[ "$phase" == app ]]; then + tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app + test -s "$bundle/macos/Checkmate.app.tar.gz" + printf '### macOS %s app build candidate\n\n- Ad-hoc signature verified for the app bundle.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- Existing `Checkmate.app.tar.gz` installation archive created and verified non-empty.\n' \ + "$target_label" "$actual_arch" >> "$GITHUB_STEP_SUMMARY" +else + shopt -s nullglob + disks=("$bundle/dmg/"*.dmg) + if (( ${#disks[@]} != 1 )) || [[ ! -s "${disks[0]}" ]]; then + printf 'Expected exactly one non-empty macOS DMG in %s/dmg\n' "$bundle" >&2 + exit 1 + fi + printf '### macOS %s DMG build candidate\n\n- Ad-hoc signature verified for the app bundle.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- Exactly one non-empty DMG verified: `%s`.\n' \ + "$target_label" "$actual_arch" "${disks[0]}" >> "$GITHUB_STEP_SUMMARY" fi -tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app -test -s "$bundle/macos/Checkmate.app.tar.gz" -printf '### macOS %s build candidate\n\n- Existing public staging verifier configuration is embedded; live licensing and runtime acceptance are not established.\n- Ad-hoc bundle signature verified; no Developer ID signing or notarization.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- %s\n- The `.app.tar.gz` updater-format archive is unsigned and not update-ready.\n' \ - "$target_label" "$actual_arch" "$cross_note" >> "$GITHUB_STEP_SUMMARY" From 43d554a94f807a576fd8cb3cf35f36d63611c04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 13:24:48 +0100 Subject: [PATCH 14/27] ci: preserve app bundle during DMG build --- scripts/ci/build-macos-candidate.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh index 6e280a7..09eed6c 100755 --- a/scripts/ci/build-macos-candidate.sh +++ b/scripts/ci/build-macos-candidate.sh @@ -9,12 +9,13 @@ case "$target" in *) printf 'Unsupported macOS target: %s\n' "$target" >&2; exit 2 ;; esac case "$phase" in - app|dmg) ;; + app) bundles=app ;; + dmg) bundles=app,dmg ;; *) printf 'Unsupported macOS build phase: %s\n' "$phase" >&2; exit 2 ;; esac node scripts/ci/validate-pilot-inputs.mjs -(cd desktop && npm run tauri -- build --target "$target" --bundles "$phase" \ +(cd desktop && npm run tauri -- build --target "$target" --bundles "$bundles" \ --features local-staging \ --config '{"build":{"beforeBuildCommand":""},"bundle":{"macOS":{"signingIdentity":"-"}}}' \ --ci -- --locked --offline) From 19e432ca131edf90e29808260d7404cbc468f9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 14:38:50 +0100 Subject: [PATCH 15/27] Keep signed Checkmate CI app archives free of macOS metadata sidecars --- scripts/ci/build-macos-candidate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/build-macos-candidate.sh b/scripts/ci/build-macos-candidate.sh index 09eed6c..f140b92 100755 --- a/scripts/ci/build-macos-candidate.sh +++ b/scripts/ci/build-macos-candidate.sh @@ -38,7 +38,8 @@ if [[ "$actual_arch" != "$expected_arch" ]]; then fi /usr/bin/codesign --verify --deep --strict "$app" if [[ "$phase" == app ]]; then - tar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app + # macOS metadata sidecars are not sealed app resources; do not add them to the archive. + COPYFILE_DISABLE=1 tar --format=ustar -czf "$bundle/macos/Checkmate.app.tar.gz" -C "$bundle/macos" Checkmate.app test -s "$bundle/macos/Checkmate.app.tar.gz" printf '### macOS %s app build candidate\n\n- Ad-hoc signature verified for the app bundle.\n- Packaged Mach-O architecture: `%s` (verified with `lipo` from `CFBundleExecutable`).\n- Existing `Checkmate.app.tar.gz` installation archive created and verified non-empty.\n' \ "$target_label" "$actual_arch" >> "$GITHUB_STEP_SUMMARY" From 55c16dead22071cade3aee73fe5687cb0ed23791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Fri, 2 Oct 2026 20:34:17 +0100 Subject: [PATCH 16/27] ci: provide pinned R oracle libraries in the private macOS job --- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8859a5..e1b2d7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: if-no-files-found: error retention-days: 7 - name: Install the pinned development-only R parity oracle - timeout-minutes: 30 + timeout-minutes: 60 env: R_JOB_DIRECTORY: ${{ steps.rust_home.outputs.directory }} R_JOB_IDENTITY: ${{ steps.rust_home.outputs.identity }} @@ -128,6 +128,89 @@ jobs: python3 scripts/ci/owned-temp-dir.py validate \ "$RUNNER_TEMP" "$R_JOB_DIRECTORY" checkmate-rust "$R_JOB_IDENTITY" export TMPDIR="$R_JOB_DIRECTORY/r-downloads" + # Only the development oracle needs these libraries; the app needs no R. + native="$R_JOB_DIRECTORY/native" + prefix="$native/prefix" + mkdir -m 700 "$native" "$native/src" "$native/build" "$prefix" \ + "$native/home" "$native/cache" "$native/cache/proj" + export HOME="$native/home" XDG_CACHE_HOME="$native/cache" + export HOMEBREW_NO_AUTO_UPDATE=1 + export PATH="$prefix/bin:$PATH" + export PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + # These inspected source archives have no links or escaping paths. + while read -r component checksum url; do + source_archive="$native/$component.archive" + mkdir -m 700 "$native/src/$component" + curl --disable --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 180 --max-filesize 33554432 \ + --retry 3 --output "$source_archive" "$url" + printf '%s %s\n' "$checksum" "$source_archive" | shasum -a 256 --check + tar --no-same-owner --no-same-permissions -xf "$source_archive" \ + -C "$native/src/$component" --strip-components=1 + done <<'NATIVE' + expat 1e727b8933ec51a77a9a9d9afcf8e688bce45d907c13e36ab7393fe36e703182 https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz + sqlite 0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c https://www.sqlite.org/2026/sqlite-autoconf-3530400.tar.gz + udunits 590baec83161a3fd62c00efa66f6113cec8a7c461e3f61a5182167e0cc5d579e https://downloads.unidata.ucar.edu/udunits/2.2.28/udunits-2.2.28.tar.gz + geos d5e5192a686d065eaed082de14dd26244c5c8e02bff16b2c6cce3265f648e00e https://download.osgeo.org/geos/geos-3.15.0.tar.bz2 + proj 791a0610547eeabb17006cfd49cdbd2034f3240f47ed5e88a1031811f4e2bcf3 https://github.com/OSGeo/PROJ/releases/download/9.9.0/proj-9.9.0.tar.gz + gdal 5e0c388d83da2d686cc00a40272882432cdb54edff43d4af173e532844a0a0ea https://github.com/OSGeo/gdal/releases/download/v3.13.3/gdal-3.13.3.tar.gz + NATIVE + build_native_cmake() { + local component="$1" + shift + cmake -S "$native/src/$component" -B "$native/build/$component" \ + -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$prefix" -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_PREFIX_PATH="$prefix" \ + -DCMAKE_C_COMPILER=/usr/bin/clang -DCMAKE_CXX_COMPILER=/usr/bin/clang++ \ + -DCMAKE_C_FLAGS=-fexceptions -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_INSTALL_NAME_DIR="$prefix/lib" \ + -DCMAKE_BUILD_WITH_INSTALL_NAME_DIR=ON -DCMAKE_INSTALL_RPATH="$prefix/lib" \ + -DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ + -DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ + -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF "$@" + cmake --build "$native/build/$component" --parallel 2 + cmake --install "$native/build/$component" + } + build_native_cmake expat -DEXPAT_BUILD_TESTS=OFF -DEXPAT_BUILD_EXAMPLES=OFF \ + -DEXPAT_BUILD_TOOLS=OFF -DEXPAT_BUILD_DOCS=OFF + mkdir -m 700 "$native/build/sqlite" "$native/build/udunits" + ( + cd "$native/build/sqlite" + CC=/usr/bin/clang CFLAGS="-O2" \ + "$native/src/sqlite/configure" --prefix="$prefix" \ + --disable-readline --disable-static \ + --enable-rtree --enable-column-metadata + make -j2 + make install + ) + ( + cd "$native/build/udunits" + # R units requires exception propagation through the C library. + CC=/usr/bin/clang CFLAGS="-O2 -fexceptions" \ + CPPFLAGS="-I$prefix/include" LDFLAGS="-L$prefix/lib -Wl,-rpath,$prefix/lib" \ + "$native/src/udunits/configure" --prefix="$prefix" \ + --enable-shared --disable-static + make -j2 MAKEINFO=true + make install MAKEINFO=true + ) + build_native_cmake geos + build_native_cmake proj -DBUILD_APPS=OFF -DENABLE_CURL=OFF -DENABLE_TIFF=OFF \ + -DNLOHMANN_JSON_ORIGIN=internal -DEXE_SQLITE3="$prefix/bin/sqlite3" \ + -DSQLite3_INCLUDE_DIR="$prefix/include" \ + -DSQLite3_LIBRARY="$prefix/lib/libsqlite3.dylib" + build_native_cmake gdal -DBUILD_APPS=OFF -DBUILD_PYTHON_BINDINGS=OFF \ + -DBUILD_JAVA_BINDINGS=OFF -DBUILD_CSHARP_BINDINGS=OFF -DUSE_CCACHE=OFF \ + -DGDAL_BUILD_OPTIONAL_DRIVERS=OFF -DOGR_BUILD_OPTIONAL_DRIVERS=OFF \ + -DGDAL_USE_EXTERNAL_LIBS=OFF -DGDAL_USE_INTERNAL_LIBS=ON \ + -DGDAL_USE_GEOS=ON -DGDAL_USE_SQLITE3=ON -DGDAL_USE_EXPAT=ON \ + -DSQLite3_INCLUDE_DIR="$prefix/include" \ + -DSQLite3_LIBRARY="$prefix/lib/libsqlite3.dylib" + export UDUNITS2_INCLUDE="$prefix/include" UDUNITS2_LIBS="$prefix/lib" + export UDUNITS2_XML_PATH="$prefix/share/udunits/udunits2.xml" + export GDAL_DATA="$prefix/share/gdal" PROJ_DATA="$prefix/share/proj" + export PROJ_USER_WRITABLE_DIRECTORY="$native/cache/proj" archive="$TMPDIR/synergyfinder_3.20.0.tar.gz" mirror=https://bioconductor.statistik.tu-dortmund.de package=packages/3.23/bioc/src/contrib/synergyfinder_3.20.0.tar.gz @@ -167,6 +250,10 @@ jobs: GIT_CONFIG_GLOBAL: /dev/null GIT_CONFIG_NOSYSTEM: "1" GIT_SSH_COMMAND: /usr/bin/false + UDUNITS2_XML_PATH: ${{ steps.rust_home.outputs.directory }}/native/prefix/share/udunits/udunits2.xml + GDAL_DATA: ${{ steps.rust_home.outputs.directory }}/native/prefix/share/gdal + PROJ_DATA: ${{ steps.rust_home.outputs.directory }}/native/prefix/share/proj + PROJ_USER_WRITABLE_DIRECTORY: ${{ steps.rust_home.outputs.directory }}/native/cache/proj run: cargo test --workspace --locked --offline --features local-staging --target aarch64-apple-darwin --manifest-path desktop/src-tauri/Cargo.toml - name: Remove this job's Rust directories if: always() From a02aa2a937d757a04115d0a2ab5342c85b18eb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sat, 3 Oct 2026 11:50:59 +0100 Subject: [PATCH 17/27] fix: pin monthly authorization SDK for Checkmate Mac pilot --- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 8 ++++---- desktop/src-tauri/Cargo.toml | 6 +++--- desktop/src-tauri/src/protected_dispatch_tests.rs | 1 + desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/App.tsx | 2 +- desktop/src/AuthGate.dom.test.tsx | 2 +- 8 files changed, 14 insertions(+), 13 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 2f1ad07..aaed808 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "checkmate-desktop", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "checkmate-desktop", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", diff --git a/desktop/package.json b/desktop/package.json index 83d7535..2ee7ee1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "checkmate-desktop", "private": true, - "version": "0.8.0", + "version": "0.8.1", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ec77a0a..21e2416 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "checkmate-desktop" -version = "0.8.0" +version = "0.8.1" dependencies = [ "anyhow", "calamine", @@ -2894,8 +2894,8 @@ dependencies = [ [[package]] name = "lapkb-authorization-protocol" -version = "0.1.1" -source = "git+ssh://git@github.com/LAPKB/desktop-authorization.git?rev=bcca349d7ea3d448c85a177fed3b1af4d3f941a1#bcca349d7ea3d448c85a177fed3b1af4d3f941a1" +version = "0.2.7" +source = "git+ssh://git@github.com/LAPKB/desktop-authorization.git?rev=b32bcefb244ffba54b9de47b4004939b6a01b0cd#b32bcefb244ffba54b9de47b4004939b6a01b0cd" dependencies = [ "base64 0.22.1", "ed25519-dalek", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "lapkb-desktop-session" version = "0.1.0" -source = "git+ssh://git@github.com/LAPKB/Launcher.git?rev=daba4ce5add38c9147f185e9caec6defa65a22a6#daba4ce5add38c9147f185e9caec6defa65a22a6" +source = "git+ssh://git@github.com/LAPKB/Launcher.git?rev=ef542825f4b0b131e0feb5c8579ef4882a072f87#ef542825f4b0b131e0feb5c8579ef4882a072f87" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c06f440..25bee65 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "checkmate-desktop" -version = "0.8.0" +version = "0.8.1" description = "Desktop interface for drug-interaction analysis and regimen ranking" authors = ["Michael Neely"] edition = "2024" @@ -46,11 +46,11 @@ rust_xlsxwriter = "0.96" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "daba4ce5add38c9147f185e9caec6defa65a22a6", features = ["client"] } +lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "ef542825f4b0b131e0feb5c8579ef4882a072f87", features = ["client"] } [dev-dependencies] ed25519-dalek = "2.1.1" -lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "bcca349d7ea3d448c85a177fed3b1af4d3f941a1" } +lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "b32bcefb244ffba54b9de47b4004939b6a01b0cd" } tauri = { version = "2", features = ["test"] } [patch.crates-io] diff --git a/desktop/src-tauri/src/protected_dispatch_tests.rs b/desktop/src-tauri/src/protected_dispatch_tests.rs index 94f6410..4a55a56 100644 --- a/desktop/src-tauri/src/protected_dispatch_tests.rs +++ b/desktop/src-tauri/src/protected_dispatch_tests.rs @@ -159,6 +159,7 @@ mod broker_fixture { app_ids: vec![ProtectedApp::Checkerboard], sequence: state.sequence, issued_at: state.issued_at, + expires_at: state.issued_at + 31 * 86_400, origin: OriginBinding::new( "00000000-0000-0000-0000-000000000010" .parse() diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 6dcba35..61f7884 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Checkmate", - "version": "0.8.0", + "version": "0.8.1", "identifier": "org.lapkb.checkmate", "build": { "beforeDevCommand": "npm run dev", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 1beb2bf..9c57549 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -105,7 +105,7 @@ interface ProjectSnapshot { } const BarPlot = lazy(() => import("./BarPlot")); -const appBuild = "0.8.0"; +const appBuild = "0.8.1"; const roleOptions: ColumnRole[] = ["ignore", "organism", "drugNameA", "drugA", "unitsA", "drugNameB", "drugB", "unitsB", "drugNameC", "drugC", "unitsC", "response"]; diff --git a/desktop/src/AuthGate.dom.test.tsx b/desktop/src/AuthGate.dom.test.tsx index 110ffb4..de66ffd 100644 --- a/desktop/src/AuthGate.dom.test.tsx +++ b/desktop/src/AuthGate.dom.test.tsx @@ -90,7 +90,7 @@ async function renderGate(strict = false) { root = createRoot(container); const gate = ( Date: Sat, 3 Oct 2026 14:14:47 +0100 Subject: [PATCH 18/27] fix: correct necessary website release prerequisites for Checkmate --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b2d7e..3400dc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,10 +178,10 @@ jobs: mkdir -m 700 "$native/build/sqlite" "$native/build/udunits" ( cd "$native/build/sqlite" - CC=/usr/bin/clang CFLAGS="-O2" \ + CC=/usr/bin/clang CFLAGS="-O2 -DSQLITE_ENABLE_COLUMN_METADATA" \ "$native/src/sqlite/configure" --prefix="$prefix" \ --disable-readline --disable-static \ - --enable-rtree --enable-column-metadata + --enable-rtree make -j2 make install ) From a072da6ede8102ec79fc0b2d9077a94ace2ff0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sat, 3 Oct 2026 18:01:43 +0100 Subject: [PATCH 19/27] fix: protect Checkmate with its checkerboard role in Checkmate --- lapkb-app.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lapkb-app.json b/lapkb-app.json index 0909787..a938926 100644 --- a/lapkb-app.json +++ b/lapkb-app.json @@ -4,7 +4,8 @@ "id": "checkerboard", "description": "Analyze drug interactions and compare combination regimens.", "accessPolicy": { - "kind": "public" + "kind": "protected", + "role": "checkerboard" }, "icon": "desktop/src-tauri/icons/128x128.png", "tauriConfig": "desktop/src-tauri/tauri.conf.json", From 5c27e2d8e6bebed3ecfd446e22b5282b9641296f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 05:28:53 +0100 Subject: [PATCH 20/27] fix: resolve Windows website runtime review corrections for Checkmate --- .github/workflows/ci.yml | 20 +- desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 3 +- desktop/src-tauri/Cargo.toml | 9 +- desktop/src-tauri/src/auth.rs | 20 +- desktop/src-tauri/src/windows_launcher.rs | 768 ++++++++++++++++++++ desktop/src-tauri/tauri.conf.json | 3 +- desktop/src-tauri/windows-install-hooks.nsh | 8 + scripts/ci/build-container-candidate.sh | 8 +- scripts/ci/verify-windows-artifacts.mjs | 75 +- scripts/ci/windows-cross.Dockerfile | 15 +- scripts/ci/windows-package.mjs | 280 +++++++ scripts/ci/windows-package.test.mjs | 131 ++++ 14 files changed, 1269 insertions(+), 77 deletions(-) create mode 100644 desktop/src-tauri/src/windows_launcher.rs create mode 100644 desktop/src-tauri/windows-install-hooks.nsh create mode 100644 scripts/ci/windows-package.mjs create mode 100644 scripts/ci/windows-package.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3400dc8..a9faf8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -271,20 +271,27 @@ jobs: github.repository == 'LAPKB/Checkerboard' && github.ref == 'refs/heads/launcher-checkmate-support' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - runs-on: [self-hosted, Linux, "${{ matrix.runner_arch }}"] + runs-on: + group: ${{ matrix.runner_group }} + labels: [self-hosted, Linux, "${{ matrix.runner_arch }}"] timeout-minutes: 180 strategy: fail-fast: false + max-parallel: 2 matrix: include: - - target: x86_64-unknown-linux-gnu - runner_arch: X64 - - target: aarch64-unknown-linux-gnu - runner_arch: ARM64 - target: x86_64-pc-windows-msvc runner_arch: X64 + runner_group: Default - target: aarch64-pc-windows-msvc runner_arch: X64 + runner_group: Default + - target: x86_64-unknown-linux-gnu + runner_arch: X64 + runner_group: Default + - target: aarch64-unknown-linux-gnu + runner_arch: ARM64 + runner_group: rust steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: @@ -297,6 +304,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 + - name: Test Windows package parsers without running Windows binaries + if: contains(matrix.target, 'windows') + run: node --test scripts/ci/windows-package.test.mjs - name: Build and verify packages with credentials confined to fetch id: build env: diff --git a/desktop/package-lock.json b/desktop/package-lock.json index aaed808..cc3e368 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "checkmate-desktop", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "checkmate-desktop", - "version": "0.8.1", + "version": "0.8.2", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", diff --git a/desktop/package.json b/desktop/package.json index 2ee7ee1..a212a07 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "checkmate-desktop", "private": true, - "version": "0.8.1", + "version": "0.8.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 21e2416..30d3b94 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "checkmate-desktop" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "calamine", @@ -695,6 +695,7 @@ dependencies = [ "tauri-plugin-store", "thiserror 2.0.20", "tokio", + "windows-sys 0.61.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 25bee65..2981910 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "checkmate-desktop" -version = "0.8.1" +version = "0.8.2" description = "Desktop interface for drug-interaction analysis and regimen ranking" authors = ["Michael Neely"] edition = "2024" @@ -48,6 +48,13 @@ serde_json = "1" thiserror = "2" lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "ef542825f4b0b131e0feb5c8579ef4882a072f87", features = ["client"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Registry", + "Win32_System_Threading", "Win32_UI_Shell", +] } + [dev-dependencies] ed25519-dalek = "2.1.1" lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "b32bcefb244ffba54b9de47b4004939b6a01b0cd" } diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs index a32bea5..100ac0b 100644 --- a/desktop/src-tauri/src/auth.rs +++ b/desktop/src-tauri/src/auth.rs @@ -16,6 +16,7 @@ use lapkb_desktop_session::{client::Client as LauncherSessionClient, client_stor use serde::Serialize; use tauri::{AppHandle, Manager, Runtime, State}; +#[cfg(not(all(windows, target_arch = "x86_64")))] const LAUNCHER_BUNDLE_ID: &str = "org.lapkb.launcher"; const ACCESS_REFRESH_INTERVAL: Duration = Duration::from_secs(1); const TRUST_CONFIGURATION_MESSAGE: &str = "Checkmate could not initialize shared access. Check this build's trusted configuration and OS secure storage, then reopen Checkmate."; @@ -24,9 +25,9 @@ const ACCESS_LOCKED_MESSAGE: &str = "Checkmate access is locked. Open LAPKB Launcher to restore access."; const ACCOUNT_SWITCHED_MESSAGE: &str = "The LAPKB account changed. Checkmate is locked until the new account is verified."; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", all(windows, target_arch = "x86_64")))] const LAUNCHER_OPEN_ERROR: &str = "Could not open LAPKB Launcher."; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", all(windows, target_arch = "x86_64"))))] const LAUNCHER_OPEN_UNSUPPORTED_MESSAGE: &str = "Opening LAPKB Launcher from Checkmate is not supported on this platform."; @@ -521,7 +522,11 @@ impl AuthState { Err(LAUNCHER_OPEN_ERROR.into()) } } - #[cfg(not(target_os = "macos"))] + #[cfg(all(windows, target_arch = "x86_64"))] + { + windows_launcher::open_launcher(startup).map_err(|_| LAUNCHER_OPEN_ERROR.to_string()) + } + #[cfg(not(any(target_os = "macos", all(windows, target_arch = "x86_64"))))] { let _ = (startup, LAUNCHER_BUNDLE_ID); Err(LAUNCHER_OPEN_UNSUPPORTED_MESSAGE.into()) @@ -529,6 +534,10 @@ impl AuthState { } } +#[cfg(all(windows, target_arch = "x86_64"))] +#[path = "windows_launcher.rs"] +mod windows_launcher; + #[cfg(any(unix, windows))] fn account_id_string(client: &LauncherSessionClient) -> Option { client @@ -540,7 +549,10 @@ fn account_id_string(client: &LauncherSessionClient) -> Option { fn prepare_app_data_directory(path: &std::path::Path) -> Result<(), ()> { if !path.is_absolute() || path.components().any(|component| { - matches!(component, std::path::Component::CurDir | std::path::Component::ParentDir) + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) }) { return Err(()); diff --git a/desktop/src-tauri/src/windows_launcher.rs b/desktop/src-tauri/src/windows_launcher.rs new file mode 100644 index 0000000..df6aaf6 --- /dev/null +++ b/desktop/src-tauri/src/windows_launcher.rs @@ -0,0 +1,768 @@ +//! Fixed current-user Launcher re-entry, not an authorization or updater API. +//! Launcher 0.1.9 is bootstrapped manually from the website with its checksum; +//! there is deliberately no invented Launcher signing key/self-update claim. +//! This native boundary verifies the local scope, ACL, path, PE product/version, +//! and immutable open-file identity. It never accepts a caller path/argument. +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, FromRawHandle}, + }, + path::{Component, Path, PathBuf, Prefix}, + process::{Command, Stdio}, + ptr, + sync::atomic::{AtomicBool, Ordering}, +}; +use windows_sys::Win32::{ + Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_ITEMS, ERROR_PATH_NOT_FOUND, GENERIC_READ, + GENERIC_WRITE, INVALID_HANDLE_VALUE, LocalFree, + }, + Security::{ + Authorization::{ConvertSidToStringSidW, GetSecurityInfo, SE_FILE_OBJECT}, + *, + }, + Storage::FileSystem::*, + System::{Com::CoTaskMemFree, Registry::*, Threading::*}, + UI::Shell::{FOLDERID_LocalAppData, SHGetKnownFolderPath}, +}; +static STARTUP_ATTEMPTED: AtomicBool = AtomicBool::new(false); +const PRODUCT: &str = "LAPKB Launcher"; +const BINARY: &str = "lapkb-launcher.exe"; +const KEY: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\LAPKB Launcher"; +fn wide(s: impl AsRef) -> Vec { + s.as_ref().encode_wide().chain(Some(0)).collect() +} +fn registry(hive: HKEY, name: &str, view: u32) -> Result, ()> { + registry_at(hive, KEY, name, view) +} +fn registry_at(hive: HKEY, key: &str, name: &str, view: u32) -> Result, ()> { + let mut buffer = [0u16; 2048]; + let mut size = std::mem::size_of_val(&buffer) as u32; + let status = unsafe { + RegGetValueW( + hive, + wide(key).as_ptr(), + wide(name).as_ptr(), + RRF_RT_REG_SZ | view, + ptr::null_mut(), + buffer.as_mut_ptr().cast(), + &mut size, + ) + }; + if matches!(status, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { + return Ok(None); + } + if status != 0 || size < 2 || size as usize > std::mem::size_of_val(&buffer) || size % 2 != 0 { + return Err(()); + } + let end = size as usize / 2 - 1; + if buffer[end] != 0 || buffer[..end].contains(&0) { + return Err(()); + } + Ok(Some(String::from_utf16(&buffer[..end]).map_err(|_| ())?)) +} +fn local_data() -> Result { + let mut output = ptr::null_mut(); + if unsafe { SHGetKnownFolderPath(&FOLDERID_LocalAppData, 0, ptr::null_mut(), &mut output) } < 0 + || output.is_null() + { + return Err(()); + } + let result = (|| { + let mut length = 0; + while unsafe { *output.add(length) } != 0 { + length += 1; + if length > 32767 { + return Err(()); + } + } + Ok(PathBuf::from( + String::from_utf16(unsafe { std::slice::from_raw_parts(output, length) }) + .map_err(|_| ())?, + )) + })(); + unsafe { CoTaskMemFree(output.cast()) }; + result +} +fn component(name: &str) -> Result<(), ()> { + let stem = name + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + if name.is_empty() + || name.ends_with(['.', ' ']) + || name.chars().any(|c| { + c.is_control() || matches!(c, ':' | '<' | '>' | '"' | '|' | '?' | '*' | '/' | '\\') + }) + || matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + || ["COM", "LPT"].iter().any(|p| { + stem.strip_prefix(p).is_some_and(|n| { + matches!( + n, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) + }) + { + return Err(()); + } + Ok(()) +} +fn local_path(path: &Path) -> Result<(), ()> { + let mut parts = path.components(); + let Some(Component::Prefix(prefix)) = parts.next() else { + return Err(()); + }; + let Prefix::Disk(letter) = prefix.kind() else { + return Err(()); + }; + if !matches!(parts.next(), Some(Component::RootDir)) + || unsafe { GetDriveTypeW(wide(format!("{}:\\", char::from(letter))).as_ptr()) } != 3 + { + return Err(()); + } + for part in parts { + let Component::Normal(name) = part else { + return Err(()); + }; + component(name.to_str().ok_or(())?)?; + } + Ok(()) +} +fn sid_text(sid: *mut core::ffi::c_void) -> Result { + if sid.is_null() || unsafe { IsValidSid(sid) } == 0 { + return Err(()); + } + let mut output = ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut output) } == 0 { + return Err(()); + } + let result = (|| { + let mut length = 0; + while unsafe { *output.add(length) } != 0 { + length += 1; + if length > 256 { + return Err(()); + } + } + String::from_utf16(unsafe { std::slice::from_raw_parts(output, length) }).map_err(|_| ()) + })(); + unsafe { LocalFree(output.cast()) }; + result +} +fn user_sid() -> Result { + let mut handle = ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut handle) } == 0 { + return Err(()); + } + let token = unsafe { File::from_raw_handle(handle) }; + let mut size = 0; + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + ptr::null_mut(), + 0, + &mut size, + ) + }; + if size == 0 || size > 65536 { + return Err(()); + } + let mut buffer = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + size, + &mut size, + ) + } == 0 + { + return Err(()); + } + if (size as usize) < std::mem::size_of::() { + return Err(()); + } + let sid = unsafe { (*buffer.as_ptr().cast::()).User.Sid }; + let start = buffer.as_ptr() as usize; + let address = sid as usize; + if address < start + || address + .checked_add(8) + .is_none_or(|n| n > start + size as usize) + { + return Err(()); + } + let length = 8 + unsafe { *sid.cast::().add(1) } as usize * 4; + if length > 68 + || address + .checked_add(length) + .is_none_or(|n| n > start + size as usize) + { + return Err(()); + } + sid_text(sid) +} +// Windows servicing may own OS directories, never our state or product files. +const TRUSTED_INSTALLER: &str = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; +fn acl_trusted(who: &str, user: &str, directory: bool, owned: bool) -> bool { + who == user + || matches!(who, "S-1-5-18" | "S-1-5-32-544") + || (directory && !owned && who == TRUSTED_INSTALLER) +} +fn acl_owner_allowed(owner: &str, user: &str, directory: bool, owned: bool) -> bool { + if owned { + owner == user + } else { + acl_trusted(owner, user, directory, false) + } +} +fn acl_grant_allowed( + who: &str, + user: &str, + mask: u32, + flags: u8, + directory: bool, + owned: bool, +) -> bool { + if flags & !0x1f != 0 { + return false; + } + if flags & 8 != 0 { + return true; + } // inherit-only does not apply to this object + if acl_trusted(who, user, directory, owned) { + return true; + } + // On OS ancestry only, Users/Authenticated Users can create siblings. Held + // existing children are not delete-shared; these rights cannot replace them. + // The same bits on files mean WRITE_DATA/APPEND_DATA and are always unsafe. + let sibling_creation = directory && !owned && matches!(who, "S-1-5-32-545" | "S-1-5-11"); + let mutations = DELETE + | WRITE_DAC + | WRITE_OWNER + | FILE_DELETE_CHILD + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | GENERIC_WRITE + | 0x10000000 + | if sibling_creation { + 0 + } else { + FILE_WRITE_DATA | FILE_APPEND_DATA + }; + mask & mutations == 0 +} +fn acl(file: &File, user: &str, directory: bool, owned: bool) -> Result<(), ()> { + let mut owner = ptr::null_mut(); + let mut dacl = ptr::null_mut(); + let mut sd = ptr::null_mut(); + if unsafe { + GetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut sd, + ) + } != 0 + { + return Err(()); + } + let result = (|| { + if sd.is_null() || dacl.is_null() { + return Err(()); + } + let owner = sid_text(owner)?; + if !acl_owner_allowed(&owner, user, directory, owned) { + return Err(()); + } + let mut info = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + dacl, + (&mut info as *mut ACL_SIZE_INFORMATION).cast(), + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } == 0 + || info.AceCount > 256 + { + return Err(()); + } + for index in 0..info.AceCount { + let mut ace = ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() { + return Err(()); + } + let header = unsafe { &*ace.cast::() }; + if header.AceType == 1 { + continue; + } + if header.AceType != 0 { + return Err(()); + } + let offset = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + if (header.AceSize as usize) < offset + 8 { + return Err(()); + } + let allowed = unsafe { &*ace.cast::() }; + let sid = ptr::addr_of!(allowed.SidStart) as *mut core::ffi::c_void; + if offset + 8 + unsafe { *sid.cast::().add(1) } as usize * 4 + != header.AceSize as usize + { + return Err(()); + } + if !acl_grant_allowed( + &sid_text(sid)?, + user, + allowed.Mask, + header.AceFlags, + directory, + owned, + ) { + return Err(()); + } + } + Ok(()) + })(); + if !sd.is_null() { + unsafe { LocalFree(sd.cast()) }; + } + result +} +fn open(path: &Path, directory: bool, owned: bool, user: &str) -> Result { + local_path(path)?; + let handle = unsafe { + CreateFileW( + wide(path).as_ptr(), + GENERIC_READ | READ_CONTROL, + FILE_SHARE_READ | if directory { FILE_SHARE_WRITE } else { 0 }, + ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT + | if directory { + FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }, + ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(()); + } + let file = unsafe { File::from_raw_handle(handle) }; + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0) != directory + || (!directory && info.nNumberOfLinks != 1) + { + return Err(()); + } + acl(&file, user, directory, owned)?; + Ok(file) +} +fn pe_product(path: &Path, file: &mut File) -> Result<(), ()> { + let mut dos = [0u8; 64]; + file.read_exact(&mut dos).map_err(|_| ())?; + if &dos[..2] != b"MZ" { + return Err(()); + } + let offset = u32::from_le_bytes(dos[60..64].try_into().map_err(|_| ())?) as u64; + if !(64..=1048576).contains(&offset) { + return Err(()); + } + file.seek(SeekFrom::Start(offset)).map_err(|_| ())?; + let mut pe = [0u8; 6]; + file.read_exact(&mut pe).map_err(|_| ())?; + if &pe[..4] != b"PE\0\0" || u16::from_le_bytes([pe[4], pe[5]]) != 0x8664 { + return Err(()); + } + let size = unsafe { GetFileVersionInfoSizeW(wide(path).as_ptr(), ptr::null_mut()) }; + if size == 0 || size > 1048576 { + return Err(()); + } + let mut buffer = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + if unsafe { GetFileVersionInfoW(wide(path).as_ptr(), 0, size, buffer.as_mut_ptr().cast()) } == 0 + { + return Err(()); + } + let query = |name: &str| -> Result<(*mut core::ffi::c_void, u32), ()> { + let mut output = ptr::null_mut(); + let mut length = 0; + if unsafe { + VerQueryValueW( + buffer.as_ptr().cast(), + wide(name).as_ptr(), + &mut output, + &mut length, + ) + } == 0 + || output.is_null() + { + return Err(()); + } + Ok((output, length)) + }; + let (fixed, length) = query("\\")?; + let start = buffer.as_ptr() as usize; + let bounded = |pointer: *mut core::ffi::c_void, count: usize| { + pointer as usize >= start + && (pointer as usize) + .checked_add(count) + .is_some_and(|n| n <= start + size as usize) + }; + if length as usize != std::mem::size_of::() + || !bounded(fixed, length as usize) + { + return Err(()); + } + let fixed = unsafe { ptr::read_unaligned(fixed.cast::()) }; + let version = ( + fixed.dwProductVersionMS >> 16, + fixed.dwProductVersionMS & 65535, + fixed.dwProductVersionLS >> 16, + ); + if fixed.dwSignature != 0xfeef04bd + || fixed.dwProductVersionLS & 65535 != 0 + || version < (0, 1, 9) + { + return Err(()); + } + let (translations, length) = query("\\VarFileInfo\\Translation")?; + if length == 0 || length > 64 || length % 4 != 0 || !bounded(translations, length as usize) { + return Err(()); + } + let translations = + unsafe { std::slice::from_raw_parts(translations.cast::(), length as usize / 2) }; + for pair in translations.chunks_exact(2) { + let (product, chars) = query(&format!( + "\\StringFileInfo\\{:04x}{:04x}\\ProductName", + pair[0], pair[1] + ))?; + if chars == 0 || chars > 256 || !bounded(product, chars as usize * 2) { + return Err(()); + } + let text = unsafe { std::slice::from_raw_parts(product.cast::(), chars as usize) }; + if text.last() != Some(&0) + || String::from_utf16(&text[..text.len() - 1]).map_err(|_| ())? != PRODUCT + { + return Err(()); + } + } + Ok(()) +} + +struct RegistryKey(HKEY); +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { RegCloseKey(self.0) }; + } +} +fn reject_other_registrations() -> Result<(), ()> { + const ROOT: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + for (hive, key_view, value_view) in [ + (HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY, RRF_SUBKEY_WOW6464KEY), + (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY, RRF_SUBKEY_WOW6432KEY), + (HKEY_CURRENT_USER, KEY_WOW64_64KEY, RRF_SUBKEY_WOW6464KEY), + (HKEY_CURRENT_USER, KEY_WOW64_32KEY, RRF_SUBKEY_WOW6432KEY), + ] { + let mut handle = ptr::null_mut(); + let status = unsafe { + RegOpenKeyExW( + hive, + wide(ROOT).as_ptr(), + 0, + KEY_READ | key_view, + &mut handle, + ) + }; + if matches!(status, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { + continue; + } + if status != 0 { + return Err(()); + } + let key = RegistryKey(handle); + let mut exhausted = false; + for index in 0..8192 { + if std::time::Instant::now() > deadline { + return Err(()); + } + let mut name = [0u16; 256]; + let mut length = name.len() as u32; + let status = unsafe { + RegEnumKeyExW( + key.0, + index, + name.as_mut_ptr(), + &mut length, + ptr::null(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if status == ERROR_NO_MORE_ITEMS { + exhausted = true; + break; + } + if status != 0 || length as usize >= name.len() { + return Err(()); + } + let leaf = String::from_utf16(&name[..length as usize]).map_err(|_| ())?; + if hive == HKEY_CURRENT_USER && leaf.eq_ignore_ascii_case(PRODUCT) { + continue; + } + if registry_at(hive, &format!("{ROOT}\\{leaf}"), "DisplayName", value_view)? + .is_some_and(|display| display.eq_ignore_ascii_case(PRODUCT)) + { + return Err(()); + } + } + if !exhausted { + return Err(()); + } + } + Ok(()) +} + +pub(super) fn open_launcher(startup: bool) -> Result<(), ()> { + if startup && STARTUP_ATTEMPTED.swap(true, Ordering::AcqRel) { + return Ok(()); + } + reject_other_registrations()?; + for (hive, view) in [ + (HKEY_LOCAL_MACHINE, RRF_SUBKEY_WOW6464KEY), + (HKEY_LOCAL_MACHINE, RRF_SUBKEY_WOW6432KEY), + ] { + if registry(hive, "DisplayName", view)?.is_some() { + return Err(()); + } + } + let view = RRF_SUBKEY_WOW6464KEY; + // Shared HKCU views can name the same current-user registration. Accept + // only equal aliases, never a differing 32-bit/custom installation. + if registry(HKEY_CURRENT_USER, "DisplayName", RRF_SUBKEY_WOW6432KEY)?.is_some() { + for field in [ + "DisplayName", + "MainBinaryName", + "InstallLocation", + "DisplayVersion", + ] { + if registry(HKEY_CURRENT_USER, field, view)? + != registry(HKEY_CURRENT_USER, field, RRF_SUBKEY_WOW6432KEY)? + { + return Err(()); + } + } + } + if registry(HKEY_CURRENT_USER, "DisplayName", view)?.as_deref() != Some(PRODUCT) + || registry(HKEY_CURRENT_USER, "MainBinaryName", view)?.as_deref() != Some(BINARY) + { + return Err(()); + } + for msi_view in [view, RRF_SUBKEY_WOW6432KEY] { + let mut msi = 0u32; + let mut bytes = 4; + let status = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + wide(KEY).as_ptr(), + wide("WindowsInstaller").as_ptr(), + RRF_RT_REG_DWORD | msi_view, + ptr::null_mut(), + (&mut msi as *mut u32).cast(), + &mut bytes, + ) + }; + if !(status == ERROR_FILE_NOT_FOUND || (status == 0 && bytes == 4 && msi == 0)) { + return Err(()); + } + } + let data = local_data()?; + let root = data.join(PRODUCT); + let registered = registry(HKEY_CURRENT_USER, "InstallLocation", view)?.ok_or(())?; + let registered = registered + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or(())?; + if Path::new(registered) != root { + return Err(()); + } + let user = user_sid()?; + let mut held = Vec::new(); + let mut current = PathBuf::new(); + for part in root.components() { + current.push(part.as_os_str()); + if matches!(part, Component::Prefix(_)) { + continue; + } + held.push(open( + ¤t, + true, + current == data || current == root, + &user, + )?); + } + let path = root.join(BINARY); + let mut executable = open(&path, false, true, &user)?; + pe_product(&path, &mut executable)?; + // Empty argument vector, fixed direct native spawn; no credentials/shell, + // renderer path, UninstallString, hidden elevation or owner replacement. + let child = Command::new(&path) + .current_dir(&root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| ())?; + drop(executable); + drop(held); + std::thread::spawn(move || { + let mut child = child; + let _ = child.wait(); + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn servicing_ancestor_and_current_user_product_acl_fixtures() { + const USER: &str = "S-1-5-21-111-222-333-1001"; + const STRANGER: &str = "S-1-5-21-111-222-333-1002"; + // Drive-root/OS ancestry: servicing owner, administrator/system full + // access, user-group read/traverse plus sibling creation, creator-owner + // full access only on inherited children. No host ACL is changed. + let ancestor = [ + (TRUSTED_INSTALLER, FILE_ALL_ACCESS, 0u8), + ("S-1-5-18", FILE_ALL_ACCESS, 0), + ("S-1-5-32-544", FILE_ALL_ACCESS, 0), + ("S-1-5-32-545", 0x1200a9, 0), + ("S-1-5-11", 0x1200a9 | FILE_APPEND_DATA, 0), + ("S-1-3-0", FILE_ALL_ACCESS, 0x0b), + ]; + assert!(acl_owner_allowed(TRUSTED_INSTALLER, USER, true, false)); + for (sid, mask, flags) in ancestor { + assert!( + acl_grant_allowed(sid, USER, mask, flags, true, false), + "{sid}" + ); + } + assert!(acl_owner_allowed(USER, USER, true, true)); + for owner in [ + TRUSTED_INSTALLER, + "S-1-5-18", + "S-1-5-32-544", + STRANGER, + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478465", + ] { + assert!(!acl_owner_allowed(owner, USER, true, true)); + } + assert!(!acl_owner_allowed(TRUSTED_INSTALLER, USER, false, false)); + assert!(!acl_owner_allowed(STRANGER, USER, true, false)); + for (sid, mask, flags) in [ + (USER, FILE_ALL_ACCESS, 0), + ("S-1-5-18", FILE_ALL_ACCESS, 0x13), + ("S-1-5-32-544", FILE_ALL_ACCESS, 0x13), + ("S-1-5-32-545", 0x1200a9, 0x13), + ] { + assert!(acl_grant_allowed(sid, USER, mask, flags, true, true)); + } + // ADD_SUBDIRECTORY on a safe ancestor is not APPEND_DATA on a file. + for sid in ["S-1-5-11", "S-1-5-32-545"] { + for mask in [FILE_WRITE_DATA, FILE_APPEND_DATA] { + assert!(acl_grant_allowed(sid, USER, mask, 0, true, false)); + assert!(!acl_grant_allowed(sid, USER, mask, 0, true, true)); + assert!(!acl_grant_allowed(sid, USER, mask, 0, false, false)); + } + } + for sid in [STRANGER, "S-1-1-0", "S-1-5-11", "S-1-5-32-545"] { + for mask in [ + DELETE, + WRITE_DAC, + WRITE_OWNER, + FILE_DELETE_CHILD, + FILE_WRITE_EA, + FILE_WRITE_ATTRIBUTES, + 0x40000000, + 0x10000000, + ] { + assert!( + !acl_grant_allowed(sid, USER, mask, 0, true, false), + "{sid} {mask:x}" + ); + } + } + assert!(!acl_grant_allowed( + STRANGER, + USER, + FILE_APPEND_DATA, + 0, + true, + false + )); + assert!(!acl_grant_allowed( + TRUSTED_INSTALLER, + USER, + FILE_ALL_ACCESS, + 0, + true, + true + )); + assert!(!acl_grant_allowed( + TRUSTED_INSTALLER, + USER, + FILE_ALL_ACCESS, + 0, + false, + false + )); + assert!(!acl_grant_allowed( + USER, + USER, + FILE_ALL_ACCESS, + 0x80, + true, + false + )); + // An inherited effective broad grant on our product is still unsafe. + assert!(!acl_grant_allowed( + STRANGER, + USER, + FILE_ALL_ACCESS, + 0x13, + true, + true + )); + } + #[test] + fn no_network_relative_or_device_paths_qualify() { + for path in [ + "\\\\server\\share\\app.exe", + "C:app.exe", + "app.exe", + "C:\\foo\\..\\app.exe", + "C:\\NUL", + ] { + assert!(local_path(Path::new(path)).is_err()); + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 61f7884..7c6cb66 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Checkmate", - "version": "0.8.1", + "version": "0.8.2", "identifier": "org.lapkb.checkmate", "build": { "beforeDevCommand": "npm run dev", @@ -25,6 +25,7 @@ }, "bundle": { "active": true, + "windows": { "nsis": { "installMode": "currentUser", "installerHooks": "windows-install-hooks.nsh" } }, "targets": "all", "icon": [ "icons/32x32.png", diff --git a/desktop/src-tauri/windows-install-hooks.nsh b/desktop/src-tauri/windows-install-hooks.nsh new file mode 100644 index 0000000..275ff88 --- /dev/null +++ b/desktop/src-tauri/windows-install-hooks.nsh @@ -0,0 +1,8 @@ +; Keep the application payload at the current-user path that Launcher verifies. +; A different selection must abort before any application files are copied. +!macro NSIS_HOOK_PREINSTALL + ${If} $INSTDIR != "$LOCALAPPDATA\${PRODUCTNAME}" + MessageBox MB_OK|MB_ICONSTOP "Install ${PRODUCTNAME} in $LOCALAPPDATA\${PRODUCTNAME}. Custom application folders are not supported." + Abort + ${EndIf} +!macroend diff --git a/scripts/ci/build-container-candidate.sh b/scripts/ci/build-container-candidate.sh index 71972d4..0aa4d7e 100755 --- a/scripts/ci/build-container-candidate.sh +++ b/scripts/ci/build-container-candidate.sh @@ -211,6 +211,11 @@ builder_creation_started=1 "${docker_argv[@]}" buildx create --name "$builder" --driver docker-container >/dev/null "${docker_argv[@]}" buildx inspect "$builder" --bootstrap >/dev/null artifact_may_be_root=1 +package_arguments=() +if [[ "$kind" == windows ]]; then + package_arguments=(--build-arg "PACKAGE_SOURCE_SHA=$(git rev-parse HEAD)" + --build-arg "PACKAGE_RUN_ID=$GITHUB_RUN_ID" --build-arg "PACKAGE_RUN_ATTEMPT=$GITHUB_RUN_ATTEMPT") +fi "${docker_argv[@]}" buildx build \ --builder "$builder" \ --platform "$platform" \ @@ -218,6 +223,7 @@ artifact_may_be_root=1 --secret "id=sdk-public,src=$sdk_public_key_file" \ --secret "id=protocol-public,src=$protocol_public_key_file" \ --build-arg "$target_argument=$target" \ + "${package_arguments[@]}" \ --build-arg CARGO_BUILD_JOBS=2 \ --build-arg "LAPKB_LOCAL_SIGNING_KID=$LAPKB_LOCAL_SIGNING_KID" \ --build-arg "LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64=$LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64" \ @@ -230,7 +236,7 @@ if [[ "$docker_mode" == sudo ]]; then restore_private_dir_owner "$artifact_dir" "$artifact_prefix" "$artifact_identity" fi if [[ "$kind" == windows ]]; then - node scripts/ci/verify-windows-artifacts.mjs "$target" "$artifact_dir" + LAPKB_PACKAGE_SOURCE_SHA="$(git rev-parse HEAD)" node scripts/ci/verify-windows-artifacts.mjs "$target" "$artifact_dir" else node scripts/ci/verify-linux-artifacts.mjs "$target" "$artifact_dir" --receipt fi diff --git a/scripts/ci/verify-windows-artifacts.mjs b/scripts/ci/verify-windows-artifacts.mjs index fed9703..2909da9 100644 --- a/scripts/ci/verify-windows-artifacts.mjs +++ b/scripts/ci/verify-windows-artifacts.mjs @@ -1,67 +1,22 @@ import assert from "node:assert/strict"; -import { createReadStream, openSync, readSync, closeSync, statSync, appendFileSync } from "node:fs"; -import { createHash } from "node:crypto"; +import { readFileSync, readdirSync, appendFileSync } from "node:fs"; import { join } from "node:path"; +import { verifyPackageProof } from "./windows-package.mjs"; const [target, outputDirectory] = process.argv.slice(2); -const expectedMachines = new Map([ - ["x86_64-pc-windows-msvc", 0x8664], - ["aarch64-pc-windows-msvc", 0xaa64], -]); -const expectedMachine = expectedMachines.get(target); -assert(expectedMachine, `Unsupported Windows target: ${target ?? ""}`); -assert(outputDirectory, "Usage: verify-windows-artifacts.mjs "); - -const appPath = join(outputDirectory, "checkmate.exe"); -const installerPath = join(outputDirectory, "checkmate-nsis-installer.exe"); - -function readAppMachine(path) { - const descriptor = openSync(path, "r"); - try { - const dos = Buffer.alloc(64); - assert.equal(readSync(descriptor, dos, 0, dos.length, 0), dos.length, "Truncated DOS header"); - assert.equal(dos.readUInt16LE(0), 0x5a4d, "Checkmate app is not a PE executable"); - const peOffset = dos.readUInt32LE(0x3c); - const pe = Buffer.alloc(6); - assert.equal(readSync(descriptor, pe, 0, pe.length, peOffset), pe.length, "Truncated PE header"); - assert.equal(pe.readUInt32LE(0), 0x00004550, "Invalid PE signature"); - return pe.readUInt16LE(4); - } finally { - closeSync(descriptor); - } -} - -async function sha256(path) { - const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return hash.digest("hex"); -} - -async function describe(label, path) { - const stat = statSync(path); - assert(stat.isFile() && stat.size > 0, `${label} is missing or empty: ${path}`); - return { label, size: stat.size, hash: await sha256(path) }; -} - -const machine = readAppMachine(appPath); -assert.equal( - machine, - expectedMachine, - `Checkmate PE machine is 0x${machine.toString(16)}, expected 0x${expectedMachine.toString(16)} for ${target}`, -); - -const records = [ - await describe("Checkmate app", appPath), - await describe("NSIS installer", installerPath), -]; +assert(["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"].includes(target), "Unsupported Windows target"); +assert(outputDirectory, "Missing output directory"); +const sourceCommit = process.env.LAPKB_PACKAGE_SOURCE_SHA; +assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); +const version = JSON.parse(readFileSync("desktop/package.json", "utf8")).version; +assert.deepEqual(readdirSync(outputDirectory).sort(), ["checkmate.exe", "checkmate-nsis-installer.exe", "windows-package.json"].sort(), "Unrecorded/missing Windows candidate output"); +const proof = verifyPackageProof({ appId: "checkerboard", target, sourceCommit, version, outputDirectory, + installerName: "checkmate-nsis-installer.exe", appName: "checkmate.exe" }); const lines = [ - `Unsigned Windows build candidate (no trusted verifier configuration; not usable as licensed pilots): ${target}; app PE machine 0x${machine.toString(16)}`, - ...records.map(({ label, size, hash }) => `${label}: ${size} bytes; SHA-256 ${hash}`), + `Checkmate Windows package candidate: ${proof.target} ${version}, source ${sourceCommit}.`, + `Actual installer-contained ${proof.windowsPayload.executable}, version/product/PE architecture, exported hash equality, resource and DLL closure inspected in the existing packaging container.`, + `NSIS installer: ${proof.installer.size} bytes; SHA-256 ${proof.installer.sha256}.`, + "The build embeds the configured public staging verifier. OS Authenticode and release Minisign signatures are not asserted; native installation/unlock/runtime acceptance has not been executed.", ]; console.log(lines.join("\n")); -if (process.env.GITHUB_STEP_SUMMARY) { - appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `### Unsigned Windows build candidate: ${target}\n\n- **No trusted verifier configuration; not usable as licensed pilots.**\n- Not native Windows execution, installation, or release acceptance.\n- App PE machine: 0x${machine.toString(16)}\n${lines.slice(1).map((line) => `- ${line}`).join("\n")}\n\n`, - ); -} +if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### ${lines[0]}\n\n${lines.slice(1).map((line) => `- ${line}`).join("\n")}\n\n`); diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile index f1c2f39..dce091c 100644 --- a/scripts/ci/windows-cross.Dockerfile +++ b/scripts/ci/windows-cross.Dockerfile @@ -3,6 +3,9 @@ FROM messense/cargo-xwin@sha256:9856b895265d4966f212228ba64802cf89337e2a2a537aa2 FROM node:24-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe AS windows-builder ARG WINDOWS_TARGET +ARG PACKAGE_SOURCE_SHA +ARG PACKAGE_RUN_ID +ARG PACKAGE_RUN_ATTEMPT ARG CARGO_BUILD_JOBS=2 ARG LAPKB_LOCAL_SIGNING_KID ARG LAPKB_LOCAL_SIGNING_PUBLIC_KEY_B64 @@ -19,7 +22,7 @@ COPY --from=cargo-xwin /usr/local/rustup/ /tmp/checkmate-rustup-home/ RUN chmod 700 "$CARGO_HOME" "$RUSTUP_HOME" RUN apt-get update \ - && apt-get install --no-install-recommends -y build-essential cmake ca-certificates clang git llvm lld nsis openssh-client pkg-config \ + && apt-get install --no-install-recommends -y build-essential cmake ca-certificates clang git llvm lld nsis 7zip openssh-client pkg-config \ && rm -rf /var/lib/apt/lists/* \ && cargo xwin --version @@ -65,6 +68,9 @@ ENV CARGO_NET_OFFLINE=true \ # Public CRT/SDK downloads happen without an SSH mount, before offline compilation. RUN cargo xwin cache xwin +# Compile native Windows regression sources; this is NOT a Windows test pass. +RUN --network=none cargo xwin test --no-run --locked --offline --features local-staging \ + --manifest-path desktop/src-tauri/Cargo.toml --target "$WINDOWS_TARGET" RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --features local-staging --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline @@ -84,5 +90,12 @@ RUN set -eu; \ cp "$1" /out/checkmate-nsis-installer.exe; \ chmod 644 /out/checkmate.exe /out/checkmate-nsis-installer.exe +RUN node scripts/ci/windows-package.mjs checkerboard "$WINDOWS_TARGET" /out "$PACKAGE_SOURCE_SHA" "$PACKAGE_RUN_ID" "$PACKAGE_RUN_ATTEMPT" public-staging + +RUN --network=none GITHUB_RUN_ID="$PACKAGE_RUN_ID" GITHUB_RUN_ATTEMPT="$PACKAGE_RUN_ATTEMPT" \ + LAPKB_WINDOWS_PACKAGE_APP=checkerboard LAPKB_WINDOWS_PACKAGE_TARGET="$WINDOWS_TARGET" \ + LAPKB_WINDOWS_PACKAGE_OUTPUT=/out LAPKB_WINDOWS_PACKAGE_SOURCE="$PACKAGE_SOURCE_SHA" LAPKB_WINDOWS_PACKAGE_PROFILE=public-staging \ + node --test --test-name-pattern='actual generated NSIS' scripts/ci/windows-package.test.mjs + FROM scratch AS ci-artifacts COPY --from=windows-builder /out/ / diff --git a/scripts/ci/windows-package.mjs b/scripts/ci/windows-package.mjs new file mode 100644 index 0000000..f887439 --- /dev/null +++ b/scripts/ci/windows-package.mjs @@ -0,0 +1,280 @@ +// Static NSIS/PE package inspection in the existing Windows container lane. +// Neither app nor installer is executed. This is build/package evidence, not +// native runtime acceptance or a signature. A protected signer must verify the +// final installer bytes and bind this inventory in the existing signed receipt. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { constants, openSync, fstatSync, lstatSync, readSync, closeSync, readFileSync, + writeFileSync, readdirSync, mkdirSync, mkdtempSync, chmodSync, rmSync, realpathSync } from "node:fs"; +import { join, resolve, basename } from "node:path"; +import { pathToFileURL } from "node:url"; +export const SPEC = Object.freeze({ + launcher: { product: "LAPKB Launcher", executable: "lapkb-launcher.exe", exported: "lapkb-launcher.exe", installer: "launcher-nsis-installer.exe", prefix: "src-tauri", package: "package.json" }, + checkerboard: { product: "Checkmate", executable: "checkmate-desktop.exe", exported: "checkmate.exe", installer: "checkmate-nsis-installer.exe", prefix: "desktop/src-tauri", package: "desktop/package.json" }, + bdautodial: { product: "BDautodial", executable: "bdautodial.exe", exported: "bdautodial.exe", installer: "bdautodial-nsis-installer.exe", prefix: "src-tauri", package: "package.json" }, + bestdose: { product: "BestDose", executable: "bestdose.exe", exported: "bestdose.exe", installer: "bestdose-nsis-installer.exe", prefix: "src-tauri", package: "package.json" }, + papir: { product: "Papir", executable: "papir-v3.exe", exported: "papir-v3.exe", prefix: "src-tauri", package: "package.json" }, +}); +const MAX_FILE = 256 * 1024 * 1024; +const MAX_EXPANDED = 1024 * 1024 * 1024; +const SYSTEM_LIBRARIES = new Set(("kernel32.dll kernelbase.dll ntdll.dll user32.dll advapi32.dll ole32.dll oleaut32.dll shell32.dll shlwapi.dll gdi32.dll gdi32full.dll comdlg32.dll comctl32.dll version.dll winmm.dll ws2_32.dll secur32.dll security.dll crypt32.dll bcrypt.dll bcryptprimitives.dll ncrypt.dll uxtheme.dll dwmapi.dll d3d11.dll dxgi.dll d2d1.dll dwrite.dll imm32.dll winhttp.dll wininet.dll psapi.dll iphlpapi.dll wtsapi32.dll msimg32.dll rpcrt4.dll cfgmgr32.dll setupapi.dll powrprof.dll normaliz.dll propsys.dll mpr.dll msvcrt.dll ucrtbase.dll dbghelp.dll dbgcore.dll dnsapi.dll netapi32.dll userenv.dll windowscodecs.dll opengl32.dll hid.dll cabinet.dll urlmon.dll avrt.dll winspool.drv mswsock.dll dhcpcsvc.dll dcomp.dll shcore.dll gdiplus.dll wintrust.dll win32u.dll d3d12.dll d3dcompiler_47.dll uiautomationcore.dll oleacc.dll").split(" ")); +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const byteOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)); +function readRegular(path, maximum = MAX_FILE) { + const before = lstatSync(path, { bigint: true }); + assert(before.isFile() && !before.isSymbolicLink() && before.nlink === 1n && before.size >= 0 && before.size <= BigInt(maximum), "Unsafe/oversized package file"); + const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const opened = fstatSync(fd, { bigint: true }); + assert(opened.dev === before.dev && opened.ino === before.ino && opened.size === before.size && opened.nlink === 1n, "File substituted while opening"); + const bytes = Buffer.alloc(Number(opened.size)); + let offset = 0; + while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); assert(count > 0, "Truncated package file"); offset += count; } + const after = lstatSync(path, { bigint: true }); + assert(after.dev === before.dev && after.ino === before.ino && after.size === before.size && after.mode === before.mode && after.mtimeNs === before.mtimeNs && after.ctimeNs === before.ctimeNs, "Package changed while reading"); + return bytes; + } finally { closeSync(fd); } +} +export function safeArchivePath(path) { + assert(typeof path === "string" && path.length > 0 && Buffer.byteLength(path) <= 1024 && !/[\x00-\x1f\x7f\\:<>"|?*]/.test(path), "Unsafe NSIS entry path"); + for (const name of path.split("/")) { + assert(name && name !== "." && name !== ".." && !/[. ]$/.test(name), "Unsafe NSIS path component"); + assert(!/^(con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name), "Windows device entry"); + } + return path; +} +export function peInfo(bytes) { + const range = (offset, count) => { assert(Number.isSafeInteger(offset) && offset >= 0 && count >= 0 && offset + count <= bytes.length, "PE field out of bounds"); return offset; }; + const u16 = (offset) => bytes.readUInt16LE(range(offset, 2)); + const u32 = (offset) => bytes.readUInt32LE(range(offset, 4)); + assert(bytes.length >= 64 && u16(0) === 0x5a4d, "Missing DOS header"); + const pe = u32(60); assert(pe >= 64 && pe <= 1048576 && u32(pe) === 0x4550, "Missing PE header"); + const machine = u16(pe + 4); const sections = u16(pe + 6); const optSize = u16(pe + 20); const opt = pe + 24; + assert(sections > 0 && sections <= 96 && optSize >= 96, "Invalid PE optional header"); range(opt, optSize); + const magic = u16(opt); + assert([0x14c, 0x8664, 0xaa64].includes(machine), "Unsupported PE machine"); + assert.equal(magic, machine === 0x14c ? 0x10b : 0x20b, "PE machine/optional-header architecture mismatch"); + assert(u16(pe + 22) & 0x2, "PE is not an executable image"); + const directoryStart = opt + (magic === 0x20b ? 112 : 96); + const directoryCount = u32(directoryStart - 4); assert(directoryCount <= 32 && directoryStart + directoryCount * 8 <= opt + optSize, "Invalid PE data directories"); + const imageBase = magic === 0x20b ? bytes.readBigUInt64LE(range(opt + 24, 8)) : BigInt(u32(opt + 28)); + const mappings = []; + for (let index = 0; index < sections; index++) { const section = opt + optSize + index * 40; range(section, 40); mappings.push({ va: u32(section + 12), size: u32(section + 16), raw: u32(section + 20) }); } + const rva = (address, length = 1) => { + const matches = mappings.filter((item) => address >= item.va && address + length <= item.va + item.size); + assert.equal(matches.length, 1, "Ambiguous/out-of-bounds PE RVA"); + return range(matches[0].raw + address - matches[0].va, length); + }; + const directory = (index) => index < directoryCount ? [u32(directoryStart + index * 8), u32(directoryStart + index * 8 + 4)] : [0, 0]; + const ascii = (address) => { const start = rva(address); let end = start; while (end < bytes.length && bytes[end] && end - start < 256) end++; assert(end < bytes.length && bytes[end] === 0 && end > start, "Invalid PE import name"); const name = bytes.subarray(start, end).toString("ascii"); assert(/^[A-Za-z0-9_.-]+$/.test(name), "Unsafe import name"); return name.toLowerCase(); }; + const imports = new Set(); + for (const [index, stride] of [[1, 20], [13, 32]]) { + const [address, length] = directory(index); if (!address && !length) continue; + assert(address && length >= stride && length <= 1024 * 1024, "Invalid import directory"); + let ended = false; + for (let offset = 0; offset + stride <= length; offset += stride) { + const entry = rva(address + offset, stride); + if (bytes.subarray(entry, entry + stride).every((byte) => byte === 0)) { ended = true; break; } + let name = u32(entry + (index === 1 ? 12 : 4)); + if (index === 13 && !(u32(entry) & 1)) { const relative = BigInt(name) - imageBase; assert(relative >= 0n && relative <= 0xffffffffn, "Invalid delay import VA"); name = Number(relative); } + imports.add(ascii(name)); + assert(imports.size <= 512, "Too many PE imports"); + } + assert(ended, "Unterminated PE import directory"); + } + let version = null; const products = []; + const [resourceRva, resourceSize] = directory(2); + if (resourceRva) { + assert(resourceSize > 0 && resourceSize <= 16 * 1024 * 1024, "Oversized resource directory"); + const base = rva(resourceRva, resourceSize); + const nodes = new Set(); const versions = []; + const walk = (offset, level, versionResource) => { + assert(level <= 3 && offset + 16 <= resourceSize && !nodes.has(offset), "Malformed/cyclic PE resource tree"); nodes.add(offset); + const node = base + offset; const count = u16(node + 12) + u16(node + 14); assert(count <= 1024 && offset + 16 + count * 8 <= resourceSize, "Resource directory out of bounds"); + for (let i = 0; i < count; i++) { + const entry = node + 16 + i * 8; const name = u32(entry); const data = u32(entry + 4); + const isVersion = level === 0 ? name === 16 : versionResource; + if (data & 0x80000000) { walk(data & 0x7fffffff, level + 1, isVersion); } + else if (isVersion) { assert(data + 16 <= resourceSize, "Version leaf out of bounds"); const address = u32(base + data); const length = u32(base + data + 4); assert(length > 0 && length <= 1024 * 1024, "Oversized version resource"); versions.push(bytes.subarray(rva(address, length), rva(address, length) + length)); } + } + }; + walk(0, 0, false); + const align = (n) => (n + 3) & ~3; + for (const blob of versions) { + const block = (offset, limit, depth) => { + assert(depth <= 8 && offset + 6 <= limit, "Version block out of bounds"); + const length = blob.readUInt16LE(offset); const valueLength = blob.readUInt16LE(offset + 2); const type = blob.readUInt16LE(offset + 4); + const end = offset + length; assert(length >= 6 && end <= limit, "Invalid version block length"); + let cursor = offset + 6; const start = cursor; + while (cursor + 2 <= end && blob.readUInt16LE(cursor) !== 0) cursor += 2; + assert(cursor + 2 <= end, "Unterminated version key"); const key = blob.subarray(start, cursor).toString("utf16le"); + cursor = align(cursor + 2); const valueBytes = type === 1 ? valueLength * 2 : valueLength; + assert(cursor + valueBytes <= end, "Version value out of bounds"); + if (key === "VS_VERSION_INFO") { + assert.equal(valueBytes, 52, "Missing fixed product version"); assert.equal(blob.readUInt32LE(cursor), 0xfeef04bd, "Invalid fixed version magic"); + const ms = blob.readUInt32LE(cursor + 16); const ls = blob.readUInt32LE(cursor + 20); + // Installer support and DLLs can have genuine four-part versions. + // Only the app comparison below requires our canonical app version. + const current = `${ms >>> 16}.${ms & 65535}.${ls >>> 16}${ls & 65535 ? `.${ls & 65535}` : ""}`; + assert(version === null || version === current, "Conflicting version resources"); version = current; + } + if (key === "ProductName") { assert(type === 1 && valueLength > 0, "Missing product name"); const text = blob.subarray(cursor, cursor + valueBytes).toString("utf16le"); assert(text.endsWith("\0") && !text.slice(0, -1).includes("\0"), "Invalid product name"); products.push(text.slice(0, -1)); } + cursor = align(cursor + valueBytes); + while (cursor + 6 <= end) { const consumed = block(cursor, end, depth + 1); assert(consumed > cursor, "Nonadvancing version block"); cursor = align(consumed); } + return end; + }; + block(0, blob.length, 0); + } + } + return { machine, version, products, imports: [...imports].sort(), dll: Boolean(u16(pe + 22) & 0x2000) }; +} +function appDetails(appId, repositoryRoot) { + const spec = SPEC[appId]; assert(spec, "Unknown app"); + const pkg = JSON.parse(readFileSync(join(repositoryRoot, spec.package), "utf8")); + const config = JSON.parse(readFileSync(join(repositoryRoot, spec.prefix, "tauri.conf.json"), "utf8")); + const cargo = readFileSync(join(repositoryRoot, spec.prefix, "Cargo.toml"), "utf8").split(/^\[/m)[1]; + const cargoVersion = cargo?.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + assert.match(pkg.version ?? "", /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/); + assert.equal(cargoVersion, pkg.version, "Package/Cargo version mismatch"); + assert.equal(config.version ?? pkg.version, pkg.version, "Tauri version mismatch"); + assert.equal(config.productName, spec.product, "Product mismatch"); + assert.equal(config.bundle.windows?.nsis?.installMode, "currentUser", "Installer scope must be explicit"); + return { spec, config, version: pkg.version }; +} +function command(args) { + const result = spawnSync("/usr/bin/7zz", args, { encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); + assert(!result.error && result.status === 0, `NSIS inspection failed: ${result.error?.message ?? result.stderr}`); + return result.stdout; +} +export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory, runId, runAttempt, profile, repositoryRoot = process.cwd() }) { + assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); assert.match(runId ?? "", /^[1-9][0-9]{0,19}$/); + assert.match(runAttempt ?? "", /^[1-9][0-9]{0,3}$/); assert(Number(runAttempt) <= 1000); + assert(["public-staging", "unconfigured"].includes(profile), "Unknown build profile"); + const machine = new Map([["x86_64-pc-windows-msvc", 0x8664], ["aarch64-pc-windows-msvc", 0xaa64]]).get(target); assert(machine, "Unknown Windows target"); + const { spec, config, version } = appDetails(appId, repositoryRoot); + const output = resolve(outputDirectory); assert.equal(realpathSync(output), output, "Output path resolves through links"); + const names = readdirSync(output); const installerName = spec.installer ?? names.find((name) => name.endsWith(".exe") && name !== spec.exported); + assert(installerName && basename(installerName) === installerName, "Missing NSIS installer"); + const installerPath = join(output, installerName); const installerBytes = readRegular(installerPath); const stub = peInfo(installerBytes); + const appBytes = readRegular(join(output, spec.exported)); const app = peInfo(appBytes); + assert(!app.dll, "Application executable is a DLL"); + assert.equal(app.machine, machine); assert.equal(app.version, version); assert(app.products.length > 0 && app.products.every((name) => name === spec.product), "Wrong PE product"); + const listing = command(["l", "-slt", "--", installerPath]); assert(/^Type = Nsis$/m.test(listing), "Not an NSIS archive"); + const separator = listing.indexOf("----------"); assert(separator >= 0, "Missing NSIS entry listing"); + const entries = listing.slice(separator + 10).trim().split(/\r?\n\r?\n/).filter(Boolean).map((block) => { + const fields = Object.fromEntries(block.split(/\r?\n/).filter((line) => line.includes(" = ")).map((line) => { const at = line.indexOf(" = "); return [line.slice(0, at), line.slice(at + 3)]; })); + safeArchivePath(fields.Path); assert(!fields["Symbolic Link"] && !fields["Hard Link"], "Link inside NSIS archive"); + assert.match(fields.Size ?? "", /^[0-9]+$/); const size = Number(fields.Size); assert(Number.isSafeInteger(size) && size <= MAX_EXPANDED, "Oversized NSIS entry"); + return { path: fields.Path, size, directory: fields.Folder === "+" || fields.Attributes?.startsWith("D") }; + }); + assert(entries.length > 0 && entries.length <= 8192, "NSIS entry bound exceeded"); + const seen = new Set(); let total = 0; + for (const entry of entries) { const name = entry.path.toLowerCase(); assert(!seen.has(name), "Duplicate/case-colliding NSIS entry"); seen.add(name); total += entry.size; assert(total <= MAX_EXPANDED, "Expanded NSIS size exceeded"); } + const scratchRoot = resolve("/tmp/lapkb-windows-package"); + try { mkdirSync(scratchRoot, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } + const rootInfo = lstatSync(scratchRoot); assert(rootInfo.isDirectory() && !rootInfo.isSymbolicLink() && (rootInfo.mode & 0o777) === 0o700, "Unsafe inspection root"); + const scratch = mkdtempSync(join(scratchRoot, "nsis-")); chmodSync(scratch, 0o700); const scratchInfo = lstatSync(scratch); + try { + command(["x", "-y", `-o${scratch}`, "--", installerPath]); + const inventory = []; const support = []; const extracted = new Map(); const pending = [scratch]; + while (pending.length) { + const directory = pending.pop(); + for (const name of readdirSync(directory)) { + const path = join(directory, name); const info = lstatSync(path); assert(!info.isSymbolicLink() && (info.isDirectory() || info.nlink === 1), "Extracted reparse/link object"); + if (info.isDirectory()) { pending.push(path); continue; } + assert(info.isFile(), "Special extracted object"); + const relative = path.slice(scratch.length + 1).split(/[\\/]/).join("/"); safeArchivePath(relative); + const listEntry = entries.find((entry) => entry.path === relative); assert(listEntry && !listEntry.directory, "Unexpected extracted entry"); + const bytes = readRegular(path, MAX_EXPANDED); assert.equal(bytes.length, listEntry.size, "Extracted size mismatch"); + extracted.set(relative, bytes); + const record = { path: relative, size: bytes.length, sha256: sha256(bytes) }; + // NSIS plugins/bootstrap files are installer support, not x64 payload. + if (relative.startsWith("$PLUGINSDIR/") || relative === "[NSIS].nsi" || relative.replace(/^\$INSTDIR\//, "") === "uninstall.exe") support.push(record); + else { record.path = relative.replace(/^\$INSTDIR\//, ""); safeArchivePath(record.path); inventory.push(record); } + } + assert(inventory.length + support.length <= 8192, "Extracted file count exceeded"); + } + assert(entries.filter((entry) => !entry.directory).every((entry) => extracted.has(entry.path)), "Missing extracted NSIS entry"); + inventory.sort((a, b) => byteOrder(a.path, b.path)); support.sort((a, b) => byteOrder(a.path, b.path)); + assert(inventory.length > 0 && inventory.length <= 4096 && new Set(inventory.map((file) => file.path.toLowerCase())).size === inventory.length, "Payload identity is ambiguous"); + const contained = inventory.find((file) => file.path === spec.executable); assert(contained, "Real installed executable is absent"); + assert.equal(contained.sha256, sha256(appBytes), "Exported PE differs from installer-contained application"); + assert.equal(contained.size, appBytes.length); + const bytesFor = (file) => extracted.get(file.path) ?? extracted.get(`$INSTDIR/${file.path}`); + const bundledLibraries = new Map(); + for (const file of inventory.filter((file) => /\.(exe|dll)$/i.test(file.path))) { + const pe = peInfo(bytesFor(file)); assert.equal(pe.machine, machine, `Wrong architecture inside payload: ${file.path}`); + if (file.path.toLowerCase().endsWith(".dll")) { const name = basename(file.path).toLowerCase(); assert(!bundledLibraries.has(name), "Ambiguous bundled DLL name"); bundledLibraries.set(name, file.path); } + } + const importedSystem = new Set(); + for (const file of inventory.filter((file) => /\.(exe|dll)$/i.test(file.path))) { + for (const library of peInfo(bytesFor(file)).imports) { + if (SYSTEM_LIBRARIES.has(library) || /^(api|ext)-ms-win-[a-z0-9-]+\.dll$/.test(library)) importedSystem.add(library); + else { const bundled = bundledLibraries.get(library); assert(bundled && !bundled.includes("/"), `Missing/root-misplaced DLL dependency: ${file.path} -> ${library}`); } + } + } + const resources = config.bundle.resources ?? {}; + for (const destination of Array.isArray(resources) ? resources.map((path) => path.replace(/^\.\.\//, "")) : Object.values(resources)) { + safeArchivePath(destination); assert(inventory.some((file) => file.path === destination || file.path.startsWith(`${destination}/`)), `Required resource is absent: ${destination}`); + } + assert.equal(sha256(readRegular(installerPath)), sha256(installerBytes), "Installer changed during inspection"); + const proof = { + schema: "lapkb-windows-package-v1", app: appId, target: `windows-${machine === 0x8664 ? "x86_64" : "aarch64"}`, + version, sourceCommit, build: { runId, runAttempt: Number(runAttempt), profile }, + installer: { filename: installerName, size: installerBytes.length, sha256: sha256(installerBytes), stubMachine: stub.machine, kind: "nsis" }, + windowsPayload: { schema: "lapkb-windows-payload-v1", productName: spec.product, executable: spec.executable, + architecture: machine === 0x8664 ? "x86_64" : "aarch64", version, installMode: "currentUser", files: inventory }, + installerSupport: support, systemLibraries: [...importedSystem].sort(), + webView2: config.bundle.windows?.webviewInstallMode?.type ?? "downloadBootstrapper", + osSignature: "not Authenticode signed", updaterSignature: "not signed", nativeRuntime: "not executed", + }; + writeFileSync(join(output, "windows-package.json"), `${JSON.stringify(proof, null, 2)}\n`, { flag: "wx", mode: 0o600 }); + return proof; + } finally { + const after = lstatSync(scratch); if (after.dev === scratchInfo.dev && after.ino === scratchInfo.ino && after.isDirectory() && !after.isSymbolicLink()) rmSync(scratch, { recursive: true }); + } +} +export function verifyPackageProof({ appId, target, sourceCommit, outputDirectory, version, installerName, appName, profile = "public-staging" }) { + const spec = SPEC[appId]; assert(spec, "Unknown app"); + const proof = JSON.parse(readRegular(join(outputDirectory, "windows-package.json"), 2 * 1024 * 1024).toString("utf8")); + const architecture = target.startsWith("aarch64") || target === "windows-aarch64" ? "aarch64" : "x86_64"; + assert(["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc", "windows-x86_64", "windows-aarch64"].includes(target), "Unknown Windows target"); + assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); + assert.equal(proof.schema, "lapkb-windows-package-v1"); assert.equal(proof.app, appId); assert.equal(proof.target, `windows-${architecture}`); + assert.equal(proof.version, version); assert.equal(proof.sourceCommit, sourceCommit); + assert.match(proof.build.runId ?? "", /^[1-9][0-9]{0,19}$/); + assert(Number.isSafeInteger(proof.build.runAttempt) && proof.build.runAttempt >= 1 && proof.build.runAttempt <= 1000); + assert.equal(proof.build.runId, process.env.GITHUB_RUN_ID); assert.equal(proof.build.runAttempt, Number(process.env.GITHUB_RUN_ATTEMPT)); + assert.equal(proof.build.profile, profile); assert.equal(proof.installer.kind, "nsis"); + assert.equal(proof.installer.filename, installerName); + assert.equal(proof.windowsPayload.schema, "lapkb-windows-payload-v1"); + assert.equal(proof.windowsPayload.productName, spec.product); assert.equal(proof.windowsPayload.executable, spec.executable); + assert.equal(proof.windowsPayload.architecture, architecture); assert.equal(proof.windowsPayload.version, version); assert.equal(proof.windowsPayload.installMode, "currentUser"); + const installer = readRegular(join(outputDirectory, installerName)); + assert.equal(installer.length, proof.installer.size); + assert.equal(sha256(installer), proof.installer.sha256); + assert.equal(peInfo(installer).machine, proof.installer.stubMachine); + const files = proof.windowsPayload.files; + assert(Array.isArray(files) && files.length > 0 && files.length <= 4096, "Missing/bounded payload inventory"); + const names = new Set(); let previous = ""; let size = 0; + for (const file of files) { + safeArchivePath(file.path); assert(!names.has(file.path.toLowerCase()) && byteOrder(previous, file.path) < 0, "Unsorted/duplicate payload identity"); + names.add(file.path.toLowerCase()); previous = file.path; + assert(Number.isSafeInteger(file.size) && file.size >= 0 && file.size <= MAX_EXPANDED); assert.match(file.sha256 ?? "", /^[0-9a-f]{64}$/); + size += file.size; assert(size <= MAX_EXPANDED, "Payload inventory size exceeds bound"); + } + const app = readRegular(join(outputDirectory, appName)); const record = files.find((file) => file.path === spec.executable); + assert(record && record.size === app.length && record.sha256 === sha256(app), "Contained payload digest differs from exported PE"); + const info = peInfo(app); assert(!info.dll, "Application executable is a DLL"); + assert.equal(info.machine, architecture === "x86_64" ? 0x8664 : 0xaa64); assert.equal(info.version, version); + assert(info.products.length && info.products.every((name) => name === spec.product)); + return proof; +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const [appId, target, outputDirectory, sourceCommit, runId, runAttempt, profile] = process.argv.slice(2); + const proof = inspectWindowsPackage({ appId, target, outputDirectory, sourceCommit, runId, runAttempt, profile }); + console.log(`Inspected actual NSIS-contained ${proof.windowsPayload.executable} ${proof.version} ${proof.target}: ${proof.windowsPayload.files.length} payload files. No Windows execution or signatures claimed.`); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs new file mode 100644 index 0000000..1c4b8ce --- /dev/null +++ b/scripts/ci/windows-package.test.mjs @@ -0,0 +1,131 @@ +// Structural fixtures are not release packages or signatures. The packaging +// container also supplies its actual generated NSIS for the positive round trip. +// Neither kind of check establishes native Windows runtime acceptance. CI only. +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, rmSync, linkSync, symlinkSync, lstatSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { SPEC, inspectWindowsPackage, peInfo, safeArchivePath, verifyPackageProof } from "./windows-package.mjs"; + +test("actual generated NSIS has a reproducible payload and source/run provenance", { + skip: !process.env.LAPKB_WINDOWS_PACKAGE_OUTPUT, +}, () => { + const outputDirectory = process.env.LAPKB_WINDOWS_PACKAGE_OUTPUT; + const appId = process.env.LAPKB_WINDOWS_PACKAGE_APP; + const target = process.env.LAPKB_WINDOWS_PACKAGE_TARGET; + const sourceCommit = process.env.LAPKB_WINDOWS_PACKAGE_SOURCE; + const profile = process.env.LAPKB_WINDOWS_PACKAGE_PROFILE; + const spec = SPEC[appId]; assert(spec); + const proof = JSON.parse(readFileSync(join(outputDirectory, "windows-package.json"), "utf8")); + const args = { appId, target, sourceCommit, outputDirectory, profile, version: proof.version, + installerName: proof.installer.filename, appName: spec.exported }; + assert.equal(proof.build.runId, process.env.GITHUB_RUN_ID); + assert.equal(proof.build.runAttempt, Number(process.env.GITHUB_RUN_ATTEMPT)); + assert.equal(proof.build.profile, profile); + if (appId !== "launcher") { + const config = JSON.parse(readFileSync(join(spec.prefix, "tauri.conf.json"), "utf8")); + assert.equal(config.bundle.windows.nsis.installerHooks, "windows-install-hooks.nsh"); + const hooks = readFileSync(join(spec.prefix, "windows-install-hooks.nsh"), "utf8"); + assert(hooks.includes("!macro NSIS_HOOK_PREINSTALL")); + assert(hooks.includes('$INSTDIR != "$LOCALAPPDATA\\${PRODUCTNAME}"')); + assert(hooks.includes(" Abort")); + } + assert.deepEqual(verifyPackageProof(args), proof); + const directory = mkdtempSync(join(tmpdir(), "windows-package-actual-")); + const owned = lstatSync(directory); + try { + copyFileSync(join(outputDirectory, args.installerName), join(directory, args.installerName)); + copyFileSync(join(outputDirectory, args.appName), join(directory, args.appName)); + // Re-extract the actual package. Equality covers every resource, bundled + // DLL, support entry and imported system library, not just the exported PE. + const inspected = inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory: directory, + runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, profile }); + assert.deepEqual(inspected, proof); + assert.deepEqual(verifyPackageProof({ ...args, outputDirectory: directory }), proof); + assert.throws(() => verifyPackageProof({ ...args, outputDirectory: directory, sourceCommit: "0".repeat(40) })); + // These are unsigned packaging bytes; no release signature/native pass is invented. + assert.equal(proof.nativeRuntime, "not executed"); + assert.equal(proof.updaterSignature, "not signed"); + } finally { + const after = lstatSync(directory); + assert(after.isDirectory() && !after.isSymbolicLink() && after.dev === owned.dev && after.ino === owned.ino); + rmSync(directory, { recursive: true }); + } +}); + +function pe(machine) { + const bytes = Buffer.alloc(512); + bytes.writeUInt16LE(0x5a4d, 0); bytes.writeUInt32LE(64, 60); bytes.writeUInt32LE(0x4550, 64); + const optionalSize = machine === 0x14c ? 112 : 128; + bytes.writeUInt16LE(machine, 68); bytes.writeUInt16LE(1, 70); bytes.writeUInt16LE(optionalSize, 84); + bytes.writeUInt16LE(0x2, 86); + bytes.writeUInt16LE(machine === 0x14c ? 0x10b : 0x20b, 88); // no resources/import directories: cannot qualify as an app + const section = 88 + optionalSize; + bytes.writeUInt32LE(0x1000, section + 12); bytes.writeUInt32LE(256, section + 16); bytes.writeUInt32LE(256, section + 20); + return bytes; +} +test("NSIS x86 stub is not x64 contained-app identity/version evidence", () => { + const stub = peInfo(pe(0x14c)); assert.equal(stub.machine, 0x14c); assert.equal(stub.version, null); assert.deepEqual(stub.products, []); + assert.equal(peInfo(pe(0x8664)).machine, 0x8664); + const mislabeled = pe(0x14c); mislabeled.writeUInt16LE(0x8664, 68); + assert.throws(() => peInfo(mislabeled), /architecture mismatch/); + for (const bytes of [Buffer.from("MZ"), Buffer.alloc(64), pe(0x8664).subarray(0, 70)]) assert.throws(() => peInfo(bytes)); +}); +test("NSIS inventory rejects traversal, drive/ADS/device and reparse-style paths", () => { + for (const name of ["../app.exe", "/app.exe", "C:/app.exe", "a\\app.exe", "app.exe:stream", "CON", "LPT1.txt", "folder/../app", "name.", "a//b"]) assert.throws(() => safeArchivePath(name), name); + assert.equal(safeArchivePath("data/model.txt"), "data/model.txt"); +}); +test("missing package proof is not a nonempty-installer pass", () => { + const directory = mkdtempSync(join(tmpdir(), "windows-package-test-")); + try { + writeFileSync(join(directory, "launcher-nsis-installer.exe"), pe(0x14c)); + assert.throws(() => verifyPackageProof({ appId: "launcher", target: "x86_64-pc-windows-msvc", sourceCommit: "a".repeat(40), outputDirectory: directory, version: "0.1.9", installerName: "launcher-nsis-installer.exe", appName: "lapkb-launcher.exe" }), /ENOENT/); + } finally { rmSync(directory, { recursive: true }); } +}); +test("proof selection rejects wrong app, source, target, version, scope, digest and missing payload", () => { + const directory = mkdtempSync(join(tmpdir(), "windows-package-test-")); + const installer = pe(0x14c); const app = pe(0x8664); + const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); + const args = { appId: "launcher", target: "x86_64-pc-windows-msvc", sourceCommit: "a".repeat(40), + outputDirectory: directory, version: "0.1.9", installerName: "launcher-nsis-installer.exe", appName: "lapkb-launcher.exe" }; + // Shape fixtures have NO version resource, actual archive or signature and + // intentionally cannot qualify even when their structural fields match. + const shape = { schema: "lapkb-windows-package-v1", app: "launcher", target: "windows-x86_64", + version: "0.1.9", sourceCommit: "a".repeat(40), + build: { runId: process.env.GITHUB_RUN_ID ?? "123", runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? "1"), profile: "public-staging" }, + installer: { kind: "nsis", filename: args.installerName, size: installer.length, sha256: digest(installer), stubMachine: 0x14c }, + windowsPayload: { schema: "lapkb-windows-payload-v1", productName: "LAPKB Launcher", executable: "lapkb-launcher.exe", + architecture: "x86_64", version: "0.1.9", installMode: "currentUser", + files: [{ path: "lapkb-launcher.exe", size: app.length, sha256: digest(app) }] } }; + try { + writeFileSync(join(directory, args.installerName), installer); writeFileSync(join(directory, args.appName), app); + for (const change of [ + (p) => { p.app = "papir"; }, (p) => { p.sourceCommit = "b".repeat(40); }, + (p) => { p.target = "windows-aarch64"; }, (p) => { p.version = "0.1.8"; }, + (p) => { p.build.profile = "unconfigured"; }, (p) => { p.build.runAttempt = 0; }, + (p) => { p.installer.sha256 = "b".repeat(64); }, (p) => { p.windowsPayload.installMode = "perMachine"; }, + (p) => { p.windowsPayload.files = []; }, (p) => { p.windowsPayload.files[0].sha256 = "b".repeat(64); }, + ]) { + const proof = structuredClone(shape); change(proof); + writeFileSync(join(directory, "windows-package.json"), JSON.stringify(proof)); + assert.throws(() => verifyPackageProof(args)); + } + writeFileSync(join(directory, "windows-package.json"), JSON.stringify(shape)); + assert.throws(() => verifyPackageProof({ ...args, target: "darwin-aarch64" }), /Unknown Windows target/); + assert.throws(() => verifyPackageProof(args)); // absent authenticated payload/version evidence + } finally { rmSync(directory, { recursive: true }); } +}); + +test("substituted/hardlinked proof is rejected before consuming identity", () => { + const directory = mkdtempSync(join(tmpdir(), "windows-package-test-")); + const args = { appId: "launcher", target: "x86_64-pc-windows-msvc", sourceCommit: "a".repeat(40), outputDirectory: directory, version: "0.1.9", installerName: "launcher-nsis-installer.exe", appName: "lapkb-launcher.exe" }; + try { + writeFileSync(join(directory, "other.json"), "{}"); linkSync(join(directory, "other.json"), join(directory, "windows-package.json")); + assert.throws(() => verifyPackageProof(args), /Unsafe/); + rmSync(join(directory, "windows-package.json")); + symlinkSync(join(directory, "other.json"), join(directory, "windows-package.json")); + assert.throws(() => verifyPackageProof(args), /Unsafe/); + } finally { rmSync(directory, { recursive: true }); } +}); From 561fd29ef2ae5841901b281bbc5fd6d50adadbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 08:18:01 +0100 Subject: [PATCH 21/27] fix: use Debian 7zip NSIS reader and preflight Windows toolchain for Checkmate --- scripts/ci/windows-cross.Dockerfile | 5 +++++ scripts/ci/windows-package.mjs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile index dce091c..36bed98 100644 --- a/scripts/ci/windows-cross.Dockerfile +++ b/scripts/ci/windows-cross.Dockerfile @@ -21,8 +21,13 @@ COPY --from=cargo-xwin /usr/local/cargo/ /tmp/checkmate-cargo-home/ COPY --from=cargo-xwin /usr/local/rustup/ /tmp/checkmate-rustup-home/ RUN chmod 700 "$CARGO_HOME" "$RUSTUP_HOME" +# Debian 7zip supplies /usr/bin/7z; require its NSIS reader before compilation. RUN apt-get update \ && apt-get install --no-install-recommends -y build-essential cmake ca-certificates clang git llvm lld nsis 7zip openssh-client pkg-config \ + && test -x /usr/bin/7z \ + && LC_ALL=C /usr/bin/7z i > /tmp/lapkb-7zip-formats \ + && /usr/bin/grep -Eq '(^|[[:space:]])Nsis([[:space:]]|$)' /tmp/lapkb-7zip-formats \ + && rm -f /tmp/lapkb-7zip-formats \ && rm -rf /var/lib/apt/lists/* \ && cargo xwin --version diff --git a/scripts/ci/windows-package.mjs b/scripts/ci/windows-package.mjs index f887439..2582da9 100644 --- a/scripts/ci/windows-package.mjs +++ b/scripts/ci/windows-package.mjs @@ -143,7 +143,7 @@ function appDetails(appId, repositoryRoot) { return { spec, config, version: pkg.version }; } function command(args) { - const result = spawnSync("/usr/bin/7zz", args, { encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); + const result = spawnSync("/usr/bin/7z", args, { encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); assert(!result.error && result.status === 0, `NSIS inspection failed: ${result.error?.message ?? result.stderr}`); return result.stdout; } From ec71c6f5832de558af5be99c856cfcf351c65c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 13:38:51 +0100 Subject: [PATCH 22/27] fix: measure solid NSIS payload sizes and select Windows x64 CI [skip ci] --- .github/workflows/ci.yml | 25 +++++++------- scripts/ci/windows-cross.Dockerfile | 3 ++ scripts/ci/windows-package.mjs | 47 +++++++++++++++++++------- scripts/ci/windows-package.test.mjs | 51 ++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9faf8f..50a85ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: description: Full commit SHA reachable from launcher-checkmate-support required: true type: string + windows_x64_only: + description: Build only the Windows x64 candidate + type: boolean + default: false permissions: contents: read @@ -30,6 +34,7 @@ jobs: actions: write name: macOS ARM64, then Intel build candidates if: >- + (!inputs.windows_x64_only) && github.repository == 'LAPKB/Checkerboard' && github.ref == 'refs/heads/launcher-checkmate-support' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') @@ -279,19 +284,13 @@ jobs: fail-fast: false max-parallel: 2 matrix: - include: - - target: x86_64-pc-windows-msvc - runner_arch: X64 - runner_group: Default - - target: aarch64-pc-windows-msvc - runner_arch: X64 - runner_group: Default - - target: x86_64-unknown-linux-gnu - runner_arch: X64 - runner_group: Default - - target: aarch64-unknown-linux-gnu - runner_arch: ARM64 - runner_group: rust + include: >- + ${{ fromJSON(inputs.windows_x64_only && + '[{"target":"x86_64-pc-windows-msvc","runner_arch":"X64","runner_group":"Default"}]' || + '[{"target":"x86_64-pc-windows-msvc","runner_arch":"X64","runner_group":"Default"}, + {"target":"aarch64-pc-windows-msvc","runner_arch":"X64","runner_group":"Default"}, + {"target":"x86_64-unknown-linux-gnu","runner_arch":"X64","runner_group":"Default"}, + {"target":"aarch64-unknown-linux-gnu","runner_arch":"ARM64","runner_group":"rust"}]') }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile index 36bed98..cbe1f51 100644 --- a/scripts/ci/windows-cross.Dockerfile +++ b/scripts/ci/windows-cross.Dockerfile @@ -42,6 +42,9 @@ RUN case "$WINDOWS_TARGET" in \ WORKDIR /workspace COPY . . +# Check real solid-archive listing semantics before compiling any application. +RUN --network=none LAPKB_NSIS_LISTING_FIXTURE=1 node --test scripts/ci/windows-package.test.mjs + RUN node scripts/ci/validate-pilot-inputs.mjs \ && cd desktop && npm ci && npm exec -- tsc && npm exec -- vite build diff --git a/scripts/ci/windows-package.mjs b/scripts/ci/windows-package.mjs index 2582da9..b3db2ea 100644 --- a/scripts/ci/windows-package.mjs +++ b/scripts/ci/windows-package.mjs @@ -147,6 +147,34 @@ function command(args) { assert(!result.error && result.status === 0, `NSIS inspection failed: ${result.error?.message ?? result.stderr}`); return result.stdout; } +export function parseNsisListing(listing) { + const separator = listing.indexOf("----------"); assert(separator >= 0, "Missing NSIS entry listing"); + const header = listing.slice(0, separator); + assert(/^Type = Nsis$/m.test(header), "Not an NSIS archive"); + const solid = /^Solid = \+$/m.test(header); + const numeric = (value, maximum, field, path) => { + assert(typeof value === "string" && /^[0-9]+$/.test(value), `Invalid NSIS ${field}: ${path}`); + const size = Number(value); + assert(Number.isSafeInteger(size) && size <= maximum, `Oversized NSIS ${field}: ${path}`); + return size; + }; + return listing.slice(separator + 10).trim().split(/\r?\n\r?\n/).filter(Boolean).map((block) => { + const fields = Object.create(null); + for (const line of block.split(/\r?\n/).filter((line) => line.includes(" = "))) { + const at = line.indexOf(" = "); const key = line.slice(0, at); + assert(!Object.hasOwn(fields, key), "Duplicate NSIS listing field"); fields[key] = line.slice(at + 3); + } + safeArchivePath(fields.Path); assert(!fields["Symbolic Link"] && !fields["Hard Link"], "Link inside NSIS archive"); + const directory = fields.Folder === "+" || Boolean(fields.Attributes?.startsWith("D")); + // 7zip estimates solid item sizes from the next item offset. The final + // item can explicitly have no estimate; its extracted size is mandatory. + const unknownSize = fields.Size === "" && solid && fields.Solid === "+" && !directory; + const size = unknownSize ? null : numeric(fields.Size, MAX_EXPANDED, "Size", fields.Path); + // Packed Size is optional for shared solid blocks, never a logical size. + if (fields["Packed Size"] !== undefined && fields["Packed Size"] !== "") numeric(fields["Packed Size"], MAX_FILE, "Packed Size", fields.Path); + return { path: fields.Path, size, directory }; + }); +} export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory, runId, runAttempt, profile, repositoryRoot = process.cwd() }) { assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); assert.match(runId ?? "", /^[1-9][0-9]{0,19}$/); assert.match(runAttempt ?? "", /^[1-9][0-9]{0,3}$/); assert(Number(runAttempt) <= 1000); @@ -160,24 +188,17 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec const appBytes = readRegular(join(output, spec.exported)); const app = peInfo(appBytes); assert(!app.dll, "Application executable is a DLL"); assert.equal(app.machine, machine); assert.equal(app.version, version); assert(app.products.length > 0 && app.products.every((name) => name === spec.product), "Wrong PE product"); - const listing = command(["l", "-slt", "--", installerPath]); assert(/^Type = Nsis$/m.test(listing), "Not an NSIS archive"); - const separator = listing.indexOf("----------"); assert(separator >= 0, "Missing NSIS entry listing"); - const entries = listing.slice(separator + 10).trim().split(/\r?\n\r?\n/).filter(Boolean).map((block) => { - const fields = Object.fromEntries(block.split(/\r?\n/).filter((line) => line.includes(" = ")).map((line) => { const at = line.indexOf(" = "); return [line.slice(0, at), line.slice(at + 3)]; })); - safeArchivePath(fields.Path); assert(!fields["Symbolic Link"] && !fields["Hard Link"], "Link inside NSIS archive"); - assert.match(fields.Size ?? "", /^[0-9]+$/); const size = Number(fields.Size); assert(Number.isSafeInteger(size) && size <= MAX_EXPANDED, "Oversized NSIS entry"); - return { path: fields.Path, size, directory: fields.Folder === "+" || fields.Attributes?.startsWith("D") }; - }); + const entries = parseNsisListing(command(["l", "-slt", "--", installerPath])); assert(entries.length > 0 && entries.length <= 8192, "NSIS entry bound exceeded"); const seen = new Set(); let total = 0; - for (const entry of entries) { const name = entry.path.toLowerCase(); assert(!seen.has(name), "Duplicate/case-colliding NSIS entry"); seen.add(name); total += entry.size; assert(total <= MAX_EXPANDED, "Expanded NSIS size exceeded"); } + for (const entry of entries) { const name = entry.path.toLowerCase(); assert(!seen.has(name), "Duplicate/case-colliding NSIS entry"); seen.add(name); if (entry.size !== null) total += entry.size; assert(total <= MAX_EXPANDED, "Expanded NSIS size exceeded"); } const scratchRoot = resolve("/tmp/lapkb-windows-package"); try { mkdirSync(scratchRoot, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } const rootInfo = lstatSync(scratchRoot); assert(rootInfo.isDirectory() && !rootInfo.isSymbolicLink() && (rootInfo.mode & 0o777) === 0o700, "Unsafe inspection root"); const scratch = mkdtempSync(join(scratchRoot, "nsis-")); chmodSync(scratch, 0o700); const scratchInfo = lstatSync(scratch); try { command(["x", "-y", `-o${scratch}`, "--", installerPath]); - const inventory = []; const support = []; const extracted = new Map(); const pending = [scratch]; + const inventory = []; const support = []; const extracted = new Map(); const pending = [scratch]; let extractedTotal = 0; while (pending.length) { const directory = pending.pop(); for (const name of readdirSync(directory)) { @@ -186,7 +207,11 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec assert(info.isFile(), "Special extracted object"); const relative = path.slice(scratch.length + 1).split(/[\\/]/).join("/"); safeArchivePath(relative); const listEntry = entries.find((entry) => entry.path === relative); assert(listEntry && !listEntry.directory, "Unexpected extracted entry"); - const bytes = readRegular(path, MAX_EXPANDED); assert.equal(bytes.length, listEntry.size, "Extracted size mismatch"); + extractedTotal += info.size; assert(Number.isSafeInteger(extractedTotal) && extractedTotal <= MAX_EXPANDED, "Extracted NSIS size exceeded"); + const bytes = readRegular(path, MAX_EXPANDED); + // 7zip reconstructs the uninstaller from the stub and its patch; its + // listed encoded length is not the reconstructed executable length. + if (listEntry.size !== null && relative.replace(/^\$INSTDIR\//, "") !== "uninstall.exe") assert.equal(bytes.length, listEntry.size, `Extracted size mismatch: ${relative}`); extracted.set(relative, bytes); const record = { path: relative, size: bytes.length, sha256: sha256(bytes) }; // NSIS plugins/bootstrap files are installer support, not x64 payload. diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs index 1c4b8ce..397ec13 100644 --- a/scripts/ci/windows-package.test.mjs +++ b/scripts/ci/windows-package.test.mjs @@ -7,7 +7,56 @@ import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, rmSync, linkSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; -import { SPEC, inspectWindowsPackage, peInfo, safeArchivePath, verifyPackageProof } from "./windows-package.mjs"; +import { spawnSync } from "node:child_process"; +import { SPEC, inspectWindowsPackage, parseNsisListing, peInfo, safeArchivePath, verifyPackageProof } from "./windows-package.mjs"; + +test("solid NSIS listing keeps optional Packed Size separate from logical Size", () => { + const listing = (size, packed = "", solid = "+") => `Type = Nsis\nSolid = ${solid}\n\n----------\nPath = app.exe\n${size === undefined ? "" : `Size = ${size}\n`}Packed Size = ${packed}\nAttributes = A\nSolid = ${solid}\n`; + assert.equal(parseNsisListing(listing("37"))[0].size, 37); + assert.equal(parseNsisListing(listing("0"))[0].size, 0); + assert.equal(parseNsisListing(listing(""))[0].size, null); + assert.throws(() => parseNsisListing(listing(undefined)), /Invalid NSIS Size/); + assert.throws(() => parseNsisListing(listing("", "", "-")), /Invalid NSIS Size/); + for (const size of ["-1", "1.5", "1e3", "NaN", " ", "1073741825", "9007199254740993"]) assert.throws(() => parseNsisListing(listing(size)), /NSIS Size/); + assert.throws(() => parseNsisListing(listing("37", "not-a-size")), /NSIS Packed Size/); +}); + +test("generated solid NSIS exposes an unknown tail size and reconstructs its uninstaller", { + skip: process.env.LAPKB_NSIS_LISTING_FIXTURE !== "1", +}, () => { + const directory = mkdtempSync(join(tmpdir(), "windows-package-listing-")); + const run = (binary, args) => { + const result = spawnSync(binary, args, { cwd: directory, encoding: "utf8", timeout: 120000, + maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); + assert(!result.error && result.status === 0, result.error?.message ?? result.stderr); + return result.stdout; + }; + try { + const files = { "first.bin": Buffer.from("first payload\n"), "second.bin": Buffer.alloc(73, 42), "last.bin": Buffer.from("last payload\n") }; + for (const [name, bytes] of Object.entries(files)) writeFileSync(join(directory, name), bytes); + for (const tail of ["uninstaller", "payload"]) { + writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\nWriteUninstaller "$INSTDIR\\uninstall.exe"\n${tail === "payload" ? 'File "last.bin"\n' : ""}SectionEnd\nSection "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n`); + run("/usr/bin/makensis", ["fixture.nsi"]); + const listing = run("/usr/bin/7z", ["l", "-slt", "--", `${tail}.exe`]); + console.log(`Generated ${tail}-tail NSIS listing:\n${listing}`); + const entries = parseNsisListing(listing); + const last = entries.find((entry) => entry.path === (tail === "payload" ? "last.bin" : "uninstall.exe")); + assert(last && last.size === null, "The fixture must exercise 7zip's explicitly unknown final Size"); + run("/usr/bin/7z", ["x", "-y", `-o${tail}-extracted`, "--", `${tail}.exe`]); + for (const entry of entries) { + const bytes = readFileSync(join(directory, `${tail}-extracted`, entry.path)); + assert(bytes.length <= 1024 * 1024); + if (entry.path === "uninstall.exe") { + assert.equal(peInfo(bytes).machine, 0x14c); + if (entry.size !== null) assert.notEqual(bytes.length, entry.size, "Rebuilt uninstaller is not the encoded patch length"); + } else { + assert.deepEqual(bytes, files[entry.path]); + if (entry.size !== null) assert.equal(bytes.length, entry.size); + } + } + } + } finally { rmSync(directory, { recursive: true }); } +}); test("actual generated NSIS has a reproducible payload and source/run provenance", { skip: !process.env.LAPKB_WINDOWS_PACKAGE_OUTPUT, From 6cf98c3148458a7b85ac2c1fda048e821ffc8106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 15:26:32 +0100 Subject: [PATCH 23/27] fix: verify NSIS fixture payloads with known and unknown sizes [skip ci] --- scripts/ci/windows-package.test.mjs | 55 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs index 397ec13..0a814ad 100644 --- a/scripts/ci/windows-package.test.mjs +++ b/scripts/ci/windows-package.test.mjs @@ -21,7 +21,7 @@ test("solid NSIS listing keeps optional Packed Size separate from logical Size", assert.throws(() => parseNsisListing(listing("37", "not-a-size")), /NSIS Packed Size/); }); -test("generated solid NSIS exposes an unknown tail size and reconstructs its uninstaller", { +test("generated solid NSIS preserves known and unknown-size payloads and reconstructs its uninstaller", { skip: process.env.LAPKB_NSIS_LISTING_FIXTURE !== "1", }, () => { const directory = mkdtempSync(join(tmpdir(), "windows-package-listing-")); @@ -35,24 +35,47 @@ test("generated solid NSIS exposes an unknown tail size and reconstructs its uni const files = { "first.bin": Buffer.from("first payload\n"), "second.bin": Buffer.alloc(73, 42), "last.bin": Buffer.from("last payload\n") }; for (const [name, bytes] of Object.entries(files)) writeFileSync(join(directory, name), bytes); for (const tail of ["uninstaller", "payload"]) { - writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\nWriteUninstaller "$INSTDIR\\uninstall.exe"\n${tail === "payload" ? 'File "last.bin"\n' : ""}SectionEnd\nSection "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n`); + // Keep the payload-tail archive free of an uninstaller patch: 7zip can + // reorder that patch after the payload regardless of NSIS command order. + writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\n${tail === "payload" ? 'File "last.bin"\n' : 'WriteUninstaller "$INSTDIR\\uninstall.exe"\n'}SectionEnd\n${tail === "uninstaller" ? 'Section "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n' : ""}`); run("/usr/bin/makensis", ["fixture.nsi"]); const listing = run("/usr/bin/7z", ["l", "-slt", "--", `${tail}.exe`]); - console.log(`Generated ${tail}-tail NSIS listing:\n${listing}`); - const entries = parseNsisListing(listing); - const last = entries.find((entry) => entry.path === (tail === "payload" ? "last.bin" : "uninstall.exe")); - assert(last && last.size === null, "The fixture must exercise 7zip's explicitly unknown final Size"); - run("/usr/bin/7z", ["x", "-y", `-o${tail}-extracted`, "--", `${tail}.exe`]); - for (const entry of entries) { - const bytes = readFileSync(join(directory, `${tail}-extracted`, entry.path)); - assert(bytes.length <= 1024 * 1024); - if (entry.path === "uninstall.exe") { - assert.equal(peInfo(bytes).machine, 0x14c); - if (entry.size !== null) assert.notEqual(bytes.length, entry.size, "Rebuilt uninstaller is not the encoded patch length"); - } else { - assert.deepEqual(bytes, files[entry.path]); - if (entry.size !== null) assert.equal(bytes.length, entry.size); + const fixtures = [{ name: "generated", listing }]; + if (tail === "payload") { + // Exercise both permitted Size forms independently of this 7zip build, + // changing only the final payload's field in its real solid listing. + for (const size of [files["last.bin"].length, null]) fixtures.push({ + name: size === null ? "explicitly unknown Size" : "known Size", size, + listing: listing.replace(/^(Path = last\.bin\r?\n)Size = [^\r\n]*/m, `$1Size = ${size ?? ""}`), + }); + } + let fixture = fixtures[0]; + try { + run("/usr/bin/7z", ["x", "-y", `-o${tail}-extracted`, "--", `${tail}.exe`]); + for (fixture of fixtures) { + const entries = parseNsisListing(fixture.listing); + const lastPath = tail === "payload" ? "last.bin" : "uninstall.exe"; + assert.deepEqual(entries.map((entry) => entry.path).sort(), ["first.bin", "second.bin", lastPath].sort()); + const last = entries.find((entry) => entry.path === lastPath); + if (fixture.size !== undefined) assert.equal(last.size, fixture.size); + for (const entry of entries) { + assert(!entry.directory); + const bytes = readFileSync(join(directory, `${tail}-extracted`, entry.path)); + assert(bytes.length <= 1024 * 1024); + if (entry.path === "uninstall.exe") { + assert.equal(peInfo(bytes).machine, 0x14c); + if (entry.size !== null) assert.notEqual(bytes.length, entry.size, "Rebuilt uninstaller is not the encoded patch length"); + } else { + assert.deepEqual(bytes, files[entry.path]); + assert.equal(bytes.length, files[entry.path].length); + assert.equal(createHash("sha256").update(bytes).digest("hex"), createHash("sha256").update(files[entry.path]).digest("hex")); + if (entry.size !== null) assert.equal(bytes.length, entry.size); + } + } } + } catch (error) { + console.error(`Generated ${tail}-tail NSIS listing (${fixture.name}):\n${fixture.listing}`); + throw error; } } } finally { rmSync(directory, { recursive: true }); } From 0b7fd2c4244f30a0a169b5927f748a41243cc2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 16:25:35 +0100 Subject: [PATCH 24/27] fix: distinguish NSIS solid size estimates from extracted lengths [skip ci] --- scripts/ci/windows-package.mjs | 27 +++++++++----- scripts/ci/windows-package.test.mjs | 58 +++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/scripts/ci/windows-package.mjs b/scripts/ci/windows-package.mjs index b3db2ea..803e69c 100644 --- a/scripts/ci/windows-package.mjs +++ b/scripts/ci/windows-package.mjs @@ -166,15 +166,27 @@ export function parseNsisListing(listing) { } safeArchivePath(fields.Path); assert(!fields["Symbolic Link"] && !fields["Hard Link"], "Link inside NSIS archive"); const directory = fields.Folder === "+" || Boolean(fields.Attributes?.startsWith("D")); - // 7zip estimates solid item sizes from the next item offset. The final - // item can explicitly have no estimate; its extracted size is mandatory. - const unknownSize = fields.Size === "" && solid && fields.Solid === "+" && !directory; - const size = unknownSize ? null : numeric(fields.Size, MAX_EXPANDED, "Size", fields.Path); + // 7zip estimates solid item sizes from the next item offset. Preserve + // that distinction even for positive estimates; the final can be blank. + const sizeIsEstimate = solid && fields.Solid === "+" && !directory; + const size = fields.Size === "" && sizeIsEstimate ? null : numeric(fields.Size, MAX_EXPANDED, "Size", fields.Path); // Packed Size is optional for shared solid blocks, never a logical size. if (fields["Packed Size"] !== undefined && fields["Packed Size"] !== "") numeric(fields["Packed Size"], MAX_FILE, "Packed Size", fields.Path); - return { path: fields.Path, size, directory }; + return { path: fields.Path, size, sizeIsEstimate, directory }; }); } +export function validateExtractedSizes(entries, extractedSizes) { + let total = 0; + for (const entry of entries.filter((entry) => !entry.directory)) { + assert(extractedSizes.has(entry.path), "Missing extracted NSIS entry"); + const size = extractedSizes.get(entry.path); + assert(Number.isSafeInteger(size) && size >= 0 && size <= MAX_EXPANDED, `Unsafe/oversized extracted NSIS size: ${entry.path}`); + total += size; assert(Number.isSafeInteger(total) && total <= MAX_EXPANDED, "Extracted NSIS size exceeded"); + // Extracted bytes are authoritative for solid-offset estimates. 7zip + // also reconstructs uninstall.exe from the stub and its encoded patch. + if (entry.size !== null && !entry.sizeIsEstimate && entry.path.replace(/^\$INSTDIR\//, "") !== "uninstall.exe") assert.equal(size, entry.size, `Extracted size mismatch: ${entry.path}`); + } +} export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory, runId, runAttempt, profile, repositoryRoot = process.cwd() }) { assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); assert.match(runId ?? "", /^[1-9][0-9]{0,19}$/); assert.match(runAttempt ?? "", /^[1-9][0-9]{0,3}$/); assert(Number(runAttempt) <= 1000); @@ -209,9 +221,6 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec const listEntry = entries.find((entry) => entry.path === relative); assert(listEntry && !listEntry.directory, "Unexpected extracted entry"); extractedTotal += info.size; assert(Number.isSafeInteger(extractedTotal) && extractedTotal <= MAX_EXPANDED, "Extracted NSIS size exceeded"); const bytes = readRegular(path, MAX_EXPANDED); - // 7zip reconstructs the uninstaller from the stub and its patch; its - // listed encoded length is not the reconstructed executable length. - if (listEntry.size !== null && relative.replace(/^\$INSTDIR\//, "") !== "uninstall.exe") assert.equal(bytes.length, listEntry.size, `Extracted size mismatch: ${relative}`); extracted.set(relative, bytes); const record = { path: relative, size: bytes.length, sha256: sha256(bytes) }; // NSIS plugins/bootstrap files are installer support, not x64 payload. @@ -220,7 +229,7 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec } assert(inventory.length + support.length <= 8192, "Extracted file count exceeded"); } - assert(entries.filter((entry) => !entry.directory).every((entry) => extracted.has(entry.path)), "Missing extracted NSIS entry"); + validateExtractedSizes(entries, new Map([...extracted].map(([path, bytes]) => [path, bytes.length]))); inventory.sort((a, b) => byteOrder(a.path, b.path)); support.sort((a, b) => byteOrder(a.path, b.path)); assert(inventory.length > 0 && inventory.length <= 4096 && new Set(inventory.map((file) => file.path.toLowerCase())).size === inventory.length, "Payload identity is ambiguous"); const contained = inventory.find((file) => file.path === spec.executable); assert(contained, "Real installed executable is absent"); diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs index 0a814ad..6b0b1fc 100644 --- a/scripts/ci/windows-package.test.mjs +++ b/scripts/ci/windows-package.test.mjs @@ -8,11 +8,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { SPEC, inspectWindowsPackage, parseNsisListing, peInfo, safeArchivePath, verifyPackageProof } from "./windows-package.mjs"; +import { SPEC, inspectWindowsPackage, parseNsisListing, peInfo, safeArchivePath, validateExtractedSizes, verifyPackageProof } from "./windows-package.mjs"; test("solid NSIS listing keeps optional Packed Size separate from logical Size", () => { const listing = (size, packed = "", solid = "+") => `Type = Nsis\nSolid = ${solid}\n\n----------\nPath = app.exe\n${size === undefined ? "" : `Size = ${size}\n`}Packed Size = ${packed}\nAttributes = A\nSolid = ${solid}\n`; assert.equal(parseNsisListing(listing("37"))[0].size, 37); + assert.equal(parseNsisListing(listing("37"))[0].sizeIsEstimate, true); + assert.equal(parseNsisListing(listing("37", "", "-"))[0].sizeIsEstimate, false); assert.equal(parseNsisListing(listing("0"))[0].size, 0); assert.equal(parseNsisListing(listing(""))[0].size, null); assert.throws(() => parseNsisListing(listing(undefined)), /Invalid NSIS Size/); @@ -21,6 +23,32 @@ test("solid NSIS listing keeps optional Packed Size separate from logical Size", assert.throws(() => parseNsisListing(listing("37", "not-a-size")), /NSIS Packed Size/); }); +test("extracted NSIS sizes distinguish solid estimates from exact lengths and retain bounds/closure", () => { + const path = "$PLUGINSDIR/StartMenu.dll"; + const listing = (archiveSolid, itemSolid, size = "20996") => `Type = Nsis\nSolid = ${archiveSolid}\n\n----------\nPath = ${path}\nSize = ${size}\nAttributes = A\nSolid = ${itemSolid}\n`; + const estimated = parseNsisListing(listing("+", "+")); + const actual = new Map([[path, 12288]]); + assert.equal(estimated[0].size, 20996); + assert.equal(estimated[0].sizeIsEstimate, true); + validateExtractedSizes(estimated, actual); // The concrete StartMenu.dll failure. + validateExtractedSizes(parseNsisListing(listing("+", "+", "")), actual); + validateExtractedSizes(parseNsisListing(listing("-", "-", "12288")), actual); + for (const [archiveSolid, itemSolid] of [["-", "-"], ["-", "+"], ["+", "-"], ["+", ""]]) { + const exact = parseNsisListing(listing(archiveSolid, itemSolid)); + assert.equal(exact[0].sizeIsEstimate, false); + assert.throws(() => validateExtractedSizes(exact, actual), /Extracted size mismatch/); + assert.throws(() => parseNsisListing(listing(archiveSolid, itemSolid, "")), /Invalid NSIS Size/); + } + for (const size of [-1, 1.5, NaN, 1073741825, 9007199254740992]) { + assert.throws(() => validateExtractedSizes(estimated, new Map([[path, size]])), /Unsafe\/oversized extracted NSIS size/); + } + const two = [...estimated, { ...estimated[0], path: "support.bin" }]; + assert.throws(() => validateExtractedSizes(two, new Map([[path, 536870913], ["support.bin", 536870913]])), /Extracted NSIS size exceeded/); + assert.throws(() => validateExtractedSizes(estimated, new Map()), /Missing extracted NSIS entry/); + // Non-solid uninstall.exe still describes an encoded patch, not its rebuilt PE. + validateExtractedSizes(parseNsisListing(listing("-", "-").replace(path, "$INSTDIR/uninstall.exe")), new Map([["$INSTDIR/uninstall.exe", 12288]])); +}); + test("generated solid NSIS preserves known and unknown-size payloads and reconstructs its uninstaller", { skip: process.env.LAPKB_NSIS_LISTING_FIXTURE !== "1", }, () => { @@ -33,14 +61,22 @@ test("generated solid NSIS preserves known and unknown-size payloads and reconst }; try { const files = { "first.bin": Buffer.from("first payload\n"), "second.bin": Buffer.alloc(73, 42), "last.bin": Buffer.from("last payload\n") }; + const pluginPath = "$PLUGINSDIR/StartMenu.dll"; const supportPath = "$PLUGINSDIR/fixture-support.bin"; + const support = { [pluginPath]: readFileSync("/usr/share/nsis/Plugins/x86-unicode/StartMenu.dll"), + [supportPath]: Buffer.from("genuine installer support fixture\n") }; for (const [name, bytes] of Object.entries(files)) writeFileSync(join(directory, name), bytes); + writeFileSync(join(directory, "support.bin"), support[supportPath]); for (const tail of ["uninstaller", "payload"]) { + // Use the stock Unicode plugin's real NSIS command, not a payload copy. // Keep the payload-tail archive free of an uninstaller patch: 7zip can // reorder that patch after the payload regardless of NSIS command order. - writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\n${tail === "payload" ? 'File "last.bin"\n' : 'WriteUninstaller "$INSTDIR\\uninstall.exe"\n'}SectionEnd\n${tail === "uninstaller" ? 'Section "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n' : ""}`); + writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nInitPluginsDir\nSetOutPath "$PLUGINSDIR"\nFile /oname=fixture-support.bin "support.bin"\nStartMenu::Init /autoadd "Listing fixture"\nPop $0\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\n${tail === "payload" ? 'File "last.bin"\n' : 'WriteUninstaller "$INSTDIR\\uninstall.exe"\n'}SectionEnd\n${tail === "uninstaller" ? 'Section "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n' : ""}`); run("/usr/bin/makensis", ["fixture.nsi"]); const listing = run("/usr/bin/7z", ["l", "-slt", "--", `${tail}.exe`]); - const fixtures = [{ name: "generated", listing }]; + const fixtures = [{ name: "generated", listing }, { + name: "positive solid plugin estimate", pluginSize: 20996, + listing: listing.replace(/^(Path = \$PLUGINSDIR\/StartMenu\.dll\r?\n)Size = [^\r\n]*/m, "$1Size = 20996"), + }]; if (tail === "payload") { // Exercise both permitted Size forms independently of this 7zip build, // changing only the final payload's field in its real solid listing. @@ -55,23 +91,29 @@ test("generated solid NSIS preserves known and unknown-size payloads and reconst for (fixture of fixtures) { const entries = parseNsisListing(fixture.listing); const lastPath = tail === "payload" ? "last.bin" : "uninstall.exe"; - assert.deepEqual(entries.map((entry) => entry.path).sort(), ["first.bin", "second.bin", lastPath].sort()); + assert.deepEqual(entries.map((entry) => entry.path).sort(), ["first.bin", "second.bin", pluginPath, supportPath, lastPath].sort()); const last = entries.find((entry) => entry.path === lastPath); if (fixture.size !== undefined) assert.equal(last.size, fixture.size); + const plugin = entries.find((entry) => entry.path === pluginPath); + assert.equal(plugin.sizeIsEstimate, true); + if (fixture.pluginSize !== undefined) assert.equal(plugin.size, fixture.pluginSize); + const extractedSizes = new Map(); for (const entry of entries) { assert(!entry.directory); const bytes = readFileSync(join(directory, `${tail}-extracted`, entry.path)); assert(bytes.length <= 1024 * 1024); + extractedSizes.set(entry.path, bytes.length); if (entry.path === "uninstall.exe") { assert.equal(peInfo(bytes).machine, 0x14c); if (entry.size !== null) assert.notEqual(bytes.length, entry.size, "Rebuilt uninstaller is not the encoded patch length"); } else { - assert.deepEqual(bytes, files[entry.path]); - assert.equal(bytes.length, files[entry.path].length); - assert.equal(createHash("sha256").update(bytes).digest("hex"), createHash("sha256").update(files[entry.path]).digest("hex")); - if (entry.size !== null) assert.equal(bytes.length, entry.size); + const input = files[entry.path] ?? support[entry.path]; assert(input, "Missing genuine fixture input"); + assert.deepEqual(bytes, input); + assert.equal(bytes.length, input.length); + assert.equal(createHash("sha256").update(bytes).digest("hex"), createHash("sha256").update(input).digest("hex")); } } + validateExtractedSizes(entries, extractedSizes); } } catch (error) { console.error(`Generated ${tail}-tail NSIS listing (${fixture.name}):\n${fixture.listing}`); From 2785c45669c97715480469f2032fe4796ccd1bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 16:52:46 +0100 Subject: [PATCH 25/27] fix: separate NSIS plugin and uninstaller fixtures [skip ci] --- scripts/ci/windows-package.test.mjs | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs index 6b0b1fc..15631e6 100644 --- a/scripts/ci/windows-package.test.mjs +++ b/scripts/ci/windows-package.test.mjs @@ -67,17 +67,18 @@ test("generated solid NSIS preserves known and unknown-size payloads and reconst for (const [name, bytes] of Object.entries(files)) writeFileSync(join(directory, name), bytes); writeFileSync(join(directory, "support.bin"), support[supportPath]); for (const tail of ["uninstaller", "payload"]) { - // Use the stock Unicode plugin's real NSIS command, not a payload copy. - // Keep the payload-tail archive free of an uninstaller patch: 7zip can - // reorder that patch after the payload regardless of NSIS command order. - writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\nInitPluginsDir\nSetOutPath "$PLUGINSDIR"\nFile /oname=fixture-support.bin "support.bin"\nStartMenu::Init /autoadd "Listing fixture"\nPop $0\nSetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\n${tail === "payload" ? 'File "last.bin"\n' : 'WriteUninstaller "$INSTDIR\\uninstall.exe"\n'}SectionEnd\n${tail === "uninstaller" ? 'Section "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n' : ""}`); + // Keep the previously passing uninstaller construction free of plugins + // and support files. Exercise the stock Unicode plugin's real command + // only in the payload-tail archive, without an uninstaller patch. + const pluginInstructions = tail === "payload" ? 'InitPluginsDir\nSetOutPath "$PLUGINSDIR"\nFile /oname=fixture-support.bin "support.bin"\nStartMenu::Init /autoadd "Listing fixture"\nPop $0\n' : ""; + writeFileSync(join(directory, "fixture.nsi"), `Unicode true\nName "Listing fixture"\nOutFile "${tail}.exe"\nRequestExecutionLevel user\nSetCompressor /SOLID lzma\nInstallDir "$LOCALAPPDATA\\Listing fixture"\nSection\n${pluginInstructions}SetOutPath "$INSTDIR"\nFile "first.bin"\nFile "second.bin"\n${tail === "payload" ? 'File "last.bin"\n' : 'WriteUninstaller "$INSTDIR\\uninstall.exe"\n'}SectionEnd\n${tail === "uninstaller" ? 'Section "Uninstall"\nDelete "$INSTDIR\\first.bin"\nSectionEnd\n' : ""}`); run("/usr/bin/makensis", ["fixture.nsi"]); const listing = run("/usr/bin/7z", ["l", "-slt", "--", `${tail}.exe`]); - const fixtures = [{ name: "generated", listing }, { - name: "positive solid plugin estimate", pluginSize: 20996, - listing: listing.replace(/^(Path = \$PLUGINSDIR\/StartMenu\.dll\r?\n)Size = [^\r\n]*/m, "$1Size = 20996"), - }]; + const fixtures = [{ name: "generated", listing }]; if (tail === "payload") { + fixtures.push({ name: "positive solid plugin estimate", pluginSize: 20996, + listing: listing.replace(/^(Path = \$PLUGINSDIR\/StartMenu\.dll\r?\n)Size = [^\r\n]*/m, "$1Size = 20996"), + }); // Exercise both permitted Size forms independently of this 7zip build, // changing only the final payload's field in its real solid listing. for (const size of [files["last.bin"].length, null]) fixtures.push({ @@ -91,12 +92,17 @@ test("generated solid NSIS preserves known and unknown-size payloads and reconst for (fixture of fixtures) { const entries = parseNsisListing(fixture.listing); const lastPath = tail === "payload" ? "last.bin" : "uninstall.exe"; - assert.deepEqual(entries.map((entry) => entry.path).sort(), ["first.bin", "second.bin", pluginPath, supportPath, lastPath].sort()); + const expectedPaths = tail === "payload" + ? ["first.bin", "second.bin", "last.bin", pluginPath, supportPath] + : ["first.bin", "second.bin", "uninstall.exe"]; + assert.deepEqual(entries.map((entry) => entry.path).sort(), expectedPaths.sort()); const last = entries.find((entry) => entry.path === lastPath); if (fixture.size !== undefined) assert.equal(last.size, fixture.size); - const plugin = entries.find((entry) => entry.path === pluginPath); - assert.equal(plugin.sizeIsEstimate, true); - if (fixture.pluginSize !== undefined) assert.equal(plugin.size, fixture.pluginSize); + if (tail === "payload") { + const plugin = entries.find((entry) => entry.path === pluginPath); + assert.equal(plugin.sizeIsEstimate, true); + if (fixture.pluginSize !== undefined) assert.equal(plugin.size, fixture.pluginSize); + } const extractedSizes = new Map(); for (const entry of entries) { assert(!entry.directory); From 3e5f50b33b8ab92d4a44d161ad7638f8999dab55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Sun, 4 Oct 2026 20:19:42 +0100 Subject: [PATCH 26/27] fix: classify Windows COM base and report complete DLL closure [skip ci] --- scripts/ci/windows-cross.Dockerfile | 15 ++++-- scripts/ci/windows-package.mjs | 74 ++++++++++++++++++++++----- scripts/ci/windows-package.test.mjs | 79 +++++++++++++++++++++++++++-- 3 files changed, 148 insertions(+), 20 deletions(-) diff --git a/scripts/ci/windows-cross.Dockerfile b/scripts/ci/windows-cross.Dockerfile index cbe1f51..937439e 100644 --- a/scripts/ci/windows-cross.Dockerfile +++ b/scripts/ci/windows-cross.Dockerfile @@ -81,7 +81,13 @@ RUN --network=none cargo xwin test --no-run --locked --offline --features local- --manifest-path desktop/src-tauri/Cargo.toml --target "$WINDOWS_TARGET" -RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --features local-staging --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline +# Retain independent compiled bytes before Tauri temporarily patches its NSIS +# bundle marker. The exported Checkmate alias retains the original compiled PE. +RUN --network=none cd desktop && npm run tauri -- build --runner cargo-xwin --target "$WINDOWS_TARGET" --features local-staging --no-bundle --config '{"build":{"beforeBuildCommand":""}}' -- --locked --offline \ + && test -s "src-tauri/target/$WINDOWS_TARGET/release/checkmate-desktop.exe" \ + && install -d /out \ + && cp "src-tauri/target/$WINDOWS_TARGET/release/checkmate-desktop.exe" /out/checkmate.exe \ + && chmod 644 /out/checkmate.exe # Tauri may download public NSIS helpers; no source credentials remain in this step. RUN cd desktop && npm run tauri -- bundle --target "$WINDOWS_TARGET" --bundles nsis --ci --no-sign @@ -93,10 +99,11 @@ RUN set -eu; \ if [ "$#" -ne 1 ] || [ ! -s "$1" ]; then \ echo "Expected exactly one non-empty NSIS installer in $nsis_dir" >&2; exit 1; \ fi; \ - install -d /out; \ - cp "$app" /out/checkmate.exe; \ + if ! cmp -s "$app" /out/checkmate.exe; then \ + echo "Tauri did not restore the independently captured compiled PE" >&2; exit 1; \ + fi; \ cp "$1" /out/checkmate-nsis-installer.exe; \ - chmod 644 /out/checkmate.exe /out/checkmate-nsis-installer.exe + chmod 644 /out/checkmate-nsis-installer.exe RUN node scripts/ci/windows-package.mjs checkerboard "$WINDOWS_TARGET" /out "$PACKAGE_SOURCE_SHA" "$PACKAGE_RUN_ID" "$PACKAGE_RUN_ATTEMPT" public-staging diff --git a/scripts/ci/windows-package.mjs b/scripts/ci/windows-package.mjs index 803e69c..75646ed 100644 --- a/scripts/ci/windows-package.mjs +++ b/scripts/ci/windows-package.mjs @@ -18,8 +18,30 @@ export const SPEC = Object.freeze({ }); const MAX_FILE = 256 * 1024 * 1024; const MAX_EXPANDED = 1024 * 1024 * 1024; -const SYSTEM_LIBRARIES = new Set(("kernel32.dll kernelbase.dll ntdll.dll user32.dll advapi32.dll ole32.dll oleaut32.dll shell32.dll shlwapi.dll gdi32.dll gdi32full.dll comdlg32.dll comctl32.dll version.dll winmm.dll ws2_32.dll secur32.dll security.dll crypt32.dll bcrypt.dll bcryptprimitives.dll ncrypt.dll uxtheme.dll dwmapi.dll d3d11.dll dxgi.dll d2d1.dll dwrite.dll imm32.dll winhttp.dll wininet.dll psapi.dll iphlpapi.dll wtsapi32.dll msimg32.dll rpcrt4.dll cfgmgr32.dll setupapi.dll powrprof.dll normaliz.dll propsys.dll mpr.dll msvcrt.dll ucrtbase.dll dbghelp.dll dbgcore.dll dnsapi.dll netapi32.dll userenv.dll windowscodecs.dll opengl32.dll hid.dll cabinet.dll urlmon.dll avrt.dll winspool.drv mswsock.dll dhcpcsvc.dll dcomp.dll shcore.dll gdiplus.dll wintrust.dll win32u.dll d3d12.dll d3dcompiler_47.dll uiautomationcore.dll oleacc.dll").split(" ")); +// Microsoft RoInitialize: ComBase.dll is Windows-provided from Windows 8 / +// Server 2012 (https://learn.microsoft.com/windows/win32/api/roapi/nf-roapi-roinitialize). +const SYSTEM_LIBRARIES = new Set(("kernel32.dll kernelbase.dll ntdll.dll user32.dll advapi32.dll ole32.dll oleaut32.dll combase.dll shell32.dll shlwapi.dll gdi32.dll gdi32full.dll comdlg32.dll comctl32.dll version.dll winmm.dll ws2_32.dll secur32.dll security.dll crypt32.dll bcrypt.dll bcryptprimitives.dll ncrypt.dll uxtheme.dll dwmapi.dll d3d11.dll dxgi.dll d2d1.dll dwrite.dll imm32.dll winhttp.dll wininet.dll psapi.dll iphlpapi.dll wtsapi32.dll msimg32.dll rpcrt4.dll cfgmgr32.dll setupapi.dll powrprof.dll normaliz.dll propsys.dll mpr.dll msvcrt.dll ucrtbase.dll dbghelp.dll dbgcore.dll dnsapi.dll netapi32.dll userenv.dll windowscodecs.dll opengl32.dll hid.dll cabinet.dll urlmon.dll avrt.dll winspool.drv mswsock.dll dhcpcsvc.dll dcomp.dll shcore.dll gdiplus.dll wintrust.dll win32u.dll d3d12.dll d3dcompiler_47.dll uiautomationcore.dll oleacc.dll").split(" ")); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const UNBUNDLED_MARKER = Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK"); +const NSIS_MARKER = Buffer.from("__TAURI_BUNDLE_TYPE_VAR_NSS"); +export function nsisApplicationBytes(compiled) { + // Pinned Tauri CLI 2.11.4 / bundler 2.9.4 patches the first marker for + // NSIS, then restores the compiled PE. Derive the expected installed bytes + // from that independent PE, never from extraction; change nothing else. + const offset = compiled.indexOf(UNBUNDLED_MARKER); + assert(offset >= 0, "Compiled application lacks the unbundled Tauri marker"); + const expected = Buffer.from(compiled); + NSIS_MARKER.copy(expected, offset); + return expected; +} +function applicationMismatch(expected, actual) { + let first = 0; let last = Math.max(expected.length, actual.length) - 1; + while (first <= last && expected[first] === actual[first]) first++; + while (last >= first && expected[last] === actual[last]) last--; + return JSON.stringify({ expectedSize: expected.length, installedSize: actual.length, + firstDifferingOffset: first, lastDifferingOffset: last, + expectedNsisMarkerOffset: expected.indexOf(NSIS_MARKER), installedNsisMarkerOffset: actual.indexOf(NSIS_MARKER) }); +} const byteOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)); function readRegular(path, maximum = MAX_FILE) { const before = lstatSync(path, { bigint: true }); @@ -187,6 +209,31 @@ export function validateExtractedSizes(entries, extractedSizes) { if (entry.size !== null && !entry.sizeIsEstimate && entry.path.replace(/^\$INSTDIR\//, "") !== "uninstall.exe") assert.equal(size, entry.size, `Extracted size mismatch: ${entry.path}`); } } +export function validatePayloadImports(payload) { + const bundledLibraries = new Map(); + for (const file of payload.filter((file) => file.path.toLowerCase().endsWith(".dll"))) { + const name = basename(file.path).toLowerCase(); + assert(!bundledLibraries.has(name), "Ambiguous bundled DLL name"); + bundledLibraries.set(name, file.path); + } + const importedSystem = new Set(); const unresolved = []; let unresolvedCount = 0; + const diagnosticLimit = 32; + // Inspect the entire app/DLL closure before failing, but bound the error text. + for (const file of payload) { + for (const library of file.imports) { + if (SYSTEM_LIBRARIES.has(library) || /^(api|ext)-ms-win-[a-z0-9-]+\.dll$/.test(library)) importedSystem.add(library); + else { + const bundled = bundledLibraries.get(library); + if (!bundled || bundled.includes("/")) { + unresolvedCount++; + if (unresolved.length < diagnosticLimit) unresolved.push(`${file.path} -> ${library}${bundled ? ` (bundled at ${bundled})` : ""}`); + } + } + } + } + assert.equal(unresolvedCount, 0, `Missing/root-misplaced DLL dependencies (${unresolvedCount} unresolved imports):\n${unresolved.join("\n")}${unresolvedCount > diagnosticLimit ? `\nDiagnostic output capped at ${diagnosticLimit} of ${unresolvedCount}; all payload imports were inspected.` : ""}`); + return importedSystem; +} export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory, runId, runAttempt, profile, repositoryRoot = process.cwd() }) { assert.match(sourceCommit ?? "", /^[0-9a-f]{40}$/); assert.match(runId ?? "", /^[1-9][0-9]{0,19}$/); assert.match(runAttempt ?? "", /^[1-9][0-9]{0,3}$/); assert(Number(runAttempt) <= 1000); @@ -233,21 +280,17 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec inventory.sort((a, b) => byteOrder(a.path, b.path)); support.sort((a, b) => byteOrder(a.path, b.path)); assert(inventory.length > 0 && inventory.length <= 4096 && new Set(inventory.map((file) => file.path.toLowerCase())).size === inventory.length, "Payload identity is ambiguous"); const contained = inventory.find((file) => file.path === spec.executable); assert(contained, "Real installed executable is absent"); - assert.equal(contained.sha256, sha256(appBytes), "Exported PE differs from installer-contained application"); - assert.equal(contained.size, appBytes.length); const bytesFor = (file) => extracted.get(file.path) ?? extracted.get(`$INSTDIR/${file.path}`); - const bundledLibraries = new Map(); + const expectedAppBytes = nsisApplicationBytes(appBytes); const expectedHash = sha256(expectedAppBytes); + const mismatch = contained.sha256 === expectedHash ? "" : `: ${applicationMismatch(expectedAppBytes, bytesFor(contained))}`; + assert.equal(contained.sha256, expectedHash, `Exported PE differs from installer-contained application after the exact Tauri NSIS marker patch${mismatch}`); + assert.equal(contained.size, expectedAppBytes.length); + const payloadImports = []; for (const file of inventory.filter((file) => /\.(exe|dll)$/i.test(file.path))) { const pe = peInfo(bytesFor(file)); assert.equal(pe.machine, machine, `Wrong architecture inside payload: ${file.path}`); - if (file.path.toLowerCase().endsWith(".dll")) { const name = basename(file.path).toLowerCase(); assert(!bundledLibraries.has(name), "Ambiguous bundled DLL name"); bundledLibraries.set(name, file.path); } - } - const importedSystem = new Set(); - for (const file of inventory.filter((file) => /\.(exe|dll)$/i.test(file.path))) { - for (const library of peInfo(bytesFor(file)).imports) { - if (SYSTEM_LIBRARIES.has(library) || /^(api|ext)-ms-win-[a-z0-9-]+\.dll$/.test(library)) importedSystem.add(library); - else { const bundled = bundledLibraries.get(library); assert(bundled && !bundled.includes("/"), `Missing/root-misplaced DLL dependency: ${file.path} -> ${library}`); } - } + payloadImports.push({ path: file.path, imports: pe.imports }); } + const importedSystem = validatePayloadImports(payloadImports); const resources = config.bundle.resources ?? {}; for (const destination of Array.isArray(resources) ? resources.map((path) => path.replace(/^\.\.\//, "")) : Object.values(resources)) { safeArchivePath(destination); assert(inventory.some((file) => file.path === destination || file.path.startsWith(`${destination}/`)), `Required resource is absent: ${destination}`); @@ -256,6 +299,7 @@ export function inspectWindowsPackage({ appId, target, sourceCommit, outputDirec const proof = { schema: "lapkb-windows-package-v1", app: appId, target: `windows-${machine === 0x8664 ? "x86_64" : "aarch64"}`, version, sourceCommit, build: { runId, runAttempt: Number(runAttempt), profile }, + compiledApplication: { filename: spec.exported, size: appBytes.length, sha256: sha256(appBytes) }, installer: { filename: installerName, size: installerBytes.length, sha256: sha256(installerBytes), stubMachine: stub.machine, kind: "nsis" }, windowsPayload: { schema: "lapkb-windows-payload-v1", productName: spec.product, executable: spec.executable, architecture: machine === 0x8664 ? "x86_64" : "aarch64", version, installMode: "currentUser", files: inventory }, @@ -299,7 +343,11 @@ export function verifyPackageProof({ appId, target, sourceCommit, outputDirector size += file.size; assert(size <= MAX_EXPANDED, "Payload inventory size exceeds bound"); } const app = readRegular(join(outputDirectory, appName)); const record = files.find((file) => file.path === spec.executable); - assert(record && record.size === app.length && record.sha256 === sha256(app), "Contained payload digest differs from exported PE"); + const compiled = proof.compiledApplication; + assert(compiled && compiled.filename === spec.exported && appName === spec.exported + && compiled.size === app.length && compiled.sha256 === sha256(app), "Compiled application identity differs from exported PE"); + const expected = nsisApplicationBytes(app); + assert(record && record.size === expected.length && record.sha256 === sha256(expected), "Contained payload digest differs from the exact Tauri NSIS marker patch"); const info = peInfo(app); assert(!info.dll, "Application executable is a DLL"); assert.equal(info.machine, architecture === "x86_64" ? 0x8664 : 0xaa64); assert.equal(info.version, version); assert(info.products.length && info.products.every((name) => name === spec.product)); diff --git a/scripts/ci/windows-package.test.mjs b/scripts/ci/windows-package.test.mjs index 15631e6..ea6fde0 100644 --- a/scripts/ci/windows-package.test.mjs +++ b/scripts/ci/windows-package.test.mjs @@ -8,7 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { SPEC, inspectWindowsPackage, parseNsisListing, peInfo, safeArchivePath, validateExtractedSizes, verifyPackageProof } from "./windows-package.mjs"; +import { SPEC, inspectWindowsPackage, nsisApplicationBytes, parseNsisListing, peInfo, safeArchivePath, validateExtractedSizes, validatePayloadImports, verifyPackageProof } from "./windows-package.mjs"; test("solid NSIS listing keeps optional Packed Size separate from logical Size", () => { const listing = (size, packed = "", solid = "+") => `Type = Nsis\nSolid = ${solid}\n\n----------\nPath = app.exe\n${size === undefined ? "" : `Size = ${size}\n`}Packed Size = ${packed}\nAttributes = A\nSolid = ${solid}\n`; @@ -49,6 +49,59 @@ test("extracted NSIS sizes distinguish solid estimates from exact lengths and re validateExtractedSizes(parseNsisListing(listing("-", "-").replace(path, "$INSTDIR/uninstall.exe")), new Map([["$INSTDIR/uninstall.exe", 12288]])); }); +test("Windows COM base is OS-provided; unknown and third-party DLLs still require root placement", () => { + const app = { path: "app.exe", imports: ["combase.dll", "ole32.dll", "vendor.dll"] }; + const vendor = { path: "vendor.dll", imports: ["combase.dll"] }; + assert.deepEqual([...validatePayloadImports([app, vendor])].sort(), ["combase.dll", "ole32.dll"]); + for (const library of ["unknown.dll", "combase-extra.dll", "vcruntime140.dll"]) { + assert.throws(() => validatePayloadImports([{ ...app, imports: [library] }]), /Missing\/root-misplaced DLL dependencies/); + } + assert.throws(() => validatePayloadImports([app, { ...vendor, path: "lib/vendor.dll" }]), /app\.exe -> vendor\.dll \(bundled at lib\/vendor\.dll\)/); + assert.throws(() => validatePayloadImports([app, vendor, { ...vendor, path: "lib/VENDOR.DLL" }]), /Ambiguous bundled DLL name/); + // Every missing/misplaced import from both application and bundled DLLs is + // reported together. System COM base is never mistaken for a bundled DLL. + assert.throws(() => validatePayloadImports([ + { ...app, imports: [...app.imports, "unknown.dll", "vcruntime140.dll"] }, + { ...vendor, imports: ["combase.dll", "nested.dll", "vendor-dependency.dll"] }, + { path: "lib/nested.dll", imports: [] }, + ]), (error) => { + assert(error.message.includes("4 unresolved imports")); + for (const entry of ["app.exe -> unknown.dll", "app.exe -> vcruntime140.dll", "vendor.dll -> nested.dll (bundled at lib/nested.dll)", "vendor.dll -> vendor-dependency.dll"]) assert(error.message.includes(entry), entry); + assert(!error.message.includes("-> combase.dll") && !error.message.includes("capped")); + return true; + }); +}); + +test("DLL diagnostics cap output explicitly without stopping the closure inspection", () => { + const imports = Array.from({ length: 40 }, (_, index) => `missing-${index}.dll`); + assert.throws(() => validatePayloadImports([ + { path: "app.exe", imports }, { path: "vendor.dll", imports: ["last-missing.dll"] }, + ]), (error) => { + assert(error.message.includes("41 unresolved imports")); + assert(error.message.includes("Diagnostic output capped at 32 of 41; all payload imports were inspected.")); + assert.equal((error.message.match(/ -> /g) ?? []).length, 32); + assert(error.message.includes("app.exe -> missing-31.dll")); + assert(!error.message.includes("missing-32.dll") && !error.message.includes("last-missing.dll")); + return true; + }); +}); + +test("Tauri NSIS identity patches only the first UNK marker, without changing compiled headers or tail", () => { + const unknown = Buffer.from("__TAURI_BUNDLE_TYPE_VAR_UNK"); const nsis = Buffer.from("__TAURI_BUNDLE_TYPE_VAR_NSS"); + const header = pe(0x8664); const tail = Buffer.concat([Buffer.from([0, 0xe8, 0xff, 0xff, 0xff, 0xff]), unknown, Buffer.from([0x7f])]); + const compiled = Buffer.concat([header, unknown, tail]); const saved = Buffer.from(compiled); + const expected = Buffer.concat([header, nsis, tail]); const actual = nsisApplicationBytes(compiled); + assert.deepEqual(actual, expected); assert.deepEqual(compiled, saved); + assert.equal(actual.length, compiled.length); + const changedOffsets = [...compiled.keys()].filter((offset) => compiled[offset] !== actual[offset]); + assert.deepEqual(changedOffsets, [header.length + unknown.length - 3, header.length + unknown.length - 2, header.length + unknown.length - 1]); + assert.deepEqual(actual.subarray(0, header.length), header); + assert.deepEqual(actual.subarray(header.length + unknown.length), tail); // The second marker remains UNK. + for (const bytes of [header, Buffer.concat([header, nsis]), Buffer.concat([header, unknown.subarray(0, -1)])]) { + assert.throws(() => nsisApplicationBytes(bytes), /lacks the unbundled Tauri marker/); + } +}); + test("generated solid NSIS preserves known and unknown-size payloads and reconstructs its uninstaller", { skip: process.env.LAPKB_NSIS_LISTING_FIXTURE !== "1", }, () => { @@ -160,10 +213,30 @@ test("actual generated NSIS has a reproducible payload and source/run provenance copyFileSync(join(outputDirectory, args.appName), join(directory, args.appName)); // Re-extract the actual package. Equality covers every resource, bundled // DLL, support entry and imported system library, not just the exported PE. - const inspected = inspectWindowsPackage({ appId, target, sourceCommit, outputDirectory: directory, - runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, profile }); + const inspection = { appId, target, sourceCommit, outputDirectory: directory, + runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, profile }; + const inspected = inspectWindowsPackage(inspection); assert.deepEqual(inspected, proof); assert.deepEqual(verifyPackageProof({ ...args, outputDirectory: directory }), proof); + const compiled = readFileSync(join(directory, args.appName)); + const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); + assert.deepEqual(proof.compiledApplication, { filename: spec.exported, size: compiled.length, sha256: digest(compiled) }); + const installed = proof.windowsPayload.files.find((file) => file.path === spec.executable); + assert.equal(installed.sha256, digest(nsisApplicationBytes(compiled))); + assert.notEqual(installed.sha256, proof.compiledApplication.sha256); + // Neither an arbitrary PE timestamp change nor extra tail bytes is part + // of Tauri's marker patch, even though both remain parseable PEs. + const timestamp = Buffer.from(compiled); timestamp[timestamp.readUInt32LE(60) + 8] ^= 1; + for (const changed of [timestamp, Buffer.concat([compiled, Buffer.from([0x7f])])]) { + writeFileSync(join(directory, args.appName), changed); + assert.throws(() => verifyPackageProof({ ...args, outputDirectory: directory }), /Compiled application identity differs/); + assert.throws(() => inspectWindowsPackage(inspection), /Exported PE differs from installer-contained application/); + } + writeFileSync(join(directory, args.appName), compiled); + const wrongCompiled = structuredClone(proof); wrongCompiled.compiledApplication.sha256 = "0".repeat(64); + writeFileSync(join(directory, "windows-package.json"), JSON.stringify(wrongCompiled)); + assert.throws(() => verifyPackageProof({ ...args, outputDirectory: directory }), /Compiled application identity differs/); + writeFileSync(join(directory, "windows-package.json"), JSON.stringify(proof)); assert.throws(() => verifyPackageProof({ ...args, outputDirectory: directory, sourceCommit: "0".repeat(40) })); // These are unsigned packaging bytes; no release signature/native pass is invented. assert.equal(proof.nativeRuntime, "not executed"); From dfcf83cb8d0b93a6e6bd36094231cc6ab7023dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20D=2E=20Ot=C3=A1lvaro?= Date: Wed, 7 Oct 2026 18:25:20 +0100 Subject: [PATCH 27/27] =?UTF-8?q?release:=20Checkmate=200.8.3=20=E2=80=94?= =?UTF-8?q?=20seat=20lock=20screens=20(sdk=20238b962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 8 +- desktop/src-tauri/Cargo.toml | 6 +- desktop/src-tauri/src/auth.rs | 60 +++- desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/protected_dispatch_tests.rs | 30 +- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/App.css | 7 + desktop/src/AuthGate.dom.test.tsx | 315 +++++++++++++++++- desktop/src/AuthGate.tsx | 247 +++++++++++--- desktop/src/types.ts | 29 ++ 12 files changed, 645 insertions(+), 66 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index cc3e368..5d281ef 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "checkmate-desktop", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "checkmate-desktop", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", diff --git a/desktop/package.json b/desktop/package.json index a212a07..ed39694 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "checkmate-desktop", "private": true, - "version": "0.8.2", + "version": "0.8.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 30d3b94..616bc53 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "checkmate-desktop" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "calamine", @@ -2895,8 +2895,8 @@ dependencies = [ [[package]] name = "lapkb-authorization-protocol" -version = "0.2.7" -source = "git+ssh://git@github.com/LAPKB/desktop-authorization.git?rev=b32bcefb244ffba54b9de47b4004939b6a01b0cd#b32bcefb244ffba54b9de47b4004939b6a01b0cd" +version = "0.2.10" +source = "git+ssh://git@github.com/LAPKB/desktop-authorization.git?rev=7bfdaa456a9dfac900618d80d7cc374fc4c3e4d3#7bfdaa456a9dfac900618d80d7cc374fc4c3e4d3" dependencies = [ "base64 0.22.1", "ed25519-dalek", @@ -2912,7 +2912,7 @@ dependencies = [ [[package]] name = "lapkb-desktop-session" version = "0.1.0" -source = "git+ssh://git@github.com/LAPKB/Launcher.git?rev=ef542825f4b0b131e0feb5c8579ef4882a072f87#ef542825f4b0b131e0feb5c8579ef4882a072f87" +source = "git+ssh://git@github.com/LAPKB/Launcher.git?rev=238b96237dd658d5ca16fa3261787ce64c1e8509#238b96237dd658d5ca16fa3261787ce64c1e8509" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2981910..c8e13cb 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "checkmate-desktop" -version = "0.8.2" +version = "0.8.3" description = "Desktop interface for drug-interaction analysis and regimen ranking" authors = ["Michael Neely"] edition = "2024" @@ -46,7 +46,7 @@ rust_xlsxwriter = "0.96" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "ef542825f4b0b131e0feb5c8579ef4882a072f87", features = ["client"] } +lapkb-desktop-session = { git = "ssh://git@github.com/LAPKB/Launcher.git", rev = "238b96237dd658d5ca16fa3261787ce64c1e8509", features = ["client"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "=0.61.2", features = [ @@ -57,7 +57,7 @@ windows-sys = { version = "=0.61.2", features = [ [dev-dependencies] ed25519-dalek = "2.1.1" -lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "b32bcefb244ffba54b9de47b4004939b6a01b0cd" } +lapkb-authorization-protocol = { git = "ssh://git@github.com/LAPKB/desktop-authorization.git", rev = "7bfdaa456a9dfac900618d80d7cc374fc4c3e4d3" } tauri = { version = "2", features = ["test"] } [patch.crates-io] diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs index 100ac0b..2df731d 100644 --- a/desktop/src-tauri/src/auth.rs +++ b/desktop/src-tauri/src/auth.rs @@ -6,11 +6,11 @@ use std::time::Duration; #[cfg(target_os = "macos")] use std::process::Command; -use lapkb_desktop_session::Permit; #[cfg(all(feature = "local-staging", any(unix, windows)))] use lapkb_desktop_session::VerificationKeySet; #[cfg(any(unix, windows))] use lapkb_desktop_session::{LeaseVerifier, ProtectedApp}; +use lapkb_desktop_session::{Permit, SeatReference, SeatStatus}; #[cfg(any(unix, windows))] use lapkb_desktop_session::{client::Client as LauncherSessionClient, client_store::ClientStore}; use serde::Serialize; @@ -30,6 +30,8 @@ const LAUNCHER_OPEN_ERROR: &str = "Could not open LAPKB Launcher."; #[cfg(not(any(target_os = "macos", all(windows, target_arch = "x86_64"))))] const LAUNCHER_OPEN_UNSUPPORTED_MESSAGE: &str = "Opening LAPKB Launcher from Checkmate is not supported on this platform."; +const SEAT_REQUEST_FAILED_MESSAGE: &str = + "Checkmate could not request access on this computer. Open LAPKB Launcher and try again."; #[cfg(all(feature = "local-staging", any(unix, windows)))] mod local_staging { @@ -166,6 +168,7 @@ pub struct AuthView { phase: AuthPhase, user: Option, account_id: Option, + seat: Option, message: Option, } @@ -396,6 +399,43 @@ impl AuthState { } } + /// Latest safe seat display state from the shared client. This is display + /// data only and never authorizes a command. + fn seat_status(&self) -> Option { + #[cfg(any(unix, windows))] + { + match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Some(client.seat_status()), + SessionRuntime::Locked => None, + } + } + #[cfg(not(any(unix, windows)))] + { + None + } + } + + /// Queue one explicit Use here request through the shared client. Ok means + /// the request was accepted for review, not that access was granted; the + /// normal refresh afterwards independently verifies any resulting permit. + async fn use_here(&self, target: Option) -> Result<(), String> { + #[cfg(any(unix, windows))] + { + let client = match self.runtime.as_ref() { + SessionRuntime::Ready(client) => Arc::clone(client), + SessionRuntime::Locked => return Err(TRUST_CONFIGURATION_MESSAGE.to_owned()), + }; + let result = client.use_here(target).await; + self.refresh_once().await; + result.map_err(|_| SEAT_REQUEST_FAILED_MESSAGE.to_owned()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = target; + Err(TRUST_CONFIGURATION_MESSAGE.to_owned()) + } + } + #[cfg(any(unix, windows))] async fn refresh_once(&self) { let client = match self.runtime.as_ref() { @@ -493,6 +533,7 @@ impl AuthState { phase, user, account_id, + seat: self.seat_status(), message: if self.runtime.is_ready() { inner.message.map(str::to_owned) } else { @@ -634,6 +675,15 @@ pub fn auth_open_launcher( state.open_launcher(startup.unwrap_or(false)) } +#[tauri::command] +pub async fn auth_use_here( + state: State<'_, AuthState>, + target: Option, +) -> Result { + state.use_here(target).await?; + Ok(state.view()) +} + #[cfg(test)] mod tests { use super::*; @@ -644,6 +694,14 @@ mod tests { let state = AuthState::test_locked(); assert!(state.acquire_permit().is_none()); assert_eq!(state.denial_message(), TRUST_CONFIGURATION_MESSAGE); + assert!(state.view().seat.is_none()); + } + + #[test] + fn locked_runtime_rejects_seat_requests() { + let state = AuthState::test_locked(); + let error = tauri::async_runtime::block_on(state.use_here(None)).unwrap_err(); + assert_eq!(error, TRUST_CONFIGURATION_MESSAGE); } #[test] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5a7ec75..9538db5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -159,6 +159,7 @@ fn register_commands(builder: tauri::Builder) -> tauri::Builder Reply { + fn signed_reply(state: &BrokerState, request: LicenseRequest) -> Reply { let device = DeviceKey::from_seed(DEVICE_SEED); let lease_id = match (state.account_id.as_str(), state.sequence) { ("account-a", 1) => "00000000-0000-0000-0000-000000000001", @@ -167,18 +168,30 @@ mod broker_fixture { Nonce::from_bytes([8; 32]), ) .expect("origin binding"), + seat: Some( + SeatAuthority::new( + "00000000-0000-0000-0000-000000000041" + .parse() + .expect("reservation id"), + 1, + state.issued_at, + Some(state.issued_at + 30), + ) + .expect("seat authority"), + ), }; let signer = TestSigner(SigningKey::from_bytes(&SERVICE_SEED)); let Ok((envelope, _)) = issue_online_lease(&signer, &issue) else { return Reply::Locked; }; let Ok(proof) = Proof::issue( - challenge, + request, &envelope, &device, BROKER_INSTANCE, GENERATION, state.issued_at, + ProofAuthority::Connected, ) else { return Reply::Locked; }; @@ -215,13 +228,14 @@ mod broker_fixture { break; }; loop { - let Ok(challenge) = transport::read_frame::(&mut stream).await + let Ok(request) = + transport::read_frame::(&mut stream).await else { break; }; let reply = { let state = state_for_task.lock().expect("broker state lock"); - signed_reply(&state, challenge) + signed_reply(&state, request) }; if transport::write_frame(&mut stream, &reply).await.is_err() { break; @@ -299,11 +313,13 @@ fn locked_dispatch_allows_only_auth_controls_and_preserves_known_command_names() } assert!(!is_protected_command("auth_status")); assert!(!is_protected_command("auth_open_launcher")); + assert!(!is_protected_command("auth_use_here")); assert!(!is_protected_command("unknown_command")); assert!(invoke(&webview, "unknown_command", json!({})).is_err()); let status = invoke(&webview, "auth_status", json!({})).expect("auth status is allowed"); assert_eq!(status["phase"], "unconfigured"); assert!(status["user"].is_null()); + assert!(status["seat"].is_null()); drop(app); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7c6cb66..0b6f93a 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Checkmate", - "version": "0.8.2", + "version": "0.8.3", "identifier": "org.lapkb.checkmate", "build": { "beforeDevCommand": "npm run dev", diff --git a/desktop/src/App.css b/desktop/src/App.css index 07324d4..98233fe 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -20,6 +20,13 @@ button:disabled { cursor: not-allowed; opacity: .5; } .access-card p { line-height: 1.5; } .access-card .primary-button { margin-top: 8px; } .access-error { color: #8c2020; } +.seat-panel { margin-top: 14px; text-align: left; } +.seat-status { margin: 10px 0 0; } +.seat-holders { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 8px; } +.seat-holders li { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 8px 10px; border: 1px solid #d9e1e8; border-radius: 6px; } +.seat-holder-label { font-weight: 600; } +.seat-holder-detail { color: #667582; font-size: .8rem; } +.seat-panel .primary-button { margin-top: 0; } .app-header { height: 58px; display: grid; diff --git a/desktop/src/AuthGate.dom.test.tsx b/desktop/src/AuthGate.dom.test.tsx index de66ffd..a30bc7c 100644 --- a/desktop/src/AuthGate.dom.test.tsx +++ b/desktop/src/AuthGate.dom.test.tsx @@ -30,6 +30,7 @@ const authenticated = (accountId: string | null = "account-a"): AuthView => ({ ? { subject: accountId, displayName: accountId, email: null } : null, accountId, + seat: null, message: null, }); @@ -37,6 +38,7 @@ const signedOut = (): AuthView => ({ phase: "signed_out", user: null, accountId: null, + seat: null, message: "Open LAPKB Launcher to sign in.", }); @@ -160,7 +162,7 @@ afterEach(async () => { }); describe("Checkmate AuthGate rendered behavior", () => { - it("locks and unmounts an authenticated workspace when status rejects", async () => { + it("locks a verified workspace hidden and inert when status rejects", async () => { const rejectedStatus = deferred(); statusMock .mockReturnValueOnce(Promise.resolve(authenticated("account-a"))) @@ -168,13 +170,18 @@ describe("Checkmate AuthGate rendered behavior", () => { await renderGate(); expect(mountedAccounts).toEqual(["account-a"]); + await editDraft("unsaved account-a work"); await advance(1000); rejectedStatus.reject(new Error("broker unavailable")); await settle(); - expect(container?.querySelector("[data-account]")).toBeNull(); - expect(unmountedAccounts).toEqual(["account-a"]); + const protectedTree = container?.querySelector("[aria-hidden]"); + expect(protectedTree?.getAttribute("aria-hidden")).toBe("true"); + expect(protectedTree?.hasAttribute("inert")).toBe(true); + expect(protectedTree?.style.display).toBe("none"); + expect(getInput().value).toBe("unsaved account-a work"); + expect(unmountedAccounts).toEqual([]); expect(container?.textContent).toContain( "Could not check LAPKB access: broker unavailable", ); @@ -200,8 +207,11 @@ describe("Checkmate AuthGate rendered behavior", () => { expect(mountedAccounts).toEqual(["account-a"]); await advance(1000); - expect(container?.querySelector("[data-account]")).toBeNull(); - expect(unmountedAccounts).toEqual(["account-a"]); + expect( + container?.querySelector("[aria-hidden]")?.getAttribute("aria-hidden"), + ).toBe("true"); + expect(container?.querySelector("[data-account='account-a']")).not.toBeNull(); + expect(unmountedAccounts).toEqual([]); expect(container?.textContent).toContain("access check timed out"); }); @@ -339,4 +349,299 @@ describe("Checkmate AuthGate rendered behavior", () => { expect(launcherMock).toHaveBeenCalledOnce(); }); + + const conflictView = ( + reason: "seat_unavailable" | "seat_moved", + totalHolders = 2, + holders = [ + { + reservationId: "11111111-1111-4111-8111-111111111111", + generation: 4, + deviceLabel: "Desk-A", + }, + ], + ): AuthView => ({ + phase: "suspended", + user: null, + accountId: "account-a", + seat: { + state: "conflict", + reason, + detail: { + appId: "checkerboard", + capacity: 2, + holders, + totalHolders, + holdersTruncated: totalHolders > holders.length, + }, + }, + message: null, + }); + + function seatButton(label: string): HTMLButtonElement { + const button = Array.from( + container?.querySelectorAll("button") ?? [], + ).find((candidate) => candidate.textContent === label); + if (!button) throw new Error(`${label} button is not rendered`); + return button; + } + + it("shows reviewed reservations and sends the exact reviewed target", async () => { + const current = conflictView("seat_unavailable"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return Promise.resolve({ + ...current, + seat: { state: "acquiring" } as const, + }); + } + return Promise.resolve(current); + }); + await renderGate(); + expect(container?.textContent).toContain("Desk-A"); + expect(container?.textContent).toContain( + "No place is free for Checkmate. 2 of 2 places are reserved.", + ); + await act(async () => seatButton("Use here").click()); + await settle(); + expect(statusMock).toHaveBeenCalledWith("auth_use_here", { + target: { + reservation_id: "11111111-1111-4111-8111-111111111111", + generation: 4, + }, + }); + }); + + it("offers an explicit free-capacity attempt after a moved denial", async () => { + const current = conflictView("seat_moved"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return Promise.resolve({ + ...current, + seat: { state: "pending", not_before: null } as const, + }); + } + return Promise.resolve(current); + }); + await renderGate(); + await act(async () => seatButton("Use free capacity here").click()); + await settle(); + expect(statusMock).toHaveBeenCalledWith("auth_use_here", { target: null }); + await advance(1000); + expect( + statusMock.mock.calls.filter(([command]) => command === "auth_use_here"), + ).toHaveLength(1); + }); + + it("renders every reviewed label as text and reports hidden reservations", async () => { + const holders = Array.from({ length: 16 }, (_, index) => ({ + reservationId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + generation: index + 1, + deviceLabel: index === 0 ? "" : `Desk-${index}`, + })); + statusMock.mockResolvedValue( + conflictView("seat_unavailable", 20, holders), + ); + await renderGate(); + expect(container?.textContent).toContain(""); + const images = container?.querySelectorAll("img") ?? []; + expect(images).toHaveLength(1); + expect(images[0]?.getAttribute("alt")).toBe("Checkmate logo"); + expect(container?.textContent).toContain( + "Showing 16 of 20 reservations; 4 more are not listed.", + ); + }); + + it("disables seat actions while a request is in flight", async () => { + let resolveRequest!: (view: AuthView) => void; + let current = conflictView("seat_unavailable"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return new Promise((resolve) => { + resolveRequest = resolve; + }); + } + return Promise.resolve(current); + }); + await renderGate(); + const button = seatButton("Use here"); + await act(async () => button.click()); + await settle(); + expect(button.disabled).toBe(true); + resolveRequest({ ...current, seat: { state: "acquiring" } }); + await settle(); + // The action reply alone must not install acquiring state. + expect(container?.textContent).not.toContain( + "Acquiring access on this computer…", + ); + current = { ...current, seat: { state: "acquiring" } }; + await advance(1000); + expect(container?.textContent).toContain( + "Acquiring access on this computer…", + ); + }); + + it("does not unlock from a positive action return alone", async () => { + const current = conflictView("seat_unavailable"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return Promise.resolve({ + ...authenticated("account-a"), + seat: { state: "granted" }, + }); + } + return Promise.resolve(current); + }); + await renderGate(); + await act(async () => seatButton("Use here").click()); + await settle(); + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(container?.textContent).toContain( + "No place is free for Checkmate. 2 of 2 places are reserved.", + ); + }); + + it("does not install a delayed action reply over a newer locked status", async () => { + let resolveRequest!: (view: AuthView) => void; + let current = conflictView("seat_unavailable"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return new Promise((resolve) => { + resolveRequest = resolve; + }); + } + return Promise.resolve(current); + }); + await renderGate(); + await act(async () => seatButton("Use here").click()); + await settle(); + current = { + ...conflictView("seat_unavailable"), + message: "Checkmate access is locked.", + }; + await advance(1000); + expect(container?.textContent).toContain("Checkmate access is locked."); + await act(async () => { + resolveRequest({ + ...authenticated("account-a"), + seat: { state: "granted" }, + }); + }); + await settle(); + expect(container?.querySelector("[data-account]")).toBeNull(); + expect(container?.textContent).toContain("Checkmate access is locked."); + expect( + statusMock.mock.calls.filter(([command]) => command === "auth_use_here"), + ).toHaveLength(1); + }); + + it("does not revive account A after a delayed action reply once account B is verified", async () => { + let resolveRequest!: (view: AuthView) => void; + let current = authenticated("account-a"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return new Promise((resolve) => { + resolveRequest = resolve; + }); + } + return Promise.resolve(current); + }); + await renderGate(); + await editDraft("account A draft"); + current = conflictView("seat_unavailable"); + await advance(1000); + await act(async () => seatButton("Use here").click()); + await settle(); + current = authenticated("account-b"); + await advance(1000); + await advance(1000); + expect(container?.querySelector("[data-account='account-b']")).not.toBeNull(); + await act(async () => { + resolveRequest({ + ...authenticated("account-a"), + seat: { state: "granted" }, + }); + }); + await settle(); + await advance(1000); + expect(container?.querySelector("[data-account='account-a']")).toBeNull(); + expect(container?.querySelector("[data-account='account-b']")).not.toBeNull(); + expect(getInput().value).toBe(""); + expect( + statusMock.mock.calls.filter(([command]) => command === "auth_use_here"), + ).toHaveLength(1); + }); + + it("disposes account A immediately when a suspended status names account B", async () => { + let current = authenticated("account-a"); + statusMock.mockImplementation(() => Promise.resolve(current)); + await renderGate(); + await editDraft("private A draft"); + current = { + ...signedOut(), + phase: "suspended", + accountId: "account-b", + message: "Checkmate access is locked.", + }; + await advance(1000); + expect(container?.querySelector("[data-account='account-a']")).toBeNull(); + expect(unmountedAccounts).toEqual(["account-a"]); + expect(container?.querySelector("[data-account='account-b']")).toBeNull(); + current = authenticated("account-b"); + await advance(1000); + expect(container?.querySelector("[data-account='account-b']")).not.toBeNull(); + expect(getInput().value).toBe(""); + }); + + it("keeps account A hidden and inert through same-account or unresolved suspension", async () => { + let current = authenticated("account-a"); + statusMock.mockImplementation(() => Promise.resolve(current)); + await renderGate(); + await editDraft("unsaved A work"); + current = { ...signedOut(), phase: "suspended", accountId: "account-a" }; + await advance(1000); + const tree = container?.querySelector("[aria-hidden]"); + expect(tree?.getAttribute("aria-hidden")).toBe("true"); + expect(tree?.hasAttribute("inert")).toBe(true); + expect(getInput().value).toBe("unsaved A work"); + expect(unmountedAccounts).toEqual([]); + current = { ...signedOut(), phase: "suspended", accountId: null }; + await advance(1000); + expect(tree?.getAttribute("aria-hidden")).toBe("true"); + expect(getInput().value).toBe("unsaved A work"); + expect(unmountedAccounts).toEqual([]); + current = authenticated("account-a"); + await advance(1000); + expect(tree?.getAttribute("aria-hidden")).toBe("false"); + expect(getInput().value).toBe("unsaved A work"); + }); + + it("does not start an unfenced status read when an action is rejected", async () => { + let rejectRequest!: (reason: unknown) => void; + let current = conflictView("seat_unavailable"); + statusMock.mockImplementation((command: unknown) => { + if (command === "auth_use_here") { + return new Promise((_resolve, reject) => { + rejectRequest = reject; + }); + } + return Promise.resolve(current); + }); + await renderGate(); + await act(async () => seatButton("Use here").click()); + await settle(); + current = { + ...conflictView("seat_unavailable"), + message: "Checkmate access is locked.", + }; + await advance(1000); + const statusCalls = statusMock.mock.calls.length; + await act(async () => { + rejectRequest("Could not request access."); + }); + await settle(); + await advance(200); + expect(statusMock.mock.calls.length).toBe(statusCalls); + expect(container?.textContent).toContain("Checkmate access is locked."); + }); }); diff --git a/desktop/src/AuthGate.tsx b/desktop/src/AuthGate.tsx index da86700..54de42d 100644 --- a/desktop/src/AuthGate.tsx +++ b/desktop/src/AuthGate.tsx @@ -1,7 +1,7 @@ -import { Fragment, useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; -import type { AuthView } from "./types"; +import type { AuthView, SeatReference, SeatStatus } from "./types"; const AUTH_STATUS_POLL_INTERVAL_MS = 1000; const AUTH_STATUS_TIMEOUT_MS = 2000; @@ -24,6 +24,10 @@ export function AuthGate({ }: AuthGateProps) { const [authView, setAuthView] = useState(null); const [authStatusError, setAuthStatusError] = useState(null); + const [mountedAccountId, setMountedAccountId] = useState(null); + const mountedAccountIdRef = useRef(null); + const [seatBusy, setSeatBusy] = useState(false); + const [seatError, setSeatError] = useState(null); useEffect(() => { let active = true; @@ -31,6 +35,34 @@ export function AuthGate({ let requestGeneration = 0; let pendingDeadline: number | undefined; + const applyStatus = (view: AuthView) => { + const nextAccountId = view.accountId; + const previousAccountId = mountedAccountIdRef.current; + if ( + previousAccountId && + nextAccountId && + previousAccountId !== nextAccountId && + view.phase !== "authenticated" + ) { + // A correctly returned status identified a different account while no + // verified session exists. Dispose the old tree now; only a later + // authenticated view may mount the replacement. + mountedAccountIdRef.current = null; + setMountedAccountId(null); + setAuthView(view); + return; + } + if (view.phase === "authenticated" && nextAccountId) { + if (nextAccountId !== mountedAccountIdRef.current) { + // A verified authenticated replacement disposes the old tree and + // mounts the fresh keyed tree in the same commit. + mountedAccountIdRef.current = nextAccountId; + setMountedAccountId(nextAccountId); + } + } + setAuthView(view); + }; + const refreshStatus = () => { if (!active || pending) return; @@ -49,7 +81,7 @@ export function AuthGate({ void invoke("auth_status") .then((view) => { if (!active || requestId !== requestGeneration) return; - setAuthView(view); + applyStatus(view); setAuthStatusError(null); }) .catch((reason: unknown) => { @@ -79,50 +111,181 @@ export function AuthGate({ }; }, [formatError]); - if (authView?.phase === "authenticated" && authView.accountId) { + const accountId = + authView?.phase === "authenticated" ? authView.accountId : null; + const unlocked = accountId !== null && accountId === mountedAccountId; + + const useHere = async (target: SeatReference | null) => { + if (seatBusy) return; + setSeatBusy(true); + setSeatError(null); + try { + // The fenced status poll is the only view writer. A queued action reply + // is not permission and must not overwrite a newer poll. + await invoke("auth_use_here", { target }); + } catch (reason) { + setSeatError(formatError(reason)); + } finally { + setSeatBusy(false); + } + }; + + const workspace = mountedAccountId ? ( +
+ {children(mountedAccountId)} +
+ ) : null; + + return ( + <> + {workspace} + {!unlocked && ( +
+
+
+ Checkmate logo + + Checkmate v{buildVersion} + +
+
+
+
+

Checkmate access is locked

+

+ {authView?.message ?? + (authStatusError + ? `Could not check LAPKB access: ${authStatusError}` + : "Checking shared LAPKB access…")} +

+

+ Sign in or restore your Checkmate license in LAPKB Launcher. + Checkmate does not store sign-in credentials. +

+ {authView?.seat && ( + void useHere(target)} + /> + )} + + {launcherError && ( +

+ {launcherError} +

+ )} +
+
+
+ )} + + ); +} + +function SeatPanel({ + seat, + busy, + error, + onUseHere, +}: { + seat: SeatStatus; + busy: boolean; + error: string | null; + onUseHere: (target: SeatReference | null) => void; +}) { + if (seat.state === "acquiring" || seat.state === "pending") { + return ( +

+ {seat.state === "acquiring" + ? "Acquiring access on this computer…" + : "Moving access to this computer…"} +

+ ); + } + if (seat.state === "granted") { return ( - - {children(authView.accountId)} - +

+ Access is ready for this computer. +

+ ); + } + if (seat.state === "locked") { + return ( +

+ Access for this computer is locked. Open LAPKB Launcher to manage it. +

); } + const hidden = Math.max(0, seat.detail.totalHolders - seat.detail.holders.length); return ( -
-
-
- Checkmate logo - - Checkmate v{buildVersion} - -
-
-
-
-

Checkmate access is locked

-

- {authView?.message ?? - (authStatusError - ? `Could not check LAPKB access: ${authStatusError}` - : "Checking shared LAPKB access…")} -

-

- Sign in or restore your Checkmate license in LAPKB Launcher. - Checkmate does not store sign-in credentials. -

- - {launcherError && ( -

- {launcherError} -

- )} -
-
+
+

+ {seat.reason === "seat_moved" + ? `Access for this computer moved to another computer. ${seat.detail.totalHolders} of ${seat.detail.capacity} places are reserved.` + : `No place is free for Checkmate. ${seat.detail.totalHolders} of ${seat.detail.capacity} places are reserved.`} +

+ {seat.detail.holders.length > 0 && ( +
    + {seat.detail.holders.map((holder) => ( +
  • + {holder.deviceLabel} + + Generation {holder.generation} + + +
  • + ))} +
+ )} + {hidden > 0 && ( +

+ Showing {seat.detail.holders.length} of {seat.detail.totalHolders}{" "} + reservations; {hidden} more {hidden === 1 ? "is" : "are"} not listed. +

+ )} + {seat.reason === "seat_moved" && ( + + )} + {error && ( +

+ {error} +

+ )}
); } diff --git a/desktop/src/types.ts b/desktop/src/types.ts index 1d9c9ff..f072db7 100644 --- a/desktop/src/types.ts +++ b/desktop/src/types.ts @@ -6,10 +6,39 @@ export interface AuthUser { email: string | null; } +export type SeatConflictReason = "seat_unavailable" | "seat_moved"; + +export interface SeatHolder { + reservationId: string; + generation: number; + deviceLabel: string; +} + +export interface SeatConflict { + appId: string; + capacity: number; + holders: SeatHolder[]; + totalHolders: number; + holdersTruncated: boolean; +} + +export type SeatStatus = + | { state: "acquiring" } + | { state: "pending"; not_before: number | null } + | { state: "granted" } + | { state: "conflict"; reason: SeatConflictReason; detail: SeatConflict } + | { state: "locked" }; + +export interface SeatReference { + reservation_id: string; + generation: number; +} + export interface AuthView { phase: AuthPhase; user: AuthUser | null; accountId: string | null; + seat: SeatStatus | null; message: string | null; }