diff --git a/.env.example b/.env.example index 75dd8f9be..c197b4468 100644 --- a/.env.example +++ b/.env.example @@ -87,7 +87,7 @@ GITHUB_EVENT_INTAKE_MODE=routing_websocket # without PROPR_UI_TUNNEL_TOKEN. Redundant when a token # is set, since a token alone already enables the tunnel # PROPR_INSTANCE_ID — this stack's instance id; must be a valid DNS label -# (letters, digits, hyphens; 1-63 chars). Derives the +# (letters, digits, hyphens; 1-61 chars). Derives the # public URL https://t-.propr.dev when no # explicit URL is set # PROPR_UI_PUBLIC_API_URL — explicit public API URL the hosted UI talks to (overrides the derived one) @@ -330,6 +330,14 @@ DASHBOARD_API_PORT=4000 # security). Defaults to http://localhost:4000 when unset; set it to the # https://t-.propr.dev host when the hosted UI tunnel is enabled. # API_PUBLIC_URL=http://localhost:4000 +# Optional lifetime for newly paired desktop instance tokens. When unset, +# tokens remain valid until the owner revokes them. Range: 1-3650 days. +# PROPR_DESKTOP_TOKEN_TTL_DAYS=90 +# Optional per-IP desktop discovery/pairing quotas. Defaults are documented in +# docs/docs/operations/desktop-pairing.md. +# PROPR_DISCOVERY_RATE_LIMIT_MAX=60 +# PROPR_PAIRING_START_RATE_LIMIT_MAX=10 +# PROPR_PAIRING_POLL_RATE_LIMIT_MAX=180 # Session cookie domain. Leave UNSET for v1 — including hosted UI tunnel proxy # sessions, which run on a single t-.propr.dev host (see the tunnel # section above). Only set it for a custom multi-subdomain deployment. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..749da8c10 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The packaged Darwin ACL helper is hash-pinned; keep its source canonical. +packages/cli/native/darwin-authority-broker.c text eol=lf diff --git a/.github/workflows/cli-node-compatibility.yml b/.github/workflows/cli-node-compatibility.yml index 1d44ef550..416e5a6bd 100644 --- a/.github/workflows/cli-node-compatibility.yml +++ b/.github/workflows/cli-node-compatibility.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/cli-node-compatibility.yml' - 'package-lock.json' - 'packages/cli/**' + - 'packages/local-setup/**' - 'packages/shared/**' concurrency: @@ -37,8 +38,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build shared dependency - run: npm run build -w @propr/shared + - name: Build workspace dependencies + run: | + npm run build -w @propr/shared + npm run build -w @propr/local-setup - name: Run project option regressions run: >- diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml new file mode 100644 index 000000000..9148dfd1f --- /dev/null +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -0,0 +1,131 @@ +name: Packaged Connect Discovery Guard + +on: + pull_request: + paths: + - '.github/workflows/desktop-connect-discovery-guard.yml' + - 'apps/desktop/**' + - 'packages/cli/**' + - 'packages/client/**' + - 'packages/shared/**' + - 'propr-ui/**' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-connect-discovery-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + packaged-connect-discovery: + name: Packaged Connect (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-x64 + runner: macos-15-intel + platform: darwin + arch: x64 + - target: darwin-arm64 + runner: macos-15 + platform: darwin + arch: arm64 + - target: linux-x64 + runner: ubuntu-24.04 + platform: linux + arch: x64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + platform: linux + arch: arm64 + - target: win32-x64 + runner: windows-2025 + platform: win32 + arch: x64 + - target: win32-arm64 + runner: windows-11-arm + platform: win32 + arch: arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up target-native Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + architecture: ${{ matrix.arch }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify selected host architecture + shell: bash + run: | + test "$(node -p process.platform)" = "${{ matrix.platform }}" + test "$(node -p process.arch)" = "${{ matrix.arch }}" + + - name: Install locked dependencies + run: npm ci + + - name: Install native Linux package and credential tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + + - name: Verify encoded Windows PowerShell ACL helper success streams + if: matrix.platform == 'win32' + run: npm run test:windows-fixture-acl -w @propr/desktop + + - name: Verify Windows packaged launcher authority + if: matrix.platform == 'win32' + run: node --test apps/desktop/scripts/windows-packaged-connect-staging.test.mjs + + - name: Package the target-native desktop app + run: npm run desktop:package + + - name: Inspect the unsigned target-native desktop app + run: npm run desktop:smoke:inspect + + - name: Run packaged Linux main-to-renderer discovery + if: matrix.platform == 'linux' + shell: bash + run: | + sandbox="apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + sudo chown root:root "$sandbox" + sudo chmod 4755 "$sandbox" + test "$(stat -c '%U:%G:%a' "$sandbox")" = 'root:root:4755' + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run smoke:connect-package -w @propr/desktop + ' bash "$keyring_root" + + - name: Run packaged Darwin main-to-renderer discovery + if: matrix.platform == 'darwin' + shell: bash + run: >- + node apps/desktop/scripts/run-bounded-darwin-command.mjs + --timeout-ms 480000 + --termination-grace-ms 90000 + --max-output-bytes 1048576 + --forward-output true + -- bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh '${{ matrix.arch }}' + + - name: Run packaged Windows main-to-renderer discovery as an ordinary user + if: matrix.platform == 'win32' + shell: powershell + run: >- + & apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 + -Architecture '${{ matrix.arch }}' diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml new file mode 100644 index 000000000..500ba1fd9 --- /dev/null +++ b/.github/workflows/desktop-release-guard.yml @@ -0,0 +1,1007 @@ +name: Desktop Package and Release + +on: + pull_request: + paths: + - '.github/workflows/desktop-release-guard.yml' + - 'apps/desktop/**' + - 'package.json' + - 'package-lock.json' + - 'packages/cli/**' + - 'packages/client/**' + - 'packages/local-setup/**' + - 'packages/shared/**' + - 'propr-ui/**' + push: + tags: + - 'desktop-v*' + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +jobs: + native-windows-durability: + name: Native Windows durability (x64) + if: github.event_name == 'pull_request' + runs-on: windows-latest + timeout-minutes: 30 + env: + PROPR_NATIVE_WINDOWS_DURABILITY_REQUIRED: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up x64 Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + architecture: x64 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install locked dependencies + run: npm ci + + - name: Run production Windows child-process durability matrix + run: npm run test:native-durability -w @propr/desktop + + - name: Typecheck desktop and renderer on Windows + run: npm run desktop:typecheck + + - name: Test desktop runtime on Windows + run: npm run desktop:test + + - name: Package Windows x64 desktop app + run: npm run desktop:package + + - name: Launch packaged Windows desktop transport smoke + run: npm run desktop:smoke + + validation-version: + name: Validate unsigned desktop package version + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + release_sha: ${{ github.sha }} + steps: + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Resolve unsigned validation version + id: version + run: | + set -euo pipefail + version="$(node -p "require('./apps/desktop/package.json').version")" + node -e 'if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(process.argv[1])) process.exit(1)' "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + package: + name: Validate unsigned ${{ matrix.platform }}-${{ matrix.arch }} package + if: github.event_name == 'pull_request' + needs: validation-version + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runner: ubuntu-24.04 + - platform: linux + arch: arm64 + runner: ubuntu-24.04-arm + - platform: darwin + arch: x64 + runner: macos-15-intel + - platform: darwin + arch: arm64 + runner: macos-15 + - platform: win32 + arch: x64 + runner: windows-2025 + - platform: win32 + arch: arm64 + runner: windows-11-arm + env: + PROPR_DESKTOP_VERSION: ${{ needs.validation-version.outputs.version }} + steps: + - name: Prove pull-request validation is secretless + shell: bash + run: | + node - <<'NODE' + const forbidden = Object.keys(process.env).filter(name => + /^PROPR_DESKTOP_(?:MAC_CERTIFICATE|WINDOWS_CERTIFICATE|APPLE_API_KEY|UPDATE_PRIVATE_KEY)/.test(name)); + if (forbidden.length) throw new Error(`Release secrets reached unsigned PR validation: ${forbidden.join(', ')}`); + NODE + + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify native runner architecture + shell: bash + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + + - name: Audit committed dependency resolution + shell: bash + run: | + npm run audit:runtime + npm run desktop:audit:packaging + + - name: Install locked dependencies + run: npm ci + + - name: Provision pinned WiX 3.14.1 binaries for Windows ARM64 + if: matrix.platform == 'win32' && matrix.arch == 'arm64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $downloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip' + $expectedSha256 = '6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31' + $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' + $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' + if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { + throw 'Pinned ARM64 WiX runner-temp paths are not fresh' + } + Invoke-WebRequest -Uri $downloadUrl -OutFile $archive -MaximumRedirection 5 -TimeoutSec 120 + $archiveItem = Get-Item -LiteralPath $archive -Force + if ($archiveItem.PSIsContainer -or ($archiveItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ` + -or $archiveItem.Length -le 0 -or $archiveItem.Length -gt 64MB) { + throw 'Pinned ARM64 WiX archive is not a regular file' + } + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedSha256) { + throw 'Pinned ARM64 WiX archive digest mismatch' + } + New-Item -ItemType Directory -Path $wixDirectory | Out-Null + Expand-Archive -LiteralPath $archive -DestinationPath $wixDirectory + Remove-Item -LiteralPath $archive -Force + "PROPR_DESKTOP_WIX_DIRECTORY=$wixDirectory" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Probe canonical WiX 3.14.1 compiler + if: matrix.platform == 'win32' + shell: pwsh + run: node apps/desktop/scripts/build-windows-machine-installer.mjs probe '${{ matrix.arch }}' $env:PROPR_DESKTOP_WIX_DIRECTORY + + - name: Install native Linux package tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + + - name: Package desktop app from clean checkout + shell: bash + run: | + test ! -e packages/shared/dist + test ! -e packages/client/dist + test ! -e apps/desktop/out + npm run desktop:package + + - name: Run Linux transaction durability parity + if: matrix.platform == 'linux' + run: npm run test:native-durability -w @propr/desktop + + - name: Assert Windows MVP package excludes update authority + if: matrix.platform == 'win32' + shell: bash + run: node apps/desktop/scripts/assert-windows-mvp-package.mjs "$(pwd)/apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}" + + - name: Typecheck and test unsigned desktop runtime + shell: bash + run: | + npm run desktop:typecheck + npm run desktop:test + + - name: Make Linux validation packages + if: matrix.platform == 'linux' + shell: bash + run: PROPR_DESKTOP_ENABLE_DEB=1 PROPR_DESKTOP_ENABLE_RPM=1 npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make macOS validation packages + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + npm run make:dmg -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make Windows validation installer + if: matrix.platform == 'win32' + shell: pwsh + run: npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Install and exercise ordinary-user Windows application + if: matrix.platform == 'win32' + shell: pwsh + run: | + $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + if ($installers.Count -ne 1) { throw 'Machine-wide Windows installer is missing or ambiguous' } + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` + -Installer $installers[0].FullName ` + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId + "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Always clean Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + + - name: Launch packaged Linux application + if: matrix.platform == 'linux' + shell: bash + run: | + sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run desktop:smoke + ' bash "$keyring_root" + + - name: Launch packaged Windows application and exercise MVP desktop flows + if: matrix.platform == 'win32' + shell: bash + run: npm run desktop:smoke + + - name: Inspect packaged application + if: matrix.platform == 'darwin' + shell: bash + run: npm run desktop:smoke:inspect + + - name: Inspect native validation packages + shell: bash + run: | + if [ "${{ matrix.platform }}" = linux ]; then + dpkg-deb --info "$(find apps/desktop/out/make -type f -name '*.deb' -print -quit)" >/dev/null + rpm -qip "$(find apps/desktop/out/make -type f -name '*.rpm' -print -quit)" >/dev/null + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + elif [ "${{ matrix.platform }}" = darwin ]; then + node apps/desktop/scripts/verify-darwin-image.mjs "$(find apps/desktop/out/make -type f -name '*.dmg' -print -quit)" + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + fi + + - name: Prove private-snapshot native DMG mounting is available + if: matrix.platform == 'darwin' + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ + --version "$PROPR_DESKTOP_VERSION" \ + --make-directory apps/desktop/out/make \ + --arch "${{ matrix.arch }}" + + - name: Stage architecture-verified validation artifacts with native DMG mount evidence + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs stage \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --make-directory apps/desktop/out/make \ + --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + + - name: Upload unsigned validation target + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-validation-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} + path: desktop-release-${{ matrix.platform }}-${{ matrix.arch }} + if-no-files-found: error + retention-days: 14 + + - name: Clean pinned Windows ARM64 WiX binaries + if: always() && matrix.platform == 'win32' && matrix.arch == 'arm64' + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' + $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' + Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $wixDirectory -Recurse -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { + throw 'Pinned ARM64 WiX cleanup failed' + } + + finalize: + name: Finalize unsigned validation checksums + if: github.event_name == 'pull_request' + needs: [validation-version, package] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install cross-format inspection tools + run: | + sudo apt-get update + sudo apt-get install --yes cpio msitools p7zip-full rpm + test -x /usr/bin/msiextract + + - name: Download all unsigned native artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: propr-desktop-validation-*-${{ github.run_id }} + path: desktop-release-fragments + + - name: Verify architecture, matrix completeness, and checksums + env: + RELEASE_VERSION: ${{ needs.validation-version.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs finalize \ + --version "$RELEASE_VERSION" \ + --input desktop-release-fragments \ + --output desktop-release-final + (cd desktop-release-final && sha256sum --check SHA256SUMS) + + preflight: + name: Protected read-only trusted release preflight + if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'desktop-v') + runs-on: ubuntu-latest + environment: + name: desktop-release-preflight + permissions: + contents: read + outputs: + version: ${{ steps.preflight.outputs.version }} + release_sha: ${{ steps.preflight.outputs.release_sha }} + tag: ${{ steps.preflight.outputs.tag }} + tag_object_sha: ${{ steps.preflight.outputs.tag_object_sha }} + steps: + - name: Checkout exact event SHA without release secrets + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Prove protected preflight has no production authority + shell: bash + run: | + node - <<'NODE' + const forbidden = Object.keys(process.env).filter(name => + /^PROPR_DESKTOP_(?:MAC_CERTIFICATE|WINDOWS_CERTIFICATE|APPLE_API_KEY|UPDATE_PRIVATE_KEY)/.test(name)); + if (forbidden.length) throw new Error(`Production release secrets reached preflight: ${forbidden.join(', ')}`); + NODE + + - name: Create short-lived read-only preflight App token + id: preflight-app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.PROPR_DESKTOP_PREFLIGHT_APP_ID }} + private-key: ${{ secrets.PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-administration: read + permission-contents: read + permission-environments: read + + - name: Verify protected-main provenance, immutable new tag, and environment policy + id: preflight + env: + GITHUB_TOKEN: ${{ steps.preflight-app-token.outputs.token }} + run: node apps/desktop/scripts/release-preflight.mjs + + release-package: + name: Sign and package ${{ matrix.platform }}-${{ matrix.arch }} production target + if: needs.preflight.result == 'success' + needs: preflight + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + environment: + name: desktop-release + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runner: ubuntu-24.04 + - platform: linux + arch: arm64 + runner: ubuntu-24.04-arm + - platform: darwin + arch: x64 + runner: macos-15-intel + - platform: darwin + arch: arm64 + runner: macos-15 + - platform: win32 + arch: x64 + runner: windows-2025 + - platform: win32 + arch: arm64 + runner: windows-11-arm + env: + PROPR_DESKTOP_VERSION: ${{ needs.preflight.outputs.version }} + PROPR_DESKTOP_PRODUCTION_RELEASE: '1' + PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1' + UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} + UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} + UPDATE_MAC_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_MAC_SIGNING_IDENTITY }} + UPDATE_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} + UPDATE_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} + UPDATE_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} + steps: + - name: Revalidate immutable tag before checkout + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + run: | + set -euo pipefail + test "$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG" --jq .object.sha)" = "$TAG_OBJECT_SHA" + test "$(gh api "repos/${{ github.repository }}/commits/$RELEASE_TAG" --jq .sha)" = "$RELEASE_SHA" + ! gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1 + + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Verify checked out immutable SHA + shell: bash + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify native runner architecture + shell: bash + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + + - name: Audit committed dependency resolution + shell: bash + run: | + npm run audit:runtime + npm run desktop:audit:packaging + + - name: Install locked dependencies + run: npm ci + + - name: Provision pinned WiX 3.14.1 binaries for Windows ARM64 + if: matrix.platform == 'win32' && matrix.arch == 'arm64' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $downloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip' + $expectedSha256 = '6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31' + $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' + $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' + if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { + throw 'Pinned ARM64 WiX runner-temp paths are not fresh' + } + Invoke-WebRequest -Uri $downloadUrl -OutFile $archive -MaximumRedirection 5 -TimeoutSec 120 + $archiveItem = Get-Item -LiteralPath $archive -Force + if ($archiveItem.PSIsContainer -or ($archiveItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ` + -or $archiveItem.Length -le 0 -or $archiveItem.Length -gt 64MB) { + throw 'Pinned ARM64 WiX archive is not a regular file' + } + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedSha256) { + throw 'Pinned ARM64 WiX archive digest mismatch' + } + New-Item -ItemType Directory -Path $wixDirectory | Out-Null + Expand-Archive -LiteralPath $archive -DestinationPath $wixDirectory + Remove-Item -LiteralPath $archive -Force + "PROPR_DESKTOP_WIX_DIRECTORY=$wixDirectory" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Probe canonical WiX 3.14.1 compiler + if: matrix.platform == 'win32' + shell: pwsh + run: node apps/desktop/scripts/build-windows-machine-installer.mjs probe '${{ matrix.arch }}' $env:PROPR_DESKTOP_WIX_DIRECTORY + + - name: Install native Linux package tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + + - name: Configure required macOS signing and notarization + if: matrix.platform == 'darwin' + shell: bash + env: + CERTIFICATE_P12_BASE64: ${{ secrets.PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.PROPR_DESKTOP_APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.PROPR_DESKTOP_APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + for name in CERTIFICATE_P12_BASE64 CERTIFICATE_PASSWORD APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID UPDATE_MAC_SIGNING_IDENTITY UPDATE_MAC_TEAM_ID; do + test -n "${!name}" || { echo "Required production macOS field $name is missing" >&2; exit 1; } + done + certificate="$RUNNER_TEMP/propr-desktop-signing.p12" + keychain="$RUNNER_TEMP/propr-desktop-signing.keychain-db" + keychain_password="$(uuidgen)" + printf '%s' "$CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + api_key="$RUNNER_TEMP/AuthKey_$APPLE_API_KEY_ID.p8" + printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$api_key" + echo "PROPR_DESKTOP_MAC_SIGNING_IDENTITY=$UPDATE_MAC_SIGNING_IDENTITY" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_KEY_FILE=$api_key" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_ISSUER_ID=$APPLE_API_ISSUER_ID" >> "$GITHUB_ENV" + echo "DESKTOP_PLATFORM_CODE_SIGNED=1" >> "$GITHUB_ENV" + + - name: Configure required Windows signing + if: matrix.platform == 'win32' + shell: pwsh + env: + CERTIFICATE_PFX_BASE64: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $values = @{ + CERTIFICATE_PFX_BASE64 = $env:CERTIFICATE_PFX_BASE64 + CERTIFICATE_PASSWORD = $env:CERTIFICATE_PASSWORD + UPDATE_WINDOWS_SIGNING_IDENTITY = $env:UPDATE_WINDOWS_SIGNING_IDENTITY + UPDATE_WINDOWS_SIGNER_PINS = $env:UPDATE_WINDOWS_SIGNER_PINS + } + foreach ($entry in $values.GetEnumerator()) { if (!$entry.Value) { throw "Required production Windows field $($entry.Key) is missing" } } + $pins = $env:UPDATE_WINDOWS_SIGNER_PINS -split ',' + if ($pins.Count -gt 16 -or (($pins | Sort-Object -CaseSensitive -Unique) -join ',') -cne $env:UPDATE_WINDOWS_SIGNER_PINS) { + throw 'Windows signer pin allowlist is not sorted and unique' + } + foreach ($pin in $pins) { + if ($pin -cnotmatch '^(certificate|spki)-sha256:[a-f0-9]{64}$') { throw 'Windows signer pin is not canonical' } + } + $certificate = Join-Path $env:RUNNER_TEMP 'propr-desktop-signing.pfx' + [IO.File]::WriteAllBytes($certificate, [Convert]::FromBase64String($env:CERTIFICATE_PFX_BASE64)) + $signingCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificate, + $env:CERTIFICATE_PASSWORD, + [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + $codeSigningEku = @($signingCertificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | + ForEach-Object { $_.EnhancedKeyUsages } | ForEach-Object { $_.Value }) -ccontains '1.3.6.1.5.5.7.3.3' + if ($signingCertificate.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY + -or [DateTime]::Now -lt $signingCertificate.NotBefore + -or [DateTime]::Now -gt $signingCertificate.NotAfter + -or !$codeSigningEku) { + throw 'Windows signing certificate publisher, validity, or code-signing EKU is invalid' + } + $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() + $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online + $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::EntireChain + $chain.ChainPolicy.VerificationFlags = [Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag + $chain.ChainPolicy.UrlRetrievalTimeout = [TimeSpan]::FromSeconds(15) + if (!$chain.Build($signingCertificate)) { throw 'Windows signing certificate chain or revocation policy is invalid' } + $certificateBase64 = [Convert]::ToBase64String($signingCertificate.RawData) + $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json + $actualPins = @("certificate-sha256:$($fingerprints.certificateSha256)", "spki-sha256:$($fingerprints.spkiSha256)") + if (@($actualPins | Where-Object { $pins -ccontains $_ }).Count -eq 0) { + throw 'Windows signing certificate does not match the configured cryptographic pin policy' + } + "PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE=$certificate" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD=$env:CERTIFICATE_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_WINDOWS_SIGNER_PINS=$env:UPDATE_WINDOWS_SIGNER_PINS" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($fingerprints.certificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($fingerprints.spkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + 'DESKTOP_PLATFORM_CODE_SIGNED=1' | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Require macOS signed-update runtime configuration + if: matrix.platform == 'darwin' + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + test -n "$UPDATE_PUBLIC_KEY" || { echo 'Required Ed25519 update public key is missing' >&2; exit 1; } + test -n "$UPDATE_MANIFEST_URL" || { echo 'Required update manifest URL is missing' >&2; exit 1; } + test "${DESKTOP_PLATFORM_CODE_SIGNED:-}" = 1 || { echo 'Production updates require a code-signed build' >&2; exit 1; } + identity="$UPDATE_MAC_TEAM_ID" + test -n "$identity" || { echo 'Required native signing identity is missing' >&2; exit 1; } + echo "PROPR_DESKTOP_ENABLE_UPDATES=1" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_CODE_SIGNED=1" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_MANIFEST_URL=$UPDATE_MANIFEST_URL" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_PUBLIC_KEY=$UPDATE_PUBLIC_KEY" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" + + - name: Package signed production app from clean checkout + shell: bash + run: | + test ! -e packages/shared/dist + test ! -e packages/client/dist + test ! -e apps/desktop/out + npm run desktop:package + + - name: Assert signed Windows MVP package excludes update authority + if: matrix.platform == 'win32' + shell: bash + run: node apps/desktop/scripts/assert-windows-mvp-package.mjs "$(pwd)/apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}" + + - name: Typecheck and test production desktop runtime + shell: bash + run: | + npm run desktop:typecheck + npm run desktop:test + + - name: Make Linux production packages + if: matrix.platform == 'linux' + shell: bash + run: PROPR_DESKTOP_ENABLE_DEB=1 PROPR_DESKTOP_ENABLE_RPM=1 npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make and notarize macOS production packages + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + npm run make:dmg -w @propr/desktop -- --arch=${{ matrix.arch }} + dmg="$(find apps/desktop/out/make -type f -name '*.dmg' -print -quit)" + xcrun notarytool submit "$dmg" \ + --key "$PROPR_DESKTOP_APPLE_API_KEY_FILE" \ + --key-id "$PROPR_DESKTOP_APPLE_API_KEY_ID" \ + --issuer "$PROPR_DESKTOP_APPLE_API_ISSUER_ID" \ + --wait + xcrun stapler staple "$dmg" + xcrun stapler validate "$dmg" + node apps/desktop/scripts/verify-darwin-image.mjs "$dmg" + + - name: Make signed Windows production installer + if: matrix.platform == 'win32' + shell: pwsh + run: npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Install and exercise signed ordinary-user Windows application + if: matrix.platform == 'win32' + shell: pwsh + run: | + $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` + -Installer $installers[0].FullName ` + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId + "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Always clean signed Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + + - name: Launch packaged Linux application + if: matrix.platform == 'linux' + shell: bash + run: | + sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run desktop:smoke + ' bash "$keyring_root" + + - name: Launch signed packaged Windows application and exercise MVP desktop flows + if: matrix.platform == 'win32' + shell: bash + run: npm run desktop:smoke + + - name: Inspect signed and notarized macOS application + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run desktop:smoke:inspect + application="apps/desktop/out/propr-desktop-darwin-${{ matrix.arch }}/propr-desktop.app" + codesign --verify --deep --strict --verbose=2 "$application" + spctl --assess --type execute --verbose=4 "$application" + signature_details="$(codesign -dv --verbose=4 "$application" 2>&1)" + actual_authority="$(printf '%s\n' "$signature_details" | sed -n 's/^Authority=//p' | head -1)" + actual_team_id="$(printf '%s\n' "$signature_details" | sed -n 's/^TeamIdentifier=//p' | head -1)" + designated_requirement="$(codesign -d -r- "$application" 2>&1 | sed -n 's/^designated =>/designated =>/p')" + test "$actual_authority" = "$UPDATE_MAC_SIGNING_IDENTITY" + test "$actual_team_id" = "$UPDATE_MAC_TEAM_ID" + test -n "$designated_requirement" + echo "PROPR_DESKTOP_ACTUAL_SIGNER_TYPE=apple-team-id" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY=$actual_team_id" >> "$GITHUB_ENV" + { + echo 'PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT<> "$GITHUB_ENV" + + - name: Inspect signed Windows application and installer payload + if: matrix.platform == 'win32' + shell: pwsh + run: | + npm run desktop:smoke:inspect + $machineInstallers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + $appExecutable = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/propr-desktop.exe" + node apps/desktop/scripts/assert-windows-mvp-package.mjs (Resolve-Path "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}").Path + if ($machineInstallers.Count -ne 1) { throw 'Canonical Windows MSI is missing or ambiguous' } + $machineInstaller = $machineInstallers[0] + node apps/desktop/scripts/release-architecture.mjs inspect ` + --path $machineInstaller.FullName ` + --kind msi ` + --platform win32 ` + --arch '${{ matrix.arch }}' + function Get-ValidatedSignerEvidence([string]$Path) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne 'Valid' -or !$signature.SignerCertificate -or !$signature.TimeStamperCertificate) { + throw "Windows Authenticode chain or timestamp status is invalid for $Path" + } + $certificateBase64 = [Convert]::ToBase64String($signature.SignerCertificate.RawData) + $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json + [PSCustomObject]@{ + Subject = $signature.SignerCertificate.Subject + CertificateSha256 = $fingerprints.certificateSha256 + SpkiSha256 = $fingerprints.spkiSha256 + } + } + $evidence = @( + Get-ValidatedSignerEvidence $machineInstaller.FullName + Get-ValidatedSignerEvidence $appExecutable + ) + foreach ($signer in $evidence) { + if ($signer.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY) { throw 'Windows Authenticode signer does not match the configured exact subject' } + } + $distinctSigners = @($evidence | ForEach-Object { $_ | ConvertTo-Json -Compress } | Sort-Object -Unique) + if ($distinctSigners.Count -ne 1) { throw 'Windows artifacts have mixed Authenticode signers' } + $actualPins = @( + "certificate-sha256:$($evidence[0].CertificateSha256)" + "spki-sha256:$($evidence[0].SpkiSha256)" + ) + $allowedPins = @($env:UPDATE_WINDOWS_SIGNER_PINS -split ',') + if (@($actualPins | Where-Object { $allowedPins -ccontains $_ }).Count -eq 0) { + throw 'Windows Authenticode signer does not match the configured build pin' + } + "PROPR_DESKTOP_ACTUAL_SIGNER_TYPE=authenticode-subject" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY=$($evidence[0].Subject)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($evidence[0].CertificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($evidence[0].SpkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Inspect native Linux production packages + if: matrix.platform == 'linux' + shell: bash + run: | + dpkg-deb --info "$(find apps/desktop/out/make -type f -name '*.deb' -print -quit)" >/dev/null + rpm -qip "$(find apps/desktop/out/make -type f -name '*.rpm' -print -quit)" >/dev/null + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + + - name: Prove private-snapshot native DMG mounting is available + if: matrix.platform == 'darwin' + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ + --version "$PROPR_DESKTOP_VERSION" \ + --make-directory apps/desktop/out/make \ + --arch "${{ matrix.arch }}" + + - name: Stage architecture and signer verified production artifacts with native DMG mount evidence + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs stage \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --make-directory apps/desktop/out/make \ + --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + + - name: Upload trusted production target + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-production-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} + path: desktop-release-${{ matrix.platform }}-${{ matrix.arch }} + if-no-files-found: error + retention-days: 14 + + - name: Clean pinned Windows ARM64 WiX binaries + if: always() && matrix.platform == 'win32' && matrix.arch == 'arm64' + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' + $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' + Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $wixDirectory -Recurse -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { + throw 'Pinned ARM64 WiX cleanup failed' + } + + release-finalize: + name: Revalidate production architectures and finalize checksums + if: needs.preflight.result == 'success' + needs: [preflight, release-package] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Install cross-format inspection tools + run: | + sudo apt-get update + sudo apt-get install --yes cpio msitools p7zip-full rpm + test -x /usr/bin/msiextract + + - name: Download all trusted native artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: propr-desktop-production-*-${{ github.run_id }} + path: desktop-release-fragments + + - name: Verify architecture, signer evidence, matrix completeness, and checksums + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs finalize \ + --version "$RELEASE_VERSION" \ + --input desktop-release-fragments \ + --output desktop-release-validated + (cd desktop-release-validated && sha256sum --check SHA256SUMS) + + - name: Upload complete validated release set + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-validated-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-validated + if-no-files-found: error + retention-days: 30 + + sign: + name: Sign trusted update metadata + if: needs.preflight.result == 'success' + needs: [preflight, release-finalize] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: desktop-release + permissions: + contents: read + steps: + - name: Revalidate immutable tag before secret use + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + run: | + set -euo pipefail + test "$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG" --jq .object.sha)" = "$TAG_OBJECT_SHA" + test "$(gh api "repos/${{ github.repository }}/commits/$RELEASE_TAG" --jq .sha)" = "$RELEASE_SHA" + ! gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1 + + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Download validated release set + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: propr-desktop-validated-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-validated + + - name: Sign cryptographically bound update metadata + env: + PROPR_DESKTOP_UPDATE_PRIVATE_KEY: ${{ secrets.PROPR_DESKTOP_UPDATE_PRIVATE_KEY }} + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} + PROPR_DESKTOP_UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} + PROPR_DESKTOP_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} + PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} + PROPR_DESKTOP_DARWIN_X64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_X64_FEED_URL }} + PROPR_DESKTOP_DARWIN_ARM64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_ARM64_FEED_URL }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs sign \ + --version "$RELEASE_VERSION" \ + --input desktop-release-validated \ + --output desktop-release-signed + test -s desktop-release-signed/desktop-release.json.sig + (cd desktop-release-signed && sha256sum --check SHA256SUMS) + + - name: Upload signed release set + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-signed-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-signed + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish new immutable desktop release + if: needs.preflight.result == 'success' + needs: [preflight, sign] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout exact approved publication helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Download signed release set + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: propr-desktop-signed-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-final + + - name: Publish only the preflight-approved tag and signed bytes + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + RELEASE_DIRECTORY: desktop-release-final + run: | + set -euo pipefail + test -s desktop-release-final/desktop-release.json.sig + node apps/desktop/scripts/release-publish.mjs diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index e48d6b813..0723b6f06 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -34,6 +34,7 @@ jobs: - name: Build and test the CLI Agent Skill run: | npm run build -w @propr/shared + npm run build -w @propr/local-setup npm run typecheck -w @propr/cli npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ @@ -46,6 +47,8 @@ jobs: test -f packages/cli/dist/native/prebuilds/darwin-x64/directory-operations.node test -f packages/cli/dist/native/prebuilds/linux-arm64/directory-operations.node test -f packages/cli/dist/native/prebuilds/linux-x64/directory-operations.node + test -f packages/cli/dist/native/prebuilds/darwin-arm64/connect-authority-broker + test -f packages/cli/dist/native/prebuilds/darwin-x64/connect-authority-broker cli-agent-skill-glibc-231: name: CLI Agent Skill (Linux x64, glibc 2.31, Node 22) @@ -82,6 +85,7 @@ jobs: runuser --user node -- env HOME=/home/node bash -euo pipefail <<'NON_ROOT' test "$(node -p 'process.geteuid()')" -ne 0 npm run build -w @propr/shared + npm run build -w @propr/local-setup npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ packages/cli/src/agentSkill.forceRace.test.ts \ @@ -111,6 +115,7 @@ jobs: test "$(node -p process.platform)" = darwin test "$(node -p process.arch)" = arm64 npm run build -w @propr/shared + npm run build -w @propr/local-setup npm run typecheck -w @propr/cli npx tsx --experimental-test-module-mocks --test \ packages/cli/src/agentSkill.test.ts \ @@ -143,6 +148,135 @@ jobs: test ! -e "$skill_fixture/home/.gemini/antigravity-cli/skills/propr" test ! -e "$skill_fixture/xdg/opencode/skills/propr" + windows-connect-discovery: + name: Windows Connect Discovery (ordinary user, Node 22) + runs-on: windows-2025 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build discovery workspaces + shell: bash + run: | + npm run build -w @propr/shared + npm run build -w @propr/core + npm run build -w @propr/local-setup + npm run typecheck -w @propr/cli + npm run build -w @propr/cli + + - name: Run CLI and API discovery as a non-administrator + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $userName = 'propr-discovery' + $plainPassword = [Guid]::NewGuid().ToString('N') + 'aA1!' + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + New-LocalUser -Name $userName -Password $securePassword -PasswordNeverExpires | Out-Null + $administrators = Get-LocalGroupMember -Group 'Administrators' | ForEach-Object { $_.Name } + if ($administrators -contains "$env:COMPUTERNAME\$userName") { throw 'discovery test user is an administrator' } + $credential = [PSCredential]::new("$env:COMPUTERNAME\$userName", $securePassword) + $fixture = Join-Path $env:SystemDrive ("propr-discovery-" + [Guid]::NewGuid().ToString('N')) + $stackRoot = Join-Path $fixture 'stack-private-path-SENTINEL' + $dataRoot = Join-Path $stackRoot 'data' + $envFile = Join-Path $stackRoot '.env' + $identityFile = Join-Path $dataRoot 'public-instance-identity.json' + $fakePowerShell = Join-Path $fixture 'System32\WindowsPowerShell\v1.0\powershell.exe' + $stdout = Join-Path $env:RUNNER_TEMP 'propr-discovery.stdout' + $stderr = Join-Path $env:RUNNER_TEMP 'propr-discovery.stderr' + try { + New-Item -ItemType Directory -Path $fixture,$stackRoot,$dataRoot | Out-Null + $utf8 = [Text.UTF8Encoding]::new($false) + [IO.File]::WriteAllText($envFile, "PROPR_STACK=authorized`n", $utf8) + [IO.File]::WriteAllText($identityFile, "{`"schemaVersion`":1,`"publicInstanceIdentity`":`"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`"}`n", $utf8) + New-Item -ItemType Directory -Path (Split-Path -Parent $fakePowerShell) -Force | Out-Null + [IO.File]::WriteAllBytes($fakePowerShell, [byte[]]@(0x4d, 0x5a)) + $userIdentity = [Security.Principal.NTAccount]::new("$env:COMPUTERNAME\$userName") + $systemIdentity = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $adminIdentity = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + function Set-DiscoveryAcl([string]$Path, [bool]$Directory, [Security.Principal.IdentityReference]$Owner) { + $acl = if ($Directory) { [Security.AccessControl.DirectorySecurity]::new() } else { [Security.AccessControl.FileSecurity]::new() } + $acl.SetOwner($Owner) + $acl.SetAccessRuleProtection($true, $false) + foreach ($identity in @($userIdentity, $systemIdentity, $adminIdentity)) { + $rights = [Security.AccessControl.FileSystemRights]::FullControl + $accessType = [Security.AccessControl.AccessControlType]::Allow + $rule = if ($Directory) { + $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit + $propagation = [Security.AccessControl.PropagationFlags]::None + [Security.AccessControl.FileSystemAccessRule]::new($identity, $rights, $inheritance, $propagation, $accessType) + } else { + [Security.AccessControl.FileSystemAccessRule]::new($identity, $rights, $accessType) + } + $acl.AddAccessRule($rule) | Out-Null + } + Set-Acl -LiteralPath $Path -AclObject $acl + } + Set-DiscoveryAcl $fixture $true $adminIdentity + Set-DiscoveryAcl $stackRoot $true $userIdentity + Set-DiscoveryAcl $dataRoot $true $userIdentity + Set-DiscoveryAcl $envFile $false $userIdentity + Set-DiscoveryAcl $identityFile $false $userIdentity + $node = (Get-Command node.exe).Source + $process = Start-Process -FilePath $node -ArgumentList @('scripts/verify-windows-standard-user-connect.mjs', $userName, $fixture) -WorkingDirectory $env:GITHUB_WORKSPACE -Credential $credential -LoadUserProfile -Wait -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Get-Content -LiteralPath $stdout + if ($process.ExitCode -ne 0) { + Get-Content -LiteralPath $stderr + throw "ordinary-user discovery proof exited $($process.ExitCode)" + } + if ((Get-Content -Raw -LiteralPath $stderr).Length -ne 0) { throw 'ordinary-user discovery proof wrote stderr' } + } finally { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stdout,$stderr -Force -ErrorAction SilentlyContinue + } + + connect-authority-darwin: + name: Connect Discovery and Darwin ACL (Node 22) + runs-on: macos-15 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build Connect discovery dependencies + shell: bash + run: | + set -euo pipefail + npm run build -w @propr/shared + npm run build -w @propr/core + npm run build -w @propr/local-setup + npm run typecheck -w @propr/cli + npm run build -w @propr/cli + + - name: Run platform-safe focused Connect suites + shell: bash + run: node scripts/verify-platform-safe-connect.mjs + + - name: Require complete native Connect authority proof + shell: bash + run: node scripts/verify-native-connect-authority.mjs + validate: name: Validate Changes runs-on: ubuntu-latest @@ -281,10 +415,11 @@ jobs: echo echo "--- Hosted tunnel regression tests ---" echo "Running hosted tunnel regression tests..." - # Build @propr/shared first: the tsx and UI tests below import from it, - # so a stale or missing dist in a clean checkout would fail or use old - # output. Build once, up front, before anything that depends on it. + # Build workspace dependencies first: the tsx and UI tests below import + # from them, so a stale or missing dist in a clean checkout would fail + # or use old output. Build once, up front, before their consumers. npm run build -w @propr/shared + npm run build -w @propr/local-setup PROPR_DEMO_MODE=true npx tsx --test \ test/orchestratorConfig.test.mjs \ packages/cli/src/commands/setup/engine.test.ts \ @@ -328,6 +463,7 @@ jobs: - 'packages/shared/**' ui: - 'propr-ui/**' + - 'packages/client/**' - 'packages/shared/**' docs: - 'docs/**' @@ -445,6 +581,18 @@ jobs: EXIT_CODE=1 fi + if [ $UI_FAILED -eq 0 ]; then + CLIENT_OUTPUT=$(npm run typecheck -w @propr/client 2>&1 && npm test -w @propr/client 2>&1 && npm run build -w @propr/client 2>&1) || { + echo "❌ Client Package Validation FAILED (UI transport dependency)" >> build_log.txt + echo "$CLIENT_OUTPUT" >> build_log.txt + UI_FAILED=1 + EXIT_CODE=1 + } + if [ $UI_FAILED -eq 0 ]; then + echo "✅ Client Package validation passed" >> build_log.txt + fi + fi + if [ $UI_FAILED -eq 0 ]; then TYPECHECK_OUTPUT=$(npm run typecheck -w propr-ui 2>&1) || { echo "❌ UI Typecheck FAILED" >> build_log.txt @@ -708,8 +856,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build shared package - run: npm run build --workspace=@propr/shared + - name: Build workspace dependencies + run: | + npm run build --workspace=@propr/shared + npm run build --workspace=@propr/local-setup - name: Parse init JSON output run: npx tsx --test packages/cli/src/commands/initCommands.test.ts diff --git a/.github/workflows/pr-test-on-label.yml b/.github/workflows/pr-test-on-label.yml index 6c6fa4165..c9d2684e4 100644 --- a/.github/workflows/pr-test-on-label.yml +++ b/.github/workflows/pr-test-on-label.yml @@ -48,7 +48,12 @@ jobs: - name: Build workspace packages id: build - run: npm run test:prepare + run: | + test ! -e packages/shared/dist + test ! -e packages/client/dist + npm run test:prepare + test -f packages/shared/dist/index.js + test -f packages/client/dist/index.js - name: Validate docs site id: docs diff --git a/.gitignore b/.gitignore index 57baa45eb..5c9139815 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ apps/release-site-videos/ # Standalone publish staging (scripts/build-publish.mjs) dist-publish/ + +# Electron Forge build and package output +apps/desktop/.vite/ +apps/desktop/out/ diff --git a/Dockerfile b/Dockerfile index 569793640..f6becd490 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN apt-get update && apt-get install -y \ # Copy package files (including workspace packages) COPY package*.json ./ COPY packages/shared/package*.json ./packages/shared/ +COPY packages/local-setup/package*.json ./packages/local-setup/ COPY packages/core/package*.json ./packages/core/ COPY packages/api/package*.json ./packages/api/ @@ -44,6 +45,9 @@ COPY . . # Build shared package first (required for @propr/shared imports) RUN cd packages/shared && npm run build +# Build Node-local shared storage helpers used by the API and CLI. +RUN cd packages/local-setup && npm run build + # Build core package (required for @propr/core imports) RUN cd packages/core && npm run build diff --git a/Dockerfile.node b/Dockerfile.node index f56adf480..39736c4f5 100644 --- a/Dockerfile.node +++ b/Dockerfile.node @@ -24,6 +24,7 @@ WORKDIR /usr/src/app # Copy package files (including workspace packages) COPY package*.json ./ COPY packages/shared/package*.json ./packages/shared/ +COPY packages/local-setup/package*.json ./packages/local-setup/ COPY packages/core/package*.json ./packages/core/ COPY packages/api/package*.json ./packages/api/ @@ -36,6 +37,9 @@ COPY . . # Build shared package first (required for @propr/core imports) RUN cd packages/shared && npm run build +# Build Node-local shared storage helpers used by the API and CLI. +RUN cd packages/local-setup && npm run build + # Build core package (required for @propr/core imports) RUN cd packages/core && npm run build diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 000000000..c68462b50 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,210 @@ +# ProPR Desktop + +This workspace packages the existing `propr-ui` React source as a sandboxed Electron renderer. The desktop entry is +`propr-ui/src/desktop.tsx`; the normal web entry, service worker, CLI, API, and self-hosted deployment remain unchanged. + +## Commands + +Run these from the repository root: + +```sh +npm run desktop:dev +npm run desktop:typecheck +npm run desktop:test +npm run desktop:package +npm run desktop:smoke # Run under xvfb-run on a headless Linux host. +npm run desktop:make +npm run desktop:audit +# On Linux hosts with the corresponding native packaging tools installed: +npm run make:deb -w @propr/desktop +npm run make:rpm -w @propr/desktop +# macOS only, after packaging the selected architecture: +npm run make:dmg -w @propr/desktop -- --arch=arm64 +``` + +Desktop development, typecheck, package, and make commands build required renderer workspace dependencies through the +desktop workspace lifecycle, in dependency order (`@propr/shared`, `@propr/local-setup`, `@propr/cli`, then +`@propr/client`). They do not depend on previously generated workspace `dist` directories. + +Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load +the generated renderer from the application ASAR through an app-owned protocol. + +The packaged-binary smoke test verifies the hardened fuse states and launches artifacts without a sandbox-disabling +flag. Its preferred window is 1280x820 with an 880x620 minimum, sourced from one runtime/smoke sizing manifest. The +runtime selects the cursor-relevant display with a primary-display fallback and clamps both sizes to that display's +work area before native construction. Native evidence requires the actual window to equal that clamped size and +derives the viewport from the actual native content bounds. The packaged smoke also constructs a hidden 800x560 +reduced-work-area window and verifies its real native bounds and clamped minimums. From the packaged custom-protocol +renderer it drives preload IPC, activation-scoped REST and Socket.IO upgrades through Electron session interception, +scope rotation, and same-ID origin editing. It also checks the real welcome-card and connection-control bounds, cookie +omission, both-origin storage cleanup, stale-scope fencing, renderer/main secret custody, uncaught exceptions, and a +clean exit. The child receives only fixed smoke triggers, private profile/temp paths, and strictly validated platform +launch inputs; it never broadly inherits the parent CI environment or `PATH`. `desktop:smoke:inspect` performs +executable and fuse inspection without launching a window. Release CI launches both Linux architectures under Xvfb, +inspects macOS and Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/MSI packages, and validates +configured OS signatures. + +Darwin packaged Connect acceptance first inspects the normal unsigned package, then generates a one-run self-signed +CA:false code-signing leaf in an isolated default keychain and signs only that smoke artifact. The signature uses an +explicit certificate-bound designated requirement that is verified before the pair process and again after the +reprobe process. Chromium creates and reopens its real Safe Storage key in the same disposable keychain; the harness +does not pre-seed or widen access to that item. A signal-aware exit trap restores the runner's original keychain list +and default, deletes the disposable keychain, and removes all temporary signing material. + +The first-release Windows MVP packages only the normal desktop application. Native self-update installation authority +is deferred to issue #2000: no broker, bootstrap, launcher, service, or authority custom action is built, copied into +`resources`, or installed by the MSI. Both Windows architectures remain mandatory release targets, and package/MSI +inspection fails if any deferred authority resource appears. + +`desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail +the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release +CI runs both checks directly from the committed lockfile before installing or executing the packaging toolchain. + +## Security boundary + +The renderer has no Node.js integration and receives only the typed `window.proprDesktop` bridge. It exposes metadata, +validated profiles, status-only pairing/probe/invalidation operations, lifecycle placeholders, and validated deep-link +events. Pairing, browser approval, credential persistence, authenticated probes, and revocation run in Electron main. +The bridge never exposes a credential value, shell, command runner, arbitrary IPC call, or filesystem path/API. + +Profile metadata is stored in an app-owned, permission-restricted JSON file. Credential values are encrypted with +Electron `safeStorage` before they are written separately. If OS encryption is unavailable—or Linux selects the +`basic_text` backend—the app reports that state and refuses to persist credentials; there is no plaintext +fallback. Profiles remain usable because they contain only a display label and validated API endpoint. + +Opaque instance tokens and the strict-discovery public identity are bound to profile ID, normalized origin, and +credential generation in encrypted main-process storage. The renderer cannot provide or override the identity. +Launch, profile switch, pairing, revocation, and every Socket.IO reconnect perform credential-free strict discovery; +an absent, malformed, or changed identity sends no stored bearer and requires a fresh pairing generation. Electron's +session request boundary strips renderer-supplied Authorization and Cookie headers from every HTTP(S) and WS(S) +request, including inactive or mismatched profile origins, then injects the active bearer only for matching REST and +Socket.IO requests. Set-Cookie is stripped from remote responses, so the packaged renderer has no parallel cookie +identity. Tokens never enter renderer JavaScript, URLs, logs, localStorage, sessionStorage, or profile metadata. +Switching named profiles clears renderer and instance-origin state. Removing or changing a paired profile first +attempts current-token revocation at the old bound origin, then removes the credential. + +`propr://connect` and `propr://open` are the only accepted deep-link actions. A single-instance lock routes later +activations to the existing window. Local lifecycle methods intentionally return `not-implemented`; this scaffold does +not download, install, start, or execute ProPR runtime components. + +## Desktop distributables and releases + +Desktop releases have their own `desktop-v..` tags. They do not use or require the monorepo's +`v` tag. `PROPR_DESKTOP_VERSION` propagates the tag version into the packaged application, renderer, native +metadata, Linux packages, protected machine MSI, artifact names, and release manifest without changing the monorepo +package versions. + +The native GitHub Actions matrix produces these assets for both x64 and arm64: + +| Platform | Native runner | Direct-distribution artifacts | +| --- | --- | --- | +| Linux | `ubuntu-24.04`, `ubuntu-24.04-arm` | DEB, RPM, ZIP | +| macOS | `macos-15-intel`, `macos-15` | DMG, ZIP | +| Windows | `windows-2025`, `windows-11-arm` | signed per-machine Program Files MSI | + +Every matrix job stages DEB/RPM/ZIP/DMG names as `ProPR-Desktop---.` and retains +`ProPR-Desktop--windows--Machine-Setup.msi` for Windows. The final job rejects +missing targets or changed fragment checksums, emits `SHA256SUMS` and `desktop-release.json`, and attaches the complete +set to the matching GitHub release. Production publication is triggered only by a new, non-forced +`desktop-v..` tag push; there is no manual dispatch path. A secretless preflight must succeed before +any job can request the protected release environment or receive release secrets. Normal local packages are unsigned +and have updates disabled: + +```sh +npm ci +npm run desktop:typecheck +npm run desktop:test +npm run desktop:package +xvfb-run --auto-servernum npm run desktop:smoke # Linux + +# Full unsigned Linux release artifacts (requires dpkg-deb and rpmbuild/rpm): +PROPR_DESKTOP_VERSION=1.2.3 \ +PROPR_DESKTOP_ENABLE_DEB=1 \ +PROPR_DESKTOP_ENABLE_RPM=1 \ +npm run make -w @propr/desktop -- --arch="$(node -p process.arch)" +``` + +### CI preflight, signing, and notarization configuration + +Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with +exactly repository **Administration: read**, **Contents: read**, and **Environments: read** (GitHub adds Metadata: read +implicitly), with no write permission and no Actions, Deployments, Releases, or other repository permission. Store its +private key only in a separate approval-protected `desktop-release-preflight` environment: + +- Variable `PROPR_DESKTOP_PREFLIGHT_APP_ID`: the least-privilege preflight App ID. +- Secret `PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY`: that App's private key. + +Configure `desktop-release-preflight` with at least one required reviewer, custom deployment policies enabled, +protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The workflow +uses a SHA-pinned token action to mint a short-lived installation token explicitly requesting only Administration read, +Contents read, and Environments read; workflow regression tests pin those exact inputs and reject any write or Actions +permission. The App installation itself must have the same exact least-privilege permission set. Preflight fails closed +when the ruleset API does not return `bypass_actors`. Pull requests do not schedule this job, and a nonmatching or +unreviewed tag cannot enter the environment or obtain the App credential. The preflight environment must contain no +signing, notarization, update-signing, release-publication, or production deployment secret. + +Signing material is read only from the distinct approval-protected `desktop-release` GitHub environment and written +to runner-temporary files/keychains. Every value below is mandatory for a production `desktop-v*` tag; unsigned and +partially signed production releases fail before publication. Pull-request package validation and the preflight +environment receive none of these secrets and explicitly check that release-secret environment variables are absent. + +GitHub Actions secrets: + +- `PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64`: base64 of the Developer ID Application `.p12`. +- `PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD`: password for that `.p12`. +- `PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64`: base64 of the App Store Connect API `.p8` key. +- `PROPR_DESKTOP_APPLE_API_KEY_ID`: App Store Connect API key ID. +- `PROPR_DESKTOP_APPLE_API_ISSUER_ID`: App Store Connect issuer UUID. +- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64`: base64 of the Authenticode `.pfx`. +- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD`: password for that `.pfx`. +- `PROPR_DESKTOP_UPDATE_PRIVATE_KEY`: base64 Ed25519 PKCS#8 DER key used only to sign update-channel metadata. + +GitHub Actions variables (public configuration, not secrets): + +- `PROPR_DESKTOP_MAC_SIGNING_IDENTITY`: exact Developer ID Application identity. +- `PROPR_DESKTOP_MAC_TEAM_ID`: exact Team ID embedded in signed macOS update builds and verified from produced apps. +- `PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY`: exact Authenticode certificate subject expected by installed builds. +- `PROPR_DESKTOP_WINDOWS_SIGNER_PINS`: sorted, unique comma-separated allowlist of one or more + `certificate-sha256:<64 lowercase hex>` or `spki-sha256:<64 lowercase hex>` fingerprints. Production Windows + packaging fails closed when this public operator pin is absent, malformed, or does not match the signing key. +- `PROPR_DESKTOP_UPDATE_PUBLIC_KEY`: base64 Ed25519 SPKI DER public key matching the update private key. +- `PROPR_DESKTOP_UPDATE_MANIFEST_URL`: stable HTTPS URL from which clients fetch `desktop-release.json`; the detached + signature must be published beside it as `desktop-release.json.sig`. +- `PROPR_DESKTOP_DARWIN_X64_FEED_URL`, `PROPR_DESKTOP_DARWIN_ARM64_FEED_URL`: macOS JSON feed URLs. + +Generate the independent update-channel keys once and store only the public output as a repository variable: + +```sh +openssl genpkey -algorithm ED25519 -outform DER -out desktop-update-private.der +openssl pkey -inform DER -in desktop-update-private.der -pubout -outform DER -out desktop-update-public.der +base64 < desktop-update-private.der # secret: PROPR_DESKTOP_UPDATE_PRIVATE_KEY +base64 < desktop-update-public.der # variable: PROPR_DESKTOP_UPDATE_PUBLIC_KEY +``` + +Do not commit either key file. The private key is available only to the approval-protected `desktop-release` +environment. Configure that environment with at least one required reviewer, custom deployment policies enabled, +protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The repository's +default branch must be protected `main`. It must also have an active tag-targeting ruleset whose sole include is +`refs/tags/desktop-v*`, whose exclude and bypass-actor lists are empty, and whose rules block both tag updates and tag +deletions. + +For each new, non-forced `desktop-v..` tag push, the read-only preflight verifies both protected +environments and the repository prerequisites through the GitHub API, proves the exact tag commit is reachable from +`main`, rejects an existing release, and rechecks the tag and immutability ruleset for changes. The active tag ruleset +must match exactly `refs/tags/desktop-v*`, have no exclusions or bypass actors, and block update and deletion. Pull- +request finalization produces unsigned validation metadata; trusted signing jobs depend on preflight, check out its +immutable SHA, revalidate the tag before publication, and fail closed if any signing, notarization, or signed-update +field is missing. A release operator must publish the exact signed manifest/signature, generated macOS feeds, and +bound macOS packages to their configured HTTPS URLs. The manifest URL must not contain a query, so its companion is +always the documented pathname plus `.sig`. + +Linux never checks for native updates. macOS remains a signed, check-only channel: it verifies the Ed25519 manifest, +exact target/version/feed bytes, package URL/size/SHA-256, and actual Team ID/designated requirement. Windows self-update +is explicitly `unsupported` for this release. The Windows build embeds no update URL or key even when update environment +variables are present; its public check and apply boundaries return `unsupported` before any network, cache, artifact, +signer, install-authority, or apply-capability call, and signed release metadata advertises no Windows feed. + +Windows still publishes exactly one timestamped Authenticode-signed machine-wide MSI for each x64 and ARM64 target, +with the packaged application's signer and architecture inspected before staging. Per-user Squirrel Setup/NUPKG +artifacts remain unsupported and are never staged, checksummed, advertised, or published. Unsigned developer packages +remain update-disabled. Windows self-update installation work resumes only under issue #2000. diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts new file mode 100644 index 000000000..363c1df5a --- /dev/null +++ b/apps/desktop/forge.config.ts @@ -0,0 +1,219 @@ +import type { ForgeConfig } from '@electron-forge/shared-types'; +import { MakerDeb } from '@electron-forge/maker-deb'; +import { MakerRpm } from '@electron-forge/maker-rpm'; +import { MakerZIP } from '@electron-forge/maker-zip'; +import { VitePlugin } from '@electron-forge/plugin-vite'; +import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses'; +import { chmodSync, copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + readCompleteEnvironmentGroup, + parseWindowsSignerPins, + requireProductionReleaseConfiguration, + resolveDesktopVersion, + resolveTrustedUpdateBuildConfig, +} from './src/release-config'; + +const DESKTOP_EXECUTABLE_NAME = 'propr-desktop'; + +const connectNativePrebuilds = fileURLToPath(new URL('../../packages/cli/native/prebuilds', import.meta.url)); +const connectOrchestrator = fileURLToPath(new URL('../../packages/cli/dist/orchestrator', import.meta.url)); + +const packagedConnectNativeArtifacts = (platform: string, arch: string): string[] => { + if (platform === 'darwin' || platform === 'mas') { + return [ + `${platform === 'mas' ? 'darwin' : platform}-${arch}/directory-operations.node`, + `${platform === 'mas' ? 'darwin' : platform}-${arch}/connect-authority-broker`, + ]; + } + if (platform === 'linux') return [`linux-${arch}/directory-operations.node`]; + return []; +}; + +const desktopPackage = JSON.parse( + readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'), +) as { version: string }; +const releaseVersion = resolveDesktopVersion(desktopPackage.version); +const updateConfig = resolveTrustedUpdateBuildConfig(); +const macSigning = readCompleteEnvironmentGroup( + process.env, + ['PROPR_DESKTOP_MAC_SIGNING_IDENTITY'], + 'macOS signing', +); +const macNotarization = readCompleteEnvironmentGroup( + process.env, + [ + 'PROPR_DESKTOP_APPLE_API_KEY_FILE', + 'PROPR_DESKTOP_APPLE_API_KEY_ID', + 'PROPR_DESKTOP_APPLE_API_ISSUER_ID', + ], + 'macOS notarization', +); +const windowsSigning = readCompleteEnvironmentGroup( + process.env, + ['PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE', 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'], + 'Windows signing', + { opaqueNames: ['PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'] }, +); + +if (macNotarization && !macSigning) { + throw new Error('macOS notarization requires macOS signing configuration'); +} +if (updateConfig.enabled) { + if (process.platform === 'darwin' && !macSigning) { + throw new Error('The macOS signed-update build must have a macOS signing identity'); + } +} +if (process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1') { + const windowsSignerPins = process.platform === 'win32' + ? parseWindowsSignerPins(process.env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS) + : []; + requireProductionReleaseConfiguration({ + platform: process.platform, + updateConfig, + macSigning, + macNotarization, + windowsSigning, + windowsSignerPins, + }); +} + +const windowsSign = windowsSigning ? { + certificateFile: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE, + certificatePassword: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD, + description: 'ProPR Desktop', +} : undefined; + +const config: ForgeConfig = { + packagerConfig: { + asar: { unpack: '**/.vite/native/prebuilds/**' }, + appBundleId: 'dev.propr.desktop', + appCategoryType: 'public.app-category.developer-tools', + appVersion: releaseVersion, + buildVersion: releaseVersion, + name: DESKTOP_EXECUTABLE_NAME, + executableName: DESKTOP_EXECUTABLE_NAME, + protocols: [{ name: 'ProPR Desktop', schemes: ['propr'] }], + ...(macSigning ? { + osxSign: { + continueOnError: false, + identity: macSigning.PROPR_DESKTOP_MAC_SIGNING_IDENTITY, + }, + } : {}), + ...(macNotarization ? { + osxNotarize: { + appleApiKey: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_FILE, + appleApiKeyId: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_ID, + appleApiIssuer: macNotarization.PROPR_DESKTOP_APPLE_API_ISSUER_ID, + }, + } : {}), + ...(windowsSign ? { windowsSign } : {}), + }, + rebuildConfig: {}, + hooks: { + readPackageJson: async (_forgeConfig, packageJson) => ({ + ...packageJson, + version: releaseVersion, + }), + packageAfterCopy: async (_forgeConfig, resourcesPath, _electronVersion, platform, arch) => { + for (const relativeArtifact of packagedConnectNativeArtifacts(platform, arch)) { + const target = resolve(resourcesPath, '.vite/native/prebuilds', relativeArtifact); + mkdirSync(dirname(target), { recursive: true }); + const source = resolve(connectNativePrebuilds, relativeArtifact); + copyFileSync(source, target); + if (platform !== 'win32') chmodSync(target, statSync(source).mode & 0o777); + } + const packagedOrchestrator = resolve(resourcesPath, '.vite/build'); + mkdirSync(packagedOrchestrator, { recursive: true }); + for (const asset of ['orchestrator.mjs', 'manifest.json']) { + copyFileSync(resolve(connectOrchestrator, asset), resolve(packagedOrchestrator, basename(asset))); + } + const applePlatform = platform === 'darwin' || platform === 'mas'; + const executableName = applePlatform ? 'Electron' : `electron${platform === 'win32' ? '.exe' : ''}`; + await flipFuses(resolve(resourcesPath, '..', '..', applePlatform ? 'MacOS' : '', executableName), { + version: FuseVersion.V1, + resetAdHocDarwinSignature: applePlatform && arch === 'arm64', + strictlyRequireAllFuses: true, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false, + [FuseV1Options.GrantFileProtocolExtraPrivileges]: false, + [FuseV1Options.WasmTrapHandlers]: true, + }); + }, + postMake: async (_forgeConfig, makeResults) => { + if (process.platform !== 'win32') return makeResults; + const installerModule = './scripts/build-windows-machine-installer.mjs'; + const { buildWindowsMachineInstaller } = await import(installerModule); + for (const result of makeResults) { + if (result.platform !== 'win32' || (result.arch !== 'x64' && result.arch !== 'arm64')) continue; + const triggerArtifact = result.artifacts[0]; + if (!triggerArtifact) throw new Error('Windows make did not produce its private MSI build trigger'); + const machineInstaller = resolve( + dirname(triggerArtifact), + `ProPR-Desktop-${releaseVersion}-Machine-Setup.msi`, + ); + const built = await buildWindowsMachineInstaller({ + appDirectory: resolve('out', `propr-desktop-win32-${result.arch}`), + output: machineInstaller, + version: releaseVersion, + arch: result.arch, + wixDirectory: process.env.PROPR_DESKTOP_WIX_DIRECTORY, + }); + if (built.skipped) throw new Error('Machine-wide Windows installer was not built'); + if (windowsSign) { + const { sign } = await import('@electron/windows-sign'); + await sign({ files: [machineInstaller], ...windowsSign }); + } + await Promise.all(result.artifacts.map(path => rm(path, { force: true }))); + result.artifacts = [machineInstaller]; + } + return makeResults; + }, + }, + makers: [ + // Forge requires a maker result before postMake. On Windows this ZIP is a + // private build trigger only: postMake deletes it and returns exactly the + // protected machine-wide MSI as the sole maker artifact. + new MakerZIP({}, ['darwin', 'linux', 'win32']), + ...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1' + ? [new MakerDeb({ + options: { + name: DESKTOP_EXECUTABLE_NAME, + productName: 'ProPR Desktop', + version: releaseVersion, + bin: DESKTOP_EXECUTABLE_NAME, + }, + })] + : []), + ...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1' + ? [new MakerRpm({ + options: { + name: DESKTOP_EXECUTABLE_NAME, + productName: 'ProPR Desktop', + version: releaseVersion, + bin: DESKTOP_EXECUTABLE_NAME, + }, + })] + : []), + ], + plugins: [ + new VitePlugin({ + build: [ + { entry: 'src/main.ts', config: 'vite.main.config.ts' }, + { entry: 'src/preload.ts', config: 'vite.preload.config.ts' }, + ], + renderer: [ + { name: 'main_window', config: 'vite.renderer.config.ts' }, + ], + }), + ], +}; + +export default config; diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 000000000..512483717 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,60 @@ +{ + "name": "@propr/desktop", + "productName": "ProPR Desktop", + "version": "0.8.15", + "private": true, + "description": "Secure ProPR desktop application", + "author": "Unchained Development OÜ / Rinalds Uzkalns", + "license": "Apache-2.0", + "dependencies": { + "@propr/cli": "*", + "@propr/client": "*", + "@propr/shared": "*" + }, + "homepage": "https://github.com/integry/propr", + "type": "module", + "main": ".vite/build/main.cjs", + "scripts": { + "prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/local-setup && npm run build -w @propr/cli && npm run build -w @propr/client", + "predev": "npm run prepare:renderer", + "dev": "electron-forge start", + "pretypecheck": "npm run prepare:renderer", + "typecheck": "tsc --noEmit", + "pretest": "npm run prepare:renderer", + "test": "tsx --test src/**/*.test.ts scripts/*.test.mjs", + "test:windows-fixture-acl": "node --test scripts/windows-fixture-acl.test.mjs", + "pretest:native-durability": "npm run prepare:renderer", + "test:native-durability": "node scripts/run-native-durability.mjs", + "prepackage": "npm run prepare:renderer", + "package": "electron-forge package", + "smoke:package": "node scripts/smoke-packaged.mjs", + "smoke:connect-package": "node scripts/smoke-packaged-connect.mjs", + "smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only", + "premake": "npm run prepare:renderer", + "make": "electron-forge make", + "make:dmg": "node scripts/make-dmg.mjs", + "release:stage": "node scripts/release-artifacts.mjs stage", + "release:finalize": "node scripts/release-artifacts.mjs finalize", + "premake:deb": "npm run prepare:renderer", + "make:deb": "PROPR_DESKTOP_ENABLE_DEB=1 electron-forge make --targets @electron-forge/maker-deb", + "premake:rpm": "npm run prepare:renderer", + "make:rpm": "PROPR_DESKTOP_ENABLE_RPM=1 electron-forge make --targets @electron-forge/maker-rpm" + }, + "devDependencies": { + "@electron-forge/cli": "8.0.0-alpha.10", + "@electron-forge/maker-deb": "8.0.0-alpha.10", + "@electron-forge/maker-rpm": "8.0.0-alpha.10", + "@electron-forge/maker-zip": "8.0.0-alpha.10", + "@electron-forge/plugin-vite": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron/windows-sign": "2.0.6", + "@electron/fuses": "^2.1.3", + "@types/node": "^22.10.0", + "@vitejs/plugin-react": "^4.6.0", + "electron": "^44.0.0", + "socket.io": "^4.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.3.5" + } +} diff --git a/apps/desktop/renderer.html b/apps/desktop/renderer.html new file mode 100644 index 000000000..3ebbceb83 --- /dev/null +++ b/apps/desktop/renderer.html @@ -0,0 +1,17 @@ + + + + + + + + ProPR Desktop + + +
+ + + diff --git a/apps/desktop/scripts/assert-windows-mvp-package.mjs b/apps/desktop/scripts/assert-windows-mvp-package.mjs new file mode 100644 index 000000000..f9f43112d --- /dev/null +++ b/apps/desktop/scripts/assert-windows-mvp-package.mjs @@ -0,0 +1,112 @@ +import { lstat, readdir } from 'node:fs/promises'; +import { basename, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractFile, listPackage } from '@electron/asar'; + +const PACKAGE_MAIN = '.vite/build/main.cjs'; +const AUTHORITY_TOKEN = /windows-(?:update-)?authority|propr-windows-(?:authority|launcher|bootstrap)|--broker/i; + +const normalizeArchiveEntry = entry => { + if (typeof entry !== 'string' || entry.length < 2 || /[\u0000-\u001F\u007F]/.test(entry)) { + throw new Error('Windows MVP application archive contains an invalid entry'); + } + const separator = entry[0]; + if ((separator !== '/' && separator !== '\\') || entry[1] === '/' || entry[1] === '\\') { + throw new Error('Windows MVP application archive entry is not represented from one root'); + } + const otherSeparator = separator === '/' ? '\\' : '/'; + if (entry.slice(1).includes(otherSeparator)) { + throw new Error('Windows MVP application archive entry mixes path representations'); + } + const normalized = entry.slice(1).split('\\').join('/'); + const components = normalized.split('/'); + if (components.some(component => !component || component === '.' || component === '..' || component.includes(':'))) { + throw new Error('Windows MVP application archive entry contains traversal or ambiguity'); + } + return normalized; +}; + +const canonicalArchiveEntry = (archiveEntries, expected) => { + if (!Array.isArray(archiveEntries) || archiveEntries.length > 10_000) { + throw new Error('Windows MVP application archive entry bound exceeded'); + } + const representations = new Map(); + let matchedEntry; + for (const entry of archiveEntries) { + const normalized = normalizeArchiveEntry(entry); + const folded = normalized.toLocaleLowerCase('en-US'); + if (representations.has(folded)) { + throw new Error('Windows MVP application archive contains duplicate or case-colliding entries'); + } + representations.set(folded, entry); + if (folded === expected.toLocaleLowerCase('en-US')) { + if (normalized !== expected) { + throw new Error(`Windows MVP application archive ${expected} entry has non-canonical casing`); + } + matchedEntry = entry.slice(1); + } + } + if (!matchedEntry) { + throw new Error(`Windows MVP application archive lacks one canonical ${expected} entry`); + } + return matchedEntry; +}; + +export const canonicalMainBundleEntry = archiveEntries => canonicalArchiveEntry(archiveEntries, PACKAGE_MAIN); + +export const assertWindowsMvpPackage = async directory => { + if (!directory || !isAbsolute(directory)) { + throw new Error('Windows MVP package assertion requires one absolute application directory'); + } + + const root = resolve(directory); + let applicationCount = 0; + let entries = 0; + const visit = async path => { + for (const entry of await readdir(path, { withFileTypes: true })) { + const target = join(path, entry.name); + const stats = await lstat(target); + entries += 1; + if (entries > 10_000) throw new Error('Windows MVP package entry bound exceeded'); + if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) { + throw new Error('Windows MVP package contains a link or special resource'); + } + if (AUTHORITY_TOKEN.test(entry.name)) { + throw new Error('Windows MVP package contains a deferred update authority resource'); + } + if (stats.isDirectory()) await visit(target); + else if (basename(target).toLocaleLowerCase('en-US') === 'propr-desktop.exe') applicationCount += 1; + } + }; + + await visit(root); + if (applicationCount !== 1) throw new Error('Windows MVP package lacks one canonical application executable'); + const asarPath = join(root, 'resources', 'app.asar'); + const asarEntries = listPackage(asarPath); + const packageEntry = canonicalArchiveEntry(asarEntries, 'package.json'); + const packageBytes = extractFile(asarPath, packageEntry); + if (packageBytes.length > 65_536) throw new Error('Windows MVP application package metadata is too large'); + let packageMetadata; + try { + packageMetadata = JSON.parse(packageBytes.toString('utf8')); + } catch { + throw new Error('Windows MVP application package metadata is invalid'); + } + if (!packageMetadata || Array.isArray(packageMetadata) || packageMetadata.main !== PACKAGE_MAIN) { + throw new Error(`Windows MVP application package main must be ${PACKAGE_MAIN}`); + } + const mainEntry = canonicalMainBundleEntry(asarEntries); + if (asarEntries.some(entry => AUTHORITY_TOKEN.test(normalizeArchiveEntry(entry)))) { + throw new Error('Windows MVP application archive contains a deferred update authority resource'); + } + const mainBundle = extractFile(asarPath, mainEntry).toString('utf8'); + if (AUTHORITY_TOKEN.test(mainBundle)) { + throw new Error('Windows MVP main process retains a reachable deferred update authority'); + } + process.stdout.write('Windows MVP package contains one application and no update authority resources.\n'); +}; + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const [directory] = process.argv.slice(2); + await assertWindowsMvpPackage(directory); +} diff --git a/apps/desktop/scripts/assert-windows-mvp-package.test.mjs b/apps/desktop/scripts/assert-windows-mvp-package.test.mjs new file mode 100644 index 000000000..38166c82a --- /dev/null +++ b/apps/desktop/scripts/assert-windows-mvp-package.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { createPackage, extractFile, listPackage } from '@electron/asar'; +import { canonicalMainBundleEntry } from './assert-windows-mvp-package.mjs'; + +const fixtures = []; +after(async () => Promise.all(fixtures.map(path => rm(path, { recursive: true, force: true })))); + +describe('Windows MVP ASAR main entry', () => { + test('uses the rooted listPackage representation accepted by extractFile', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-windows-mvp-asar-')); + fixtures.push(root); + const source = join(root, 'source'); + const archive = join(root, 'app.asar'); + await mkdir(join(source, '.vite', 'build'), { recursive: true }); + await writeFile(join(source, '.vite', 'build', 'main.cjs'), 'module.exports = "main fixture";\n'); + await writeFile(join(source, 'package.json'), '{"main":".vite/build/main.cjs"}\n'); + await createPackage(source, archive); + + const entries = listPackage(archive); + const listedMain = entries.find(entry => entry.replaceAll('\\', '/') === '/.vite/build/main.cjs'); + assert.ok(listedMain?.startsWith('/') || listedMain?.startsWith('\\')); + const extractionEntry = canonicalMainBundleEntry(entries); + assert.equal(extractionEntry, listedMain.slice(1)); + assert.equal(extractFile(archive, extractionEntry).toString('utf8'), 'module.exports = "main fixture";\n'); + }); + + test('preserves the Windows separator after removing the one archive root', () => { + assert.equal(canonicalMainBundleEntry([ + '\\.vite', + '\\.vite\\build', + '\\.vite\\build\\main.cjs', + ]), '.vite\\build\\main.cjs'); + }); + + test('rejects traversal, duplicate entries, and case-colliding main paths', () => { + assert.throws( + () => canonicalMainBundleEntry(['/.vite', '/.vite/../build', '/.vite/build/main.cjs']), + /traversal or ambiguity/, + ); + assert.throws( + () => canonicalMainBundleEntry(['/.vite/build/main.cjs', '/.vite/build/main.cjs']), + /duplicate or case-colliding/, + ); + assert.throws( + () => canonicalMainBundleEntry(['/.vite/build/main.cjs', '/.VITE/build/main.cjs']), + /duplicate or case-colliding/, + ); + assert.throws( + () => canonicalMainBundleEntry(['/.VITE/build/main.cjs']), + /non-canonical casing/, + ); + }); +}); diff --git a/apps/desktop/scripts/build-windows-authority-helper.mjs b/apps/desktop/scripts/build-windows-authority-helper.mjs new file mode 100644 index 000000000..4e318a636 --- /dev/null +++ b/apps/desktop/scripts/build-windows-authority-helper.mjs @@ -0,0 +1,780 @@ +import { createHash } from 'node:crypto'; +import { execFile, fork } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, lstat, mkdir, mkdtemp, open, realpath, rename, rm, stat } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { promisify } from 'node:util'; +import { + buildWindowsNativeLauncher, + cleanupWindowsAuthorityBuildStaging, + inspectWindowsNativeLauncherPe, + sealWindowsAuthorityDirectory, + WINDOWS_NATIVE_BOOTSTRAP, + WINDOWS_NATIVE_BUILD_BOOTSTRAP, + WINDOWS_NATIVE_LAUNCHER, +} from './build-windows-native-launcher.mjs'; + +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +export const WINDOWS_AUTHORITY_SOURCE = join(desktopRoot, 'src', 'native', 'propr-windows-authority.cs'); +export const WINDOWS_AUTHORITY_BUILD_DIRECTORY = join(desktopRoot, 'build', 'windows-authority'); +export const WINDOWS_AUTHORITY_EXECUTABLE = join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'propr-windows-authority.exe'); +export const WINDOWS_AUTHORITY_MANIFEST = join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'propr-windows-authority.manifest.json'); +export const WINDOWS_AUTHORITY_BUILD_STAGES = Object.freeze(['BUILD_COMPILER', 'BUILD_SOURCE', 'BUILD_OUTPUT']); +export const WINDOWS_AUTHORITY_COMPILER_SUBSTAGES = Object.freeze([ + 'DIRECTORY_PROBE', 'CATALOG_ENUMERATION', 'MEMBER_TAG', 'CATALOG_HASH', 'WINTRUST_POLICY', + 'REVOCATION', 'CATALOG_LEASE', 'SIGNER_PARSE', 'EXACT_PUBLISHER', 'ROOT_PIN', 'CERTIFICATE_PIN', + 'SPKI_PIN', 'COMPILER_OPEN', 'REFERENCE_OPEN', 'SIGNER_CATALOG', 'BOOTSTRAP_READ', 'BOOTSTRAP_AUTH', + 'LAUNCHER_AUTH', 'OPEN', 'FILE_META', 'OWNER', 'DACL', 'DACL_PROTECTED', 'ARCH', 'HASH', + 'SAME_IMAGE', 'LEASE', 'SOURCE_COPY', 'SPAWN', + 'COMPILE', 'LINK', 'EXIT', 'TIMEOUT', 'OUTPUT_LIMIT', 'IMAGE', 'OUTPUT_VALIDATION', +]); +const MAX_SOURCE_BYTES = 256 * 1024; +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 64 * 1024; +const WINDOWS_COMPILER_TIMEOUT_MS = 6 * 60_000; +const WINDOWS_BUILD_CHILD_TIMEOUT_MS = WINDOWS_COMPILER_TIMEOUT_MS + 30_000; +const WINDOWS_BUILD_CHILD_ARGUMENT = '--windows-authority-build-child-v1'; +const WINDOWS_BUILD_CHILD_SCHEMA_VERSION = 1; +const WINDOWS_BUILD_CHILD_MAX_MESSAGES = 6; +const WINDOWS_BUILD_CHILD_MAX_MESSAGE_BYTES = 2 * 1024; +export const WINDOWS_BUILD_CHILD_EVIDENCE = Object.freeze([ + 'STARTED', 'BOOTSTRAP_AUTHENTICATED', 'LAUNCHER_AUTHENTICATED', 'COMPILER_STARTED', 'PUBLISHED', +]); +const WINDOWS_LAUNCHER_AUTH_PREDICATES = Object.freeze([ + 'OPEN', 'FILE_META', 'OWNER', 'DACL', 'DACL_PROTECTED', 'ARCH', 'HASH', +]); +const WINDOWS_BUILD_AUTH_FAILURES = Object.freeze([ + 'BOOTSTRAP_AUTH', 'LAUNCHER_AUTH', ...WINDOWS_LAUNCHER_AUTH_PREDICATES, 'SAME_IMAGE', +]); +const WINDOWS_CLEANUP_DIAGNOSTIC = 'BUILD_COMPILER:LEASE'; +const SYSTEM_DIRECTORY_RECORD_BYTES = 2 + (520 * 2); +const require = createRequire(import.meta.url); +const execFileAsync = promisify(execFile); +const boundedCompilerDiagnostics = diagnostics => Array.isArray(diagnostics) + ? diagnostics.filter(value => typeof value === 'string' && ( + /^(?:propr_windows_launcher\.(?:cc|obj)|link):\d+:(?:C|LNK)\d{4}$/.test(value) + || /^CS\d{4}$/.test(value) + || /^member:[A-Za-z0-9_.~-]{1,64}$/.test(value) + || /^catalog:[A-Za-z0-9_.~-]{1,176}\.cat$/.test(value) + || /^catalog-sha256:[a-f0-9]{64}$/.test(value) + )).slice(0, 8) + : []; + +const windowsAuthorityFailure = (stage, substage, diagnostics = []) => { + const boundedSubstage = stage === 'BUILD_COMPILER' && WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(substage) + ? `:${substage}` : ''; + const error = new Error(`Windows authority helper build failed [win-authority:${stage}${boundedSubstage}]`); + error.stage = stage; + if (boundedSubstage) error.substage = substage; + error.diagnostics = Object.freeze(stage === 'BUILD_COMPILER' ? boundedCompilerDiagnostics(diagnostics) : []); + error.cleanupDiagnostics = Object.freeze([]); + return error; +}; + +const fail = (stage, substage, diagnostics = []) => { + throw windowsAuthorityFailure(stage, substage, diagnostics); +}; + +const addCleanupDiagnostic = error => { + const primary = error instanceof Error ? error : windowsAuthorityFailure('BUILD_COMPILER', 'EXIT'); + primary.cleanupDiagnostics = Object.freeze([WINDOWS_CLEANUP_DIAGNOSTIC]); + return primary; +}; + +export const preserveWindowsAuthorityCompilerFailure = (error, fallback = 'DIRECTORY_PROBE') => { + if (typeof error === 'object' && error !== null) { + if (error.stage === 'BUILD_COMPILER' && WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(error.substage)) { + fail('BUILD_COMPILER', error.substage, error.diagnostics); + } + if (WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(error.code)) { + fail('BUILD_COMPILER', error.code, error.diagnostics); + } + } + fail('BUILD_COMPILER', fallback); +}; + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const samePath = (left, right) => process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; + +export const validateWindowsAuthoritySource = bytes => { + if (!Buffer.isBuffer(bytes) || bytes.length <= 0 || bytes.length > MAX_SOURCE_BYTES + || Buffer.from(bytes.toString('utf8'), 'utf8').compare(bytes) !== 0 + || !bytes.toString('utf8').includes('public static int Main(string[] args)')) fail('BUILD_SOURCE'); + return sha256(bytes); +}; + +const validateTree = async (root, target, stage) => { + const canonicalRoot = await realpath(root).catch(() => fail(stage)); + const canonicalTarget = await realpath(target).catch(() => fail(stage)); + if (!samePath(resolve(root), canonicalRoot) || !samePath(resolve(target), canonicalTarget)) fail(stage); + const inside = relative(canonicalRoot, canonicalTarget); + if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) fail(stage); + let cursor = canonicalRoot; + for (const component of inside.split(sep)) { + cursor = join(cursor, component); + const entry = await lstat(cursor).catch(() => fail(stage)); + if (entry.isSymbolicLink() || (!entry.isDirectory() && cursor !== canonicalTarget)) fail(stage); + } + const targetStats = await stat(canonicalTarget).catch(() => fail(stage)); + if (!targetStats.isFile() || targetStats.size <= 0) fail(stage); + return canonicalTarget; +}; + +const readHeldBuildOutput = async (root, target) => { + const canonical = await validateTree(root, target, 'BUILD_OUTPUT'); + const pathStats = await lstat(canonical, { bigint: true }).catch(() => fail('BUILD_OUTPUT')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_OUTPUT_BYTES)) fail('BUILD_OUTPUT'); + const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => fail('BUILD_OUTPUT')); + try { + const before = await handle.stat({ bigint: true }); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== pathStats.nlink) fail('BUILD_OUTPUT'); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size + || after.nlink !== before.nlink || BigInt(bytes.length) !== before.size) fail('BUILD_OUTPUT'); + return bytes; + } finally { await handle.close(); } +}; + +export const decodeWindowsSystemDirectoryRecord = record => { + if (!Buffer.isBuffer(record) || record.length !== SYSTEM_DIRECTORY_RECORD_BYTES) fail('BUILD_COMPILER'); + const length = record.readUInt16LE(0); + if (length < 3 || length >= 520) fail('BUILD_COMPILER'); + const pathBytes = record.subarray(2, 2 + (length * 2)); + if (record.subarray(2 + (length * 2)).some(byte => byte !== 0)) fail('BUILD_COMPILER'); + const path = pathBytes.toString('utf16le'); + if (!/^[A-Za-z]:\\[^\0]+$/.test(path) || path.startsWith('\\\\') || path.includes('\0') + || path.indexOf(':', 2) >= 0) fail('BUILD_COMPILER'); + return path; +}; + +export const nativeLauncherAuthenticationSubstage = error => error?.code === 'MODULE_IMAGE' + ? 'SAME_IMAGE' : WINDOWS_LAUNCHER_AUTH_PREDICATES.includes(error?.code) + ? error.code : 'LAUNCHER_AUTH'; + +const loadAuthenticatedNativeLauncher = async (launcher, evidence = () => undefined) => { + const buildBootstrapBytes = await readHeldBuildOutput( + WINDOWS_AUTHORITY_BUILD_DIRECTORY, launcher.buildBootstrap.path, + ).catch(() => fail('BUILD_COMPILER', 'BOOTSTRAP_READ')); + try { + if (buildBootstrapBytes.length !== launcher.buildBootstrap.size + || sha256(buildBootstrapBytes) !== launcher.buildBootstrap.sha256) fail('BUILD_COMPILER', 'BOOTSTRAP_AUTH'); + inspectWindowsNativeLauncherPe(buildBootstrapBytes, process.arch); + } catch { fail('BUILD_COMPILER', 'BOOTSTRAP_AUTH'); } + let bootstrap; + try { bootstrap = require(launcher.buildBootstrap.path); } + catch { fail('BUILD_COMPILER', 'BOOTSTRAP_AUTH'); } + if (!bootstrap || typeof bootstrap.loadVerifiedModule !== 'function') fail('BUILD_COMPILER', 'BOOTSTRAP_AUTH'); + evidence('BOOTSTRAP_AUTHENTICATED'); + try { + const nativeLauncher = bootstrap.loadVerifiedModule({ + path: launcher.path, + size: launcher.size, + sha256: launcher.sha256, + production: false, + authenticationMode: 'held-build-artifact', + publisher: null, + signerCertificateSha256: null, + signerSpkiSha256: null, + }); + evidence('LAUNCHER_AUTHENTICATED'); + return nativeLauncher; + } catch (error) { return fail('BUILD_COMPILER', nativeLauncherAuthenticationSubstage(error)); } +}; + +export const resolveWindowsCompilerLayout = async (env, probe) => { + // The native boundary returns one fixed-size UTF-16 record from + // GetSystemWindowsDirectoryW, after opening and authenticating the canonical + // system PowerShell image. Environment roots are disagreement checks only. + let reportedRoot; + try { + reportedRoot = await Promise.resolve().then(() => probe(env)); + } catch (error) { preserveWindowsAuthorityCompilerFailure(error); } + const canonicalRoot = await realpath(reportedRoot).catch(() => fail('BUILD_COMPILER', 'DIRECTORY_PROBE')); + if (!samePath(resolve(reportedRoot), canonicalRoot)) fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + for (const hint of [env.SystemRoot, env.windir]) { + if (hint && (!isAbsolute(hint) || !samePath(await realpath(hint) + .catch(() => fail('BUILD_COMPILER', 'DIRECTORY_PROBE')), canonicalRoot))) { + fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + } + } + const layouts = ['Framework64', 'Framework']; + let compilerFound = false; + for (const layout of layouts) { + const framework = join(canonicalRoot, 'Microsoft.NET', layout, 'v4.0.30319'); + const compiler = join(framework, 'csc.exe'); + const systemReference = join(framework, 'System.dll'); + const webReference = join(framework, 'System.Web.Extensions.dll'); + try { + const canonicalCompiler = await validateTree(canonicalRoot, compiler, 'BUILD_COMPILER'); + compilerFound = true; + return { + systemRoot: canonicalRoot, + compiler: canonicalCompiler, + framework, + systemReference: await validateTree(canonicalRoot, systemReference, 'BUILD_COMPILER'), + webReference: await validateTree(canonicalRoot, webReference, 'BUILD_COMPILER'), + }; + } catch { /* try the other trusted SystemRoot framework layout */ } + } + return fail('BUILD_COMPILER', compilerFound ? 'REFERENCE_OPEN' : 'COMPILER_OPEN'); +}; + +const readHeldExactlyForBuild = async (handle, size, stage = 'BUILD_COMPILER') => { + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const result = await handle.read(bytes, offset, size - offset, offset).catch(() => fail(stage)); + if (result.bytesRead <= 0) fail(stage); + offset += result.bytesRead; + } + return bytes; +}; + +const holdSourceInput = async () => { + const canonical = await realpath(WINDOWS_AUTHORITY_SOURCE).catch(() => fail('BUILD_SOURCE')); + if (!samePath(canonical, resolve(WINDOWS_AUTHORITY_SOURCE))) fail('BUILD_SOURCE'); + const pathStats = await lstat(canonical, { bigint: true }).catch(() => fail('BUILD_SOURCE')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_SOURCE_BYTES)) fail('BUILD_SOURCE'); + const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => fail('BUILD_SOURCE')); + try { + const before = await handle.stat({ bigint: true }); + const bytes = await readHeldExactlyForBuild(handle, Number(before.size), 'BUILD_SOURCE'); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== 1n || BigInt(bytes.length) !== before.size) fail('BUILD_SOURCE'); + return { path: canonical, handle, before, bytes, sha256: validateWindowsAuthoritySource(bytes) }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +}; + +const reverifySourceInput = async source => { + const after = await source.handle.stat({ bigint: true }).catch(() => fail('BUILD_SOURCE')); + const pathStats = await lstat(source.path, { bigint: true }).catch(() => fail('BUILD_SOURCE')); + if (after.dev !== source.before.dev || after.ino !== source.before.ino || after.size !== source.before.size + || after.nlink !== 1n || pathStats.dev !== after.dev || pathStats.ino !== after.ino + || pathStats.size !== after.size || pathStats.nlink !== 1n) fail('BUILD_SOURCE'); + const bytes = await readHeldExactlyForBuild(source.handle, Number(after.size), 'BUILD_SOURCE'); + if (sha256(bytes) !== source.sha256) fail('BUILD_SOURCE'); +}; + +const compilerSubstage = error => { + const code = typeof error === 'object' && error !== null && typeof error.code === 'string' ? error.code : ''; + return WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(code) ? code : 'SPAWN'; +}; + +const DIRECT_COMPILER_MAX_BUFFER_BYTES = 64 * 1024; +const DIRECT_COMPILER_DIAGNOSTIC_LIMIT = 8; + +export const sanitizeWindowsCompilerDiagnostics = output => { + const text = Buffer.isBuffer(output) ? output.toString('utf8') : typeof output === 'string' ? output : ''; + const diagnostics = []; + const seen = new Set(); + for (const match of text.matchAll(/\bCS\d{4}\b/gi)) { + const code = match[0].toUpperCase(); + if (!seen.has(code)) { + seen.add(code); + diagnostics.push(code); + } + if (diagnostics.length === DIRECT_COMPILER_DIAGNOSTIC_LIMIT) break; + } + return Object.freeze(diagnostics); +}; + +const directCompilerFailure = error => { + if (error?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + || error?.name === 'RangeError' && /maxBuffer/i.test(String(error?.message ?? ''))) return 'OUTPUT_LIMIT'; + if (error?.killed === true) return 'TIMEOUT'; + if (['EINVAL', 'ENOENT', 'EACCES', 'EPERM'].includes(error?.code)) return 'SPAWN'; + const diagnostics = sanitizeWindowsCompilerDiagnostics(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`); + return diagnostics.length > 0 ? 'COMPILE' : 'EXIT'; +}; + +export const compileWindowsAuthorityDirect = async (layout, privatePaths, invoke = execFileAsync) => { + const { compiler, framework, systemReference, systemRoot, webReference } = layout; + const { cwd, output, source } = privatePaths; + const paths = [compiler, framework, systemReference, systemRoot, webReference, cwd, output, source]; + if (!paths.every(value => typeof value === 'string' && isAbsolute(value) && !value.includes('\0'))) { + fail('BUILD_COMPILER', 'SPAWN'); + } + const fixedFrameworks = ['Framework64', 'Framework'] + .map(name => join(systemRoot, 'Microsoft.NET', name, 'v4.0.30319')); + if (!fixedFrameworks.some(candidate => samePath(framework, candidate)) + || !samePath(compiler, join(framework, 'csc.exe')) + || !samePath(systemReference, join(framework, 'System.dll')) + || !samePath(webReference, join(framework, 'System.Web.Extensions.dll')) + || !samePath(output, join(cwd, 'propr-windows-authority.exe')) + || !samePath(source, join(cwd, 'propr-windows-authority.cs'))) fail('BUILD_COMPILER', 'SPAWN'); + const args = [ + '/nologo', '/noconfig', '/target:exe', '/platform:anycpu', '/optimize+', '/checked+', '/warnaserror+', + `/out:${output}`, `/reference:${systemReference}`, `/reference:${webReference}`, source, + ]; + try { + await invoke(compiler, args, { + cwd, + env: { SystemRoot: systemRoot, TEMP: cwd, TMP: cwd }, + shell: false, + windowsHide: true, + timeout: WINDOWS_COMPILER_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: DIRECT_COMPILER_MAX_BUFFER_BYTES, + encoding: 'utf8', + }); + } catch (error) { + const diagnostics = sanitizeWindowsCompilerDiagnostics(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`); + fail('BUILD_COMPILER', directCompilerFailure(error), diagnostics); + } +}; + +const writePrivateSource = async (target, bytes) => { + const handle = await open(target, fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600) + .catch(() => fail('BUILD_SOURCE')); + try { + await handle.writeFile(bytes); + await handle.sync(); + const stats = await handle.stat({ bigint: true }); + const copied = await readHeldExactlyForBuild(handle, Number(stats.size), 'BUILD_SOURCE'); + if (!stats.isFile() || stats.nlink !== 1n || BigInt(bytes.length) !== stats.size + || !copied.equals(bytes)) fail('BUILD_SOURCE'); + } finally { await handle.close().catch(() => undefined); } +}; + +export const inspectAnyCpuPe = bytes => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > MAX_OUTPUT_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) fail('BUILD_OUTPUT'); + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset < 0x40 || peOffset + 248 > bytes.length || bytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') { + fail('BUILD_OUTPUT'); + } + const machine = bytes.readUInt16LE(peOffset + 4); + const sectionCount = bytes.readUInt16LE(peOffset + 6); + const optionalSize = bytes.readUInt16LE(peOffset + 20); + const optional = peOffset + 24; + if (machine !== 0x14c || sectionCount <= 0 || sectionCount > 96 + || optionalSize < 224 || bytes.readUInt16LE(optional) !== 0x10b) fail('BUILD_OUTPUT'); + const clrDirectory = optional + 96 + (14 * 8); + const clrRva = bytes.readUInt32LE(clrDirectory); + if (clrDirectory + 8 > optional + optionalSize || clrRva === 0 || bytes.readUInt32LE(clrDirectory + 4) < 72) fail('BUILD_OUTPUT'); + const sectionTable = optional + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > bytes.length) fail('BUILD_OUTPUT'); + const virtualSize = bytes.readUInt32LE(section + 8); + const virtualAddress = bytes.readUInt32LE(section + 12); + const rawSize = bytes.readUInt32LE(section + 16); + const rawAddress = bytes.readUInt32LE(section + 20); + const span = Math.max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva < virtualAddress + span) clrOffset = rawAddress + clrRva - virtualAddress; + } + if (clrOffset < 0 || clrOffset + 20 > bytes.length) fail('BUILD_OUTPUT'); + const corFlags = bytes.readUInt32LE(clrOffset + 16); + if ((corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) fail('BUILD_OUTPUT'); + return { format: 'PE32', architecture: 'anycpu', machine: 'I386', clr: true }; +}; + +const writeAtomic = async (target, bytes) => { + const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; + const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, target); +}; + +const buildWindowsAuthorityHelperInner = async (env, launcher, evidence = () => undefined) => { + if (process.platform !== 'win32') return { skipped: true }; + if (launcher.skipped) fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + evidence('STARTED'); + const nativeLauncher = await loadAuthenticatedNativeLauncher(launcher, evidence); + if (WINDOWS_BUILD_AUTH_FAILURES.includes(env.PROPR_WINDOWS_AUTHORITY_TEST_AUTH_FAILURE)) { + fail('BUILD_COMPILER', env.PROPR_WINDOWS_AUTHORITY_TEST_AUTH_FAILURE); + } + const compilerLayout = await resolveWindowsCompilerLayout( + env, + probeEnv => { + if (!nativeLauncher || typeof nativeLauncher.probeSystemDirectory !== 'function') { + return fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + } + let record; + try { record = nativeLauncher.probeSystemDirectory({ systemRoot: probeEnv.SystemRoot ?? '', windir: probeEnv.windir ?? '', + fault: probeEnv.PROPR_WINDOWS_AUTHORITY_TEST_DIRECTORY_PROBE_FAULT ?? null }); } + catch (error) { return preserveWindowsAuthorityCompilerFailure(error, + compilerSubstage(error) === 'SPAWN' ? 'DIRECTORY_PROBE' : compilerSubstage(error)); } + try { return decodeWindowsSystemDirectoryRecord(record); } + catch { return fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); } + }, + ); + const { framework } = compilerLayout; + const sourceInput = await holdSourceInput(); + const sourceSha256 = sourceInput.sha256; + await mkdir(WINDOWS_AUTHORITY_BUILD_DIRECTORY, { recursive: true }); + const privateOutputDirectory = await mkdtemp(join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'compile-')); + await chmod(privateOutputDirectory, 0o700).catch(() => fail('BUILD_OUTPUT')); + const temporaryOutput = join(privateOutputDirectory, 'propr-windows-authority.exe'); + const privateSource = join(privateOutputDirectory, 'propr-windows-authority.cs'); + let result; + let primaryFailure; + try { + await reverifySourceInput(sourceInput); + await writePrivateSource(privateSource, sourceInput.bytes); + const frameworkIdentity = framework.toLowerCase().endsWith(`${sep}framework64${sep}v4.0.30319`.toLowerCase()) + ? 'Framework64-v4.0.30319' + : 'Framework-v4.0.30319'; + evidence('COMPILER_STARTED'); + await compileWindowsAuthorityDirect(compilerLayout, { + cwd: privateOutputDirectory, output: temporaryOutput, source: privateSource, + }); + await reverifySourceInput(sourceInput); + const compiledSource = await readHeldBuildOutput(privateOutputDirectory, privateSource) + .catch(() => fail('BUILD_SOURCE')); + if (!compiledSource.equals(sourceInput.bytes) || sha256(compiledSource) !== sourceSha256) fail('BUILD_SOURCE'); + const output = await readHeldBuildOutput(privateOutputDirectory, temporaryOutput); + const pe = inspectAnyCpuPe(output); + if (output.length <= 0 || output.length > MAX_OUTPUT_BYTES) fail('BUILD_OUTPUT'); + await writeAtomic(WINDOWS_AUTHORITY_EXECUTABLE, output); + const publishedOutput = await readHeldBuildOutput(WINDOWS_AUTHORITY_BUILD_DIRECTORY, WINDOWS_AUTHORITY_EXECUTABLE); + if (!publishedOutput.equals(output)) fail('BUILD_OUTPUT'); + const manifest = { + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: pe.format, + architecture: pe.architecture, + machine: pe.machine, + clr: pe.clr, + size: output.length, + sha256: sha256(output), + sourceSha256, + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: launcher.name, + format: launcher.format, + architecture: launcher.architecture, + machine: launcher.machine, + size: launcher.size, + sha256: launcher.sha256, + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: launcher.bootstrap.name, + format: launcher.bootstrap.format, + architecture: launcher.bootstrap.architecture, + machine: launcher.bootstrap.machine, + size: launcher.bootstrap.size, + sha256: launcher.bootstrap.sha256, + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-fixed-system-dotnet-framework-csc-v1', + framework: frameworkIdentity, + }, + }; + await writeAtomic(WINDOWS_AUTHORITY_MANIFEST, Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8')); + evidence('PUBLISHED'); + result = { skipped: false, executable: WINDOWS_AUTHORITY_EXECUTABLE, manifest: WINDOWS_AUTHORITY_MANIFEST, ...manifest }; + } catch (error) { primaryFailure = error; } + await sourceInput.handle.close().catch(() => undefined); + let cleanupFailed = false; + await rm(privateOutputDirectory, { recursive: true, force: true }).catch(() => { cleanupFailed = true; }); + if (primaryFailure) throw cleanupFailed ? addCleanupDiagnostic(primaryFailure) : primaryFailure; + if (cleanupFailed) fail('BUILD_COMPILER', 'LEASE'); + return result; +}; + +const hasExactKeys = (value, keys) => typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); +const validNativeDescriptor = value => hasExactKeys(value, ['architecture', 'format', 'machine', 'sha256', 'size']) + && Number.isSafeInteger(value.size) && value.size > 0 && value.size <= MAX_OUTPUT_BYTES + && /^[a-f0-9]{64}$/.test(value.sha256) && value.format === 'PE' + && value.architecture === process.arch + && value.machine === (process.arch === 'arm64' ? 'ARM64' : process.arch === 'x64' ? 'AMD64' : ''); + +const nativeDescriptor = value => ({ + architecture: value.architecture, + format: value.format, + machine: value.machine, + sha256: value.sha256, + size: value.size, +}); + +const buildChildRequest = launcher => ({ + schemaVersion: WINDOWS_BUILD_CHILD_SCHEMA_VERSION, + type: 'build', + launcher: nativeDescriptor(launcher), + bootstrap: nativeDescriptor(launcher.bootstrap), + buildBootstrap: nativeDescriptor(launcher.buildBootstrap), +}); + +const decodeBuildChildRequest = message => { + if (!hasExactKeys(message, ['bootstrap', 'buildBootstrap', 'launcher', 'schemaVersion', 'type']) + || message.schemaVersion !== WINDOWS_BUILD_CHILD_SCHEMA_VERSION || message.type !== 'build' + || !validNativeDescriptor(message.launcher) || !validNativeDescriptor(message.bootstrap) + || !validNativeDescriptor(message.buildBootstrap)) fail('BUILD_COMPILER', 'EXIT'); + return { + skipped: false, + path: WINDOWS_NATIVE_LAUNCHER, + name: 'propr-windows-launcher.node', + ...message.launcher, + bootstrap: { + path: WINDOWS_NATIVE_BOOTSTRAP, + name: 'propr-windows-bootstrap.node', + ...message.bootstrap, + }, + buildBootstrap: { + path: WINDOWS_NATIVE_BUILD_BOOTSTRAP, + ...message.buildBootstrap, + }, + }; +}; + +const boundedIpcRecord = message => { + try { return Buffer.byteLength(JSON.stringify(message), 'utf8') <= WINDOWS_BUILD_CHILD_MAX_MESSAGE_BYTES; } + catch { return false; } +}; + +const validEvidenceRecord = message => hasExactKeys(message, ['schemaVersion', 'type', 'value']) + && message.schemaVersion === WINDOWS_BUILD_CHILD_SCHEMA_VERSION && message.type === 'evidence' + && WINDOWS_BUILD_CHILD_EVIDENCE.includes(message.value); + +const validResultRecord = message => { + if (message?.schemaVersion !== WINDOWS_BUILD_CHILD_SCHEMA_VERSION || message?.type !== 'result') return false; + if (message.status === 'success') { + return hasExactKeys(message, ['schemaVersion', 'status', 'type']); + } + return message.status === 'failure' + && hasExactKeys(message, [ + 'cleanupDiagnostics', 'diagnostics', 'schemaVersion', 'stage', 'status', 'substage', 'type', + ]) + && WINDOWS_AUTHORITY_BUILD_STAGES.includes(message.stage) + && (message.stage === 'BUILD_COMPILER' + ? WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(message.substage) : message.substage === null) + && Array.isArray(message.diagnostics) + && message.diagnostics.length <= 8 + && boundedCompilerDiagnostics(message.diagnostics).length === message.diagnostics.length + && Array.isArray(message.cleanupDiagnostics) && message.cleanupDiagnostics.length <= 1 + && message.cleanupDiagnostics.every(value => value === WINDOWS_CLEANUP_DIAGNOSTIC); +}; + +const normalizeWindowsAuthorityFailure = (error, fallback = 'EXIT') => { + if (error instanceof Error && WINDOWS_AUTHORITY_BUILD_STAGES.includes(error.stage)) { + const normalized = error.stage === 'BUILD_COMPILER' + && WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(error.substage) + ? windowsAuthorityFailure(error.stage, error.substage, error.diagnostics) + : error.stage !== 'BUILD_COMPILER' ? windowsAuthorityFailure(error.stage) : windowsAuthorityFailure('BUILD_COMPILER', fallback); + if (Array.isArray(error.buildChildEvidence) + && error.buildChildEvidence.every(value => WINDOWS_BUILD_CHILD_EVIDENCE.includes(value))) { + normalized.buildChildEvidence = Object.freeze([...error.buildChildEvidence]); + } + return Array.isArray(error.cleanupDiagnostics) && error.cleanupDiagnostics.includes(WINDOWS_CLEANUP_DIAGNOSTIC) + ? addCleanupDiagnostic(normalized) : normalized; + } + return windowsAuthorityFailure('BUILD_COMPILER', fallback); +}; + +const failureRecord = error => { + const failure = normalizeWindowsAuthorityFailure(error); + return { + schemaVersion: WINDOWS_BUILD_CHILD_SCHEMA_VERSION, + type: 'result', + status: 'failure', + stage: failure.stage, + substage: failure.substage ?? null, + diagnostics: failure.diagnostics, + cleanupDiagnostics: failure.cleanupDiagnostics, + }; +}; + +const failureFromRecord = record => { + const failure = windowsAuthorityFailure(record.stage, record.substage, record.diagnostics); + return record.cleanupDiagnostics.length > 0 ? addCleanupDiagnostic(failure) : failure; +}; + +const buildChildEnvironment = env => { + const childEnvironment = {}; + for (const name of ['SystemRoot', 'windir']) { + const value = env[name]; + if (typeof value === 'string' && value.length <= 520 && !value.includes('\0')) childEnvironment[name] = value; + } + for (const name of [ + 'PROPR_WINDOWS_AUTHORITY_TEST_AUTH_FAILURE', + 'PROPR_WINDOWS_AUTHORITY_TEST_DIRECTORY_PROBE_FAULT', + ]) { + const value = env[name]; + if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value)) childEnvironment[name] = value; + } + return childEnvironment; +}; + +const sendBuildChildRecord = record => new Promise((resolveSend, rejectSend) => { + if (typeof process.send !== 'function' || !boundedIpcRecord(record)) { + rejectSend(windowsAuthorityFailure('BUILD_COMPILER', 'EXIT')); + return; + } + process.send(record, error => { if (error) rejectSend(error); else resolveSend(); }); +}); + +const runWindowsBuildChild = (env, launcher) => new Promise((resolveChild, rejectChild) => { + let child; + try { + child = fork(fileURLToPath(import.meta.url), [WINDOWS_BUILD_CHILD_ARGUMENT], { + cwd: desktopRoot, + env: buildChildEnvironment(env), + execArgv: [], + serialization: 'json', + windowsHide: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + } catch { + rejectChild(windowsAuthorityFailure('BUILD_COMPILER', 'SPAWN')); + return; + } + const evidence = []; + let resultRecord; + let protocolFailed = false; + let spawnFailed = false; + let timedOut = false; + let messageCount = 0; + const terminate = () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }; + const timer = setTimeout(() => { timedOut = true; terminate(); }, WINDOWS_BUILD_CHILD_TIMEOUT_MS); + child.on('message', message => { + messageCount += 1; + if (messageCount > WINDOWS_BUILD_CHILD_MAX_MESSAGES || !boundedIpcRecord(message)) { + protocolFailed = true; + terminate(); + return; + } + if (validEvidenceRecord(message)) { + if (resultRecord || message.value !== WINDOWS_BUILD_CHILD_EVIDENCE[evidence.length]) { + protocolFailed = true; + terminate(); + return; + } + evidence.push(message.value); + process.stderr.write(`[win-authority:BUILD_CHILD:${message.value}]\n`); + return; + } + if (!resultRecord && validResultRecord(message)) { + resultRecord = message; + if (message.status === 'failure') terminate(); + } else { protocolFailed = true; terminate(); } + }); + child.once('error', () => { spawnFailed = true; terminate(); }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (timedOut) rejectChild(windowsAuthorityFailure('BUILD_COMPILER', 'TIMEOUT')); + else if (spawnFailed) rejectChild(windowsAuthorityFailure('BUILD_COMPILER', 'SPAWN')); + else if (protocolFailed || !resultRecord) rejectChild(windowsAuthorityFailure('BUILD_COMPILER', 'EXIT')); + else if (resultRecord.status === 'failure') { + const failure = failureFromRecord(resultRecord); + failure.buildChildEvidence = Object.freeze(evidence); + rejectChild(failure); + } + else if (code !== 0 || signal !== null + || evidence.length !== WINDOWS_BUILD_CHILD_EVIDENCE.length) rejectChild(windowsAuthorityFailure('BUILD_COMPILER', 'EXIT')); + else resolveChild(Object.freeze(evidence)); + }); + child.send(buildChildRequest(launcher), error => { + if (error) { spawnFailed = true; terminate(); } + }); +}); + +const readPublishedWindowsAuthorityResult = async launcher => { + const [output, manifestBytes] = await Promise.all([ + readHeldBuildOutput(WINDOWS_AUTHORITY_BUILD_DIRECTORY, WINDOWS_AUTHORITY_EXECUTABLE), + readHeldBuildOutput(WINDOWS_AUTHORITY_BUILD_DIRECTORY, WINDOWS_AUTHORITY_MANIFEST), + ]).catch(() => fail('BUILD_OUTPUT')); + if (manifestBytes.length > MAX_MANIFEST_BYTES || manifestBytes.at(-1) !== 0x0a + || Buffer.from(manifestBytes.toString('utf8'), 'utf8').compare(manifestBytes) !== 0) fail('BUILD_OUTPUT'); + let manifest; + try { + manifest = JSON.parse(manifestBytes.subarray(0, -1).toString('utf8')); + } catch { fail('BUILD_OUTPUT'); } + if (`${JSON.stringify(manifest)}\n` !== manifestBytes.toString('utf8') + || manifest?.schemaVersion !== 1 || manifest.name !== 'propr-windows-authority.exe' + || manifest.size !== output.length || manifest.sha256 !== sha256(output) + || manifest.launcher?.size !== launcher.size || manifest.launcher?.sha256 !== launcher.sha256 + || manifest.bootstrap?.size !== launcher.bootstrap.size + || manifest.bootstrap?.sha256 !== launcher.bootstrap.sha256) fail('BUILD_OUTPUT'); + inspectAnyCpuPe(output); + return { skipped: false, executable: WINDOWS_AUTHORITY_EXECUTABLE, manifest: WINDOWS_AUTHORITY_MANIFEST, ...manifest }; +}; + +export const buildWindowsAuthorityHelper = async (env = process.env) => { + if (process.platform !== 'win32') return { skipped: true }; + let primaryFailure; + let result; + let childEvidence; + try { + const launcher = await buildWindowsNativeLauncher({ restage: true }); + childEvidence = await runWindowsBuildChild(env, launcher); + result = await readPublishedWindowsAuthorityResult(launcher); + } catch (error) { primaryFailure = normalizeWindowsAuthorityFailure(error); } + + let cleanupFailure; + await cleanupWindowsAuthorityBuildStaging({ + fault: env.PROPR_WINDOWS_AUTHORITY_TEST_CLEANUP_FAULT === 'after-remove' ? 'after-remove' : null, + }).catch(error => { cleanupFailure = normalizeWindowsAuthorityFailure(error, 'LEASE'); }); + if (primaryFailure) throw cleanupFailure ? addCleanupDiagnostic(primaryFailure) : primaryFailure; + if (cleanupFailure) throw cleanupFailure; + await sealWindowsAuthorityDirectory(); + return { ...result, buildChildEvidence: childEvidence }; +}; + +const runBuildChildEntrypoint = async () => { + let handled = false; + process.once('message', async message => { + if (handled) return; + handled = true; + let record; + try { + const launcher = decodeBuildChildRequest(message); + const evidence = value => { + const evidenceRecord = { schemaVersion: WINDOWS_BUILD_CHILD_SCHEMA_VERSION, type: 'evidence', value }; + if (typeof process.send === 'function' && validEvidenceRecord(evidenceRecord)) process.send(evidenceRecord); + }; + await buildWindowsAuthorityHelperInner(process.env, launcher, evidence); + record = { schemaVersion: WINDOWS_BUILD_CHILD_SCHEMA_VERSION, type: 'result', status: 'success' }; + } catch (error) { record = failureRecord(error); } + try { await sendBuildChildRecord(record); } + catch { process.exitCode = 1; } + if (record.status === 'failure') process.exitCode = 1; + process.disconnect(); + }); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv[2] === WINDOWS_BUILD_CHILD_ARGUMENT) await runBuildChildEntrypoint(); + else buildWindowsAuthorityHelper().then(result => { + if (!result.skipped) process.stdout.write('Windows authority helper built and verified\n'); + }).catch(error => { + process.stderr.write(`${error instanceof Error ? error.message : 'Windows authority helper build failed'}\n`); + for (const diagnostic of error?.diagnostics ?? []) { + process.stderr.write(`Windows native build diagnostic [win-authority-build:${diagnostic}]\n`); + } + for (const diagnostic of error?.cleanupDiagnostics ?? []) { + process.stderr.write(`Windows authority cleanup diagnostic [win-authority:${diagnostic}]\n`); + } + process.exitCode = 1; + }); +} diff --git a/apps/desktop/scripts/build-windows-machine-installer.mjs b/apps/desktop/scripts/build-windows-machine-installer.mjs new file mode 100644 index 000000000..4fc408e89 --- /dev/null +++ b/apps/desktop/scripts/build-windows-machine-installer.mjs @@ -0,0 +1,383 @@ +import { execFile } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { constants as osConstants, tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, win32 } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { assertWindowsInstallerProductVersion } from './windows-installer-version.mjs'; + +const execFileAsync = promisify(execFile); +const INSTALLED_WIX_DIRECTORY = String.raw`C:\Program Files (x86)\WiX Toolset v3.14\bin`; +const WIX_VERSION = /\bversion\s+3\.14\.1(?:\.\d+)?\b/i; +const WIX_TIMEOUT_POLICY_MS = Object.freeze({ + TOOL_VERSION: 120_000, + CANDLE: 120_000, + PROBE_LIGHT: 120_000, + PRODUCTION_LIGHT: 10 * 60_000, +}); +const WIX_MAX_BUFFER_BYTES = 64 * 1024; +const WIX_DIAGNOSTIC_BYTES = 4 * 1024; +const MAX_FILES = 4096; +const MAX_PATH_BYTES = 32 * 1024; +const UPGRADE_CODE = '79D29087-5B38-4D77-93C8-5BC0F7856D59'; + +const fail = message => { throw new Error(`Windows machine installer build failed: ${message}`); }; +const xml = value => String(value).replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + +const failWixPrerequisite = () => fail('provide the official WiX Toolset 3.14.1 build directory'); + +const windowsPathIdentity = value => win32.normalize(value).replace(/^\\\\\?\\/, '').toLowerCase(); + +const selectedWixDirectory = (arch, wixDirectory) => { + if (arch === 'x64') { + if (wixDirectory && windowsPathIdentity(wixDirectory) !== windowsPathIdentity(INSTALLED_WIX_DIRECTORY)) { + failWixPrerequisite(); + } + return INSTALLED_WIX_DIRECTORY; + } + if (arch !== 'arm64' || typeof wixDirectory !== 'string' || !win32.isAbsolute(wixDirectory) + || Buffer.byteLength(wixDirectory, 'utf8') > MAX_PATH_BYTES) { + failWixPrerequisite(); + } + return wixDirectory; +}; + +export const windowsWixDirectoryForTest = selectedWixDirectory; + +const canonicalWixTool = async expected => { + try { + const stats = await lstat(expected); + if (!stats.isFile() || stats.isSymbolicLink()) failWixPrerequisite(); + const canonical = await realpath(expected); + if (windowsPathIdentity(canonical) !== windowsPathIdentity(expected)) failWixPrerequisite(); + return canonical; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Windows machine installer build failed:')) throw error; + failWixPrerequisite(); + } +}; + +const redactLiteral = (value, literal) => { + if (!literal) return value; + const escaped = literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return value.replace(new RegExp(escaped, 'gi'), ''); +}; + +const normalizedWixDiagnostic = (error, redactions) => { + const stderr = typeof error?.stderr === 'string' || Buffer.isBuffer(error?.stderr) + ? String(error.stderr) + : ''; + const stdout = typeof error?.stdout === 'string' || Buffer.isBuffer(error?.stdout) + ? String(error.stdout) + : ''; + const message = error instanceof Error ? error.message : ''; + let diagnostic = stderr.trim() || stdout.trim() || message.trim() || 'no diagnostic output'; + diagnostic = diagnostic.replace(/\r\n?/g, '\n').replace(/\u001b\[[0-9;]*m/g, ''); + for (const path of [...redactions].sort((left, right) => right.length - left.length)) { + diagnostic = redactLiteral(diagnostic, path); + } + diagnostic = diagnostic + .split('\n') + .map(line => line.replace(/^.*?(?=\(\d+(?:,\d+)?\)\s*:\s*(?:error|warning)\b)/i, '')) + .join('\n') + .replace(/\b[A-Za-z]:[\\/][^\r\n]*/g, '') + .replace(/\\\\[^\r\n]*/g, '') + .replace(/[^\t\n\x20-\x7e]/g, '?') + .trim(); + return (diagnostic || 'no diagnostic output').slice(0, WIX_DIAGNOSTIC_BYTES); +}; + +const numericWixSignal = signal => { + if (Number.isInteger(signal)) return signal; + if (typeof signal === 'string' && Number.isInteger(osConstants.signals[signal])) { + return osConstants.signals[signal]; + } + return 0; +}; + +const runWix = async (stage, executable, args, cwd, timeout, redactions = []) => { + try { + return await execFileAsync(executable, args, { + cwd, + shell: false, + windowsHide: true, + timeout, + maxBuffer: WIX_MAX_BUFFER_BYTES, + }); + } catch (error) { + const exit = Number.isInteger(error?.code) ? error.code : -1; + const signal = numericWixSignal(error?.signal); + const sensitivePaths = [executable, cwd, ...args, ...redactions] + .filter(value => typeof value === 'string' && win32.isAbsolute(value)); + const diagnostic = normalizedWixDiagnostic(error, sensitivePaths); + const wrapped = new Error( + `Windows machine installer build failed: ${stage} exit=${exit} signal=${signal}: ${diagnostic}`, + ); + wrapped.stack = wrapped.message; + throw wrapped; + } +}; + +const resolveWixToolset = async (cwd, arch, wixDirectory) => { + const directory = selectedWixDirectory(arch, wixDirectory); + const candle = await canonicalWixTool(join(directory, 'candle.exe')); + const light = await canonicalWixTool(join(directory, 'light.exe')); + const [candleVersion, lightVersion] = await Promise.all([ + runWix('CANDLE', candle, ['-?'], cwd, WIX_TIMEOUT_POLICY_MS.TOOL_VERSION), + runWix('LIGHT', light, ['-?'], cwd, WIX_TIMEOUT_POLICY_MS.TOOL_VERSION), + ]); + for (const result of [candleVersion, lightVersion]) { + if (!WIX_VERSION.test(`${result.stdout}\n${result.stderr}`)) failWixPrerequisite(); + } + return { candle, light }; +}; + +const removeTemporary = async (temporary, failed) => { + try { + await rm(temporary, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } catch { + if (!failed) fail('temporary cleanup failed'); + } +}; + +const collectTree = async root => { + const files = []; + const visit = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); + for (const entry of entries) { + const path = join(directory, entry.name); + const stats = await lstat(path, { bigint: true }); + if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) fail('special packaged entry'); + if (stats.isDirectory()) await visit(path); + else { + const name = relative(root, path); + if (!name || Buffer.byteLength(name, 'utf8') > MAX_PATH_BYTES || stats.size < 0n) fail('invalid packaged entry'); + files.push({ path, name, size: stats.size }); + if (files.length > MAX_FILES) fail('packaged entry bound'); + } + } + }; + await visit(root); + if (!files.some(entry => entry.name.toLowerCase() === 'propr-desktop.exe')) fail('canonical executable missing'); + const forbiddenAuthority = files.find(entry => /(?:^|\\)(?:windows-update-authority|windows-authority)(?:\\|$)/i.test(entry.name) + || /propr-windows-(?:authority|launcher|bootstrap)/i.test(entry.name)); + if (forbiddenAuthority) { + fail('deferred Windows update authority resource present'); + } + return files; +}; + +const directoryXml = files => { + const root = { children: new Map(), files: [] }; + for (const file of files) { + const parts = file.name.split('\\'); + let cursor = root; + for (const part of parts.slice(0, -1)) { + if (!cursor.children.has(part)) cursor.children.set(part, { children: new Map(), files: [] }); + cursor = cursor.children.get(part); + } + cursor.files.push(file); + } + let next = 0; + const components = []; + const render = (node, indent) => { + const lines = []; + for (const [name, child] of node.children) { + const directoryId = `D${next++}`; + lines.push(`${indent}`); + lines.push(render(child, `${indent} `)); + lines.push(`${indent}`); + } + for (const file of node.files) { + const componentId = `C${next++}`; + const fileId = `F${next++}`; + components.push(componentId); + lines.push(`${indent}`); + lines.push(`${indent} `); + lines.push(`${indent}`); + } + return lines.join('\n'); + }; + return { content: render(root, ' '), components }; +}; + +export const windowsMachineInstallerSourceForTest = (appDirectory, version, arch, files) => { + const tree = directoryXml(files); + const platform = arch === 'arm64' ? 'arm64' : 'x64'; + const productCode = '*'; + return ` + + + + + + + + +${tree.content} + + + + + + + + + + + + + + + + + + + + + +${tree.components.map(id => ` `).join('\n')} + + + + + +`; +}; + +export const wixProbeSourceForTest = arch => ` + + + + + + + + + + + + + +`; + +const compileWixSource = async ({ + source, + object, + output, + arch, + wix, + cwd, + lightTimeout = WIX_TIMEOUT_POLICY_MS.PROBE_LIGHT, + redactions = [], +}) => { + await runWix( + 'CANDLE', + wix.candle, + ['-nologo', '-arch', arch, '-out', object, source], + cwd, + WIX_TIMEOUT_POLICY_MS.CANDLE, + redactions, + ); + await runWix('LIGHT', wix.light, ['-nologo', '-out', output, object], cwd, lightTimeout, redactions); +}; + +const requireMsi = async path => { + try { + const bytes = await readFile(path); + if (bytes.length < 4096 || bytes.subarray(0, 8).toString('hex') !== 'd0cf11e0a1b11ae1') fail('invalid MSI output'); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Windows machine installer build failed:')) throw error; + fail('invalid MSI output'); + } +}; + +export const probeWindowsWixToolset = async ({ arch, wixDirectory }) => { + if (process.platform !== 'win32') fail('WiX Toolset 3.14.1 probe requires a Windows builder'); + if (!['x64', 'arm64'].includes(arch)) fail('arguments'); + const temporary = await mkdtemp(join(tmpdir(), 'propr-wix-probe-')); + let failed = false; + try { + const source = join(temporary, 'probe.wxs'); + const object = join(temporary, 'probe.wixobj'); + const output = join(temporary, 'probe.msi'); + const wix = await resolveWixToolset(temporary, arch, wixDirectory); + await writeFile(source, wixProbeSourceForTest(arch), { encoding: 'utf8', flag: 'wx' }); + await compileWixSource({ + source, + object, + output, + arch, + wix, + cwd: temporary, + lightTimeout: WIX_TIMEOUT_POLICY_MS.PROBE_LIGHT, + }); + await requireMsi(output); + return { arch, version: '3.14.1' }; + } catch (error) { + failed = true; + throw error; + } finally { + await removeTemporary(temporary, failed); + } +}; + +export const buildWindowsMachineInstaller = async ({ appDirectory, output, version, arch, wixDirectory }) => { + assertWindowsInstallerProductVersion(version); + if (process.platform !== 'win32') return { skipped: true }; + if (!['x64', 'arm64'].includes(arch)) fail('arguments'); + const canonicalApp = resolve(appDirectory); + const files = await collectTree(canonicalApp); + await mkdir(dirname(output), { recursive: true }); + const temporary = await mkdtemp(join(dirname(output), '.machine-installer-')); + let failed = false; + try { + const source = join(temporary, 'propr-desktop.wxs'); + const object = join(temporary, 'propr-desktop.wixobj'); + const wix = await resolveWixToolset(temporary, arch, wixDirectory); + await writeFile(source, windowsMachineInstallerSourceForTest(canonicalApp, version, arch, files), { encoding: 'utf8', flag: 'wx' }); + await compileWixSource({ + source, + object, + output, + arch, + wix, + cwd: temporary, + lightTimeout: WIX_TIMEOUT_POLICY_MS.PRODUCTION_LIGHT, + redactions: files.map(file => file.path), + }); + await requireMsi(output); + return { skipped: false, path: output, files: files.length }; + } catch (error) { + failed = true; + throw error; + } finally { + await removeTemporary(temporary, failed); + } +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv[2] === 'probe') { + await probeWindowsWixToolset({ arch: process.argv[3], wixDirectory: process.argv[4] }); + process.exit(0); + } + const [, , appDirectory, output, version, arch, wixDirectory] = process.argv; + await buildWindowsMachineInstaller({ + appDirectory, + output, + version, + arch, + wixDirectory, + }); +} diff --git a/apps/desktop/scripts/build-windows-machine-installer.test.mjs b/apps/desktop/scripts/build-windows-machine-installer.test.mjs new file mode 100644 index 000000000..7abfdf74e --- /dev/null +++ b/apps/desktop/scripts/build-windows-machine-installer.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + buildWindowsMachineInstaller, + windowsMachineInstallerSourceForTest, + windowsWixDirectoryForTest, + wixProbeSourceForTest, +} from './build-windows-machine-installer.mjs'; +import { + assertWindowsInstallerProductVersion, + WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR, +} from './windows-installer-version.mjs'; + +const installerScript = readFileSync(new URL('./build-windows-machine-installer.mjs', import.meta.url), 'utf8'); + +const assertExplicitCodepages = source => { + assert.match(source, /]*\bCodepage="1252"[^>]*>/); + assert.match(source, /]*\bSummaryCodepage="1252"[^>]*\/>/); + assert.match(source, /Manufacturer="Unchained Development OÜ"/); + assert.equal(source.match(/\bCodepage="1252"/g)?.length, 1); + assert.equal(source.match(/\bSummaryCodepage="1252"/g)?.length, 1); +}; + +test('sets explicit Windows-1252 MSI and summary code pages in probe and production WXS', () => { + const files = [{ + path: 'C:\\fixture\\propr-desktop.exe', + name: 'propr-desktop.exe', + size: 1n, + }]; + + assertExplicitCodepages(wixProbeSourceForTest('x64')); + assertExplicitCodepages(wixProbeSourceForTest('arm64')); + assertExplicitCodepages(windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', 'x64', files)); + assertExplicitCodepages(windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', 'arm64', files)); +}); + +test('accepts the exact MSI ProductVersion boundary and retains version and upgrade identity in WXS', () => { + const version = assertWindowsInstallerProductVersion('255.255.65535'); + const files = [{ + path: 'C:\\fixture\\propr-desktop.exe', + name: 'propr-desktop.exe', + size: 1n, + }]; + const boundarySource = windowsMachineInstallerSourceForTest('C:\\fixture', version, 'x64', files); + const ordinarySource = windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', 'x64', files); + + assert.match( + boundarySource, + //); + assert.match(source, / { + for (const version of [ + '256.0.0', + '0.256.0', + '0.0.65536', + `${'9'.repeat(10_000)}.0.0`, + '01.2.3', + '1.02.3', + '1.2.03', + 'v1.2.3', + '+1.2.3', + '-1.2.3', + '1.-2.3', + '1.2.+3', + '1.2.3.4', + '1.2.3.', + '1.2', + '1.2.3-rc.1', + '255.255.65535-rc.1', + ]) { + await assert.rejects( + buildWindowsMachineInstaller({ + appDirectory: 'unused', + output: 'unused', + version, + arch: 'x64', + }), + { message: WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR }, + ); + } +}); + +test('uses per-machine scope without explicitly authoring the derived ALLUSERS property', () => { + const files = [{ + path: 'C:\\fixture\\propr-desktop.exe', + name: 'propr-desktop.exe', + size: 1n, + }]; + + for (const arch of ['x64', 'arm64']) { + const source = windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', arch, files); + assert.match(source, /]*\bInstallScope="perMachine"[^>]*\/>/); + assert.doesNotMatch(source, /]*\bId="ALLUSERS"(?:\s|\/|>)/); + } +}); + +test('authors the complete per-machine Start Menu contract for x64 and ARM64', () => { + const files = [{ + path: 'C:\\fixture\\propr-desktop.exe', + name: 'propr-desktop.exe', + size: 1n, + }]; + + for (const arch of ['x64', 'arm64']) { + const source = windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', arch, files); + const registration = source.match(//)?.[0]; + const shortcut = source.match(//)?.[0]; + assert.ok(registration); + assert.ok(shortcut); + assert.match(source, /]*\bInstallScope="perMachine"[^>]*\/>/); + assert.equal(registration.match(/Root="HKLM"/g)?.length, 4); + assert.equal(registration.match(/KeyPath="yes"/g)?.length, 1); + assert.doesNotMatch(registration, /Root="HKCU"|/); + assert.match(shortcut, //); + assert.match(shortcut, /]*On="uninstall" \/>/); + assert.match( + shortcut, + //, + ); + assert.equal(shortcut.match(/KeyPath="yes"/g)?.length, 1); + assert.equal(shortcut.match(/Root="HKCU"/g)?.length, 1); + assert.doesNotMatch(shortcut, /\bWin64=|Root="HKLM"/); + assert.match(source, /\s*/); + assert.match( + source, + /[\s\S]*\s*<\/Directory>\s*<\/Directory>\s*/, + ); + assert.doesNotMatch(source, /\bCommonProgramMenuFolder\b/); + assert.match(source, //); + assert.match(source, //); + } +}); + +test('selects only the installed x64 WiX directory or an explicit ARM64 build directory', () => { + const installed = String.raw`C:\Program Files (x86)\WiX Toolset v3.14\bin`; + const provisioned = String.raw`D:\runner-temp\propr-wix3141-arm64`; + assert.equal(windowsWixDirectoryForTest('x64'), installed); + assert.equal(windowsWixDirectoryForTest('x64', installed), installed); + assert.equal(windowsWixDirectoryForTest('arm64', provisioned), provisioned); + assert.throws(() => windowsWixDirectoryForTest('x64', provisioned), /official WiX Toolset 3\.14\.1 build directory/); + assert.throws(() => windowsWixDirectoryForTest('arm64'), /official WiX Toolset 3\.14\.1 build directory/); + assert.throws(() => windowsWixDirectoryForTest('arm64', 'relative'), /official WiX Toolset 3\.14\.1 build directory/); + assert.match(installerScript, /const INSTALLED_WIX_DIRECTORY = String\.raw`C:\\Program Files \(x86\)\\WiX Toolset v3\.14\\bin`;/); + assert.match(installerScript, /if \(arch === 'x64'\)/); + assert.match(installerScript, /wixDirectory && windowsPathIdentity\(wixDirectory\) !== windowsPathIdentity\(INSTALLED_WIX_DIRECTORY\)/); + assert.match(installerScript, /arch !== 'arm64'.*!win32\.isAbsolute\(wixDirectory\)/s); + assert.match(installerScript, /canonicalWixTool\(join\(directory, 'candle\.exe'\)\)/); + assert.match(installerScript, /canonicalWixTool\(join\(directory, 'light\.exe'\)\)/); + assert.doesNotMatch(installerScript, /process\.env\.PATH|choco|electron-winstaller|wixVendor/); +}); + +test('uses a ten-minute timeout only for production Light', () => { + assert.match(installerScript, /TOOL_VERSION: 120_000,/); + assert.match(installerScript, /CANDLE: 120_000,/); + assert.match(installerScript, /PROBE_LIGHT: 120_000,/); + assert.match(installerScript, /PRODUCTION_LIGHT: 10 \* 60_000,/); + assert.match(installerScript, /runWix\('CANDLE', candle, \['-\?'\], cwd, WIX_TIMEOUT_POLICY_MS\.TOOL_VERSION\)/); + assert.match(installerScript, /runWix\('LIGHT', light, \['-\?'\], cwd, WIX_TIMEOUT_POLICY_MS\.TOOL_VERSION\)/); + assert.match(installerScript, /WIX_TIMEOUT_POLICY_MS\.CANDLE,\s+redactions,/); + assert.match(installerScript, /lightTimeout: WIX_TIMEOUT_POLICY_MS\.PROBE_LIGHT,/); + assert.match(installerScript, /lightTimeout: WIX_TIMEOUT_POLICY_MS\.PRODUCTION_LIGHT,/); +}); + +test('keeps WiX processes and their emitted diagnostics bounded', () => { + assert.match(installerScript, /shell: false,/); + assert.match(installerScript, /timeout,/); + assert.match(installerScript, /const WIX_MAX_BUFFER_BYTES = 64 \* 1024;/); + assert.match(installerScript, /const WIX_DIAGNOSTIC_BYTES = 4 \* 1024;/); + assert.match(installerScript, /maxBuffer: WIX_MAX_BUFFER_BYTES/); + assert.match(installerScript, /\.slice\(0, WIX_DIAGNOSTIC_BYTES\)/); + assert.ok(installerScript.includes('${stage} exit=${exit} signal=${signal}: ${diagnostic}')); +}); + +test('emits WiX v3 default registry values without empty Name attributes', () => { + const source = windowsMachineInstallerSourceForTest('C:\\fixture', '1.2.3', 'x64', [{ + path: 'C:\\fixture\\propr-desktop.exe', + name: 'propr-desktop.exe', + size: 1n, + }]); + + assert.match( + source, + //, + ); + assert.match( + source, + //, + ); + assert.match( + source, + //, + ); + assert.match( + source, + //, + ); + assert.doesNotMatch(source, /\bName=""/); +}); diff --git a/apps/desktop/scripts/build-windows-native-launcher.d.mts b/apps/desktop/scripts/build-windows-native-launcher.d.mts new file mode 100644 index 000000000..9b837e383 --- /dev/null +++ b/apps/desktop/scripts/build-windows-native-launcher.d.mts @@ -0,0 +1,24 @@ +export const WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY: string; +export const WINDOWS_NATIVE_LAUNCHER: string; +export const WINDOWS_NATIVE_BOOTSTRAP: string; +export const WINDOWS_NATIVE_BUILD_BOOTSTRAP: string; +export const WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY: string; +export const WINDOWS_NATIVE_AUTHORITY_DIRECTORY: string; + +export function prepareWindowsAuthorityBuildDirectory(root?: string): Promise; +export function sealWindowsAuthorityDirectory(root?: string): Promise; +export function cleanupWindowsAuthorityBuildStaging(): Promise; +export function resolveWindowsAclTool(tool: string): Promise; +export function invokeWindowsAclTool( + tool: string, + args: readonly string[], + invoke?: (tool: string, args: readonly string[], options: Record) => Promise, +): Promise; + +export function inspectWindowsNativeLauncherPe(bytes: Buffer, expectedArchitecture: string): { + format: 'PE'; + architecture: string; + machine: 'ARM64' | 'AMD64'; +}; + +export function buildWindowsNativeLauncher(): Promise>; diff --git a/apps/desktop/scripts/build-windows-native-launcher.mjs b/apps/desktop/scripts/build-windows-native-launcher.mjs new file mode 100644 index 000000000..78103fc29 --- /dev/null +++ b/apps/desktop/scripts/build-windows-native-launcher.mjs @@ -0,0 +1,464 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises'; +import { join, resolve, win32 as windowsPath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +const repositoryRoot = resolve(desktopRoot, '..', '..'); +export const WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY = join(desktopRoot, 'src', 'native', 'windows-launcher'); +export const WINDOWS_NATIVE_LAUNCHER = join(desktopRoot, 'build', 'windows-authority', 'propr-windows-launcher.node'); +export const WINDOWS_NATIVE_BOOTSTRAP = join(desktopRoot, 'build', 'windows-authority', 'propr-windows-bootstrap.node'); +export const WINDOWS_NATIVE_AUTHORITY_DIRECTORY = join(desktopRoot, 'build', 'windows-authority'); +export const WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY = join(WINDOWS_NATIVE_AUTHORITY_DIRECTORY, '.build-staging'); +export const WINDOWS_NATIVE_BUILD_BOOTSTRAP = join(WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY, + 'propr-windows-build-bootstrap.node'); +const MAX_LAUNCHER_BYTES = 4 * 1024 * 1024; +const MAX_ACL_TOOL_BYTES = 4 * 1024 * 1024; +const WINDOWS_NATIVE_REBUILD_TIMEOUT_MS = 6 * 60_000; +const WINDOWS_NATIVE_REBUILD_PROGRESS_INTERVAL_MS = 60_000; +const WINDOWS_NATIVE_REBUILD_PROGRESS_BUCKETS = 5; +const WINDOWS_NATIVE_REBUILD_MAX_BUFFER_BYTES = 64 * 1024; +const KERNEL_TAKEOWN = String.raw`\\?\GLOBALROOT\SystemRoot\System32\takeown.exe`; +const KERNEL_ICACLS = String.raw`\\?\GLOBALROOT\SystemRoot\System32\icacls.exe`; +const KERNEL_WHOAMI = String.raw`\\?\GLOBALROOT\SystemRoot\System32\whoami.exe`; +const KERNEL_POWERSHELL = String.raw`\\?\GLOBALROOT\SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe`; +const SYSTEM_SID = '*S-1-5-18'; +const ADMINISTRATORS_SID = '*S-1-5-32-544'; +const TRUSTED_INSTALLER_SID = '*S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'; +const MAX_WHOAMI_OUTPUT_BYTES = 4 * 1024; + +const fail = (substage = 'OUTPUT_VALIDATION', diagnostics = []) => { + const error = new Error(`Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]`); + error.stage = 'BUILD_COMPILER'; + error.substage = substage; + error.code = substage; + error.diagnostics = Object.freeze([...diagnostics]); + throw error; +}; +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +// These records are generated by this parent, never copied from node-gyp or +// the hosted toolchain. Their fixed vocabulary and count provide coarse CI +// liveness without disclosing paths, environment, or arbitrary build output. +const nativeRebuildEvidence = value => { + process.stderr.write(`[win-authority:BUILD_COMPILER:NATIVE_REBUILD:${value}]\n`); +}; + +const BUILD_DIAGNOSTIC_LIMIT = 8; +const diagnosticRecord = (file, line, code) => `${file}:${line}:${code}`; + +// node-gyp output contains checkout paths, SDK paths, user profiles and the +// complete inherited build environment. Preserve only a bounded compiler +// location/code tuple rooted at the committed source basename. +export const sanitizeWindowsNativeBuildDiagnostics = output => { + const text = Buffer.isBuffer(output) ? output.toString('utf8') : typeof output === 'string' ? output : ''; + const diagnostics = []; + const seen = new Set(); + const patterns = [ + /(?:^|[\\/])(propr_windows_launcher\.cc)\((\d+)(?:,\d+)?\)\s*:\s*(?:fatal\s+)?error\s+(C\d{4})\b/gim, + /(?:^|[\\/])(propr_windows_launcher\.(?:cc|obj))\s*:\s*(?:fatal\s+)?error\s+(LNK\d{4})\b/gim, + /\b(?:fatal\s+)?error\s+(LNK\d{4})\b/gim, + ]; + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const value = match[3] + ? diagnosticRecord(match[1], match[2], match[3].toUpperCase()) + : match[2] + ? diagnosticRecord(match[1], '0', match[2].toUpperCase()) + : diagnosticRecord('link', '0', match[1].toUpperCase()); + if (!seen.has(value)) { + seen.add(value); + diagnostics.push(value); + } + if (diagnostics.length === BUILD_DIAGNOSTIC_LIMIT) return Object.freeze(diagnostics); + } + } + return Object.freeze(diagnostics); +}; + +export const classifyWindowsNativeBuildFailure = error => { + const code = error && typeof error === 'object' ? error.code : undefined; + if (code === 'EINVAL' || code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') return 'SPAWN'; + if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error?.name === 'RangeError' + && /maxBuffer/i.test(String(error?.message ?? ''))) return 'OUTPUT_LIMIT'; + if (error?.killed === true && error?.signal) return 'TIMEOUT'; + const diagnostics = sanitizeWindowsNativeBuildDiagnostics(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`); + if (diagnostics.some(value => /:LNK\d{4}$/.test(value))) return 'LINK'; + if (diagnostics.some(value => /:C\d{4}$/.test(value))) return 'COMPILE'; + return 'EXIT'; +}; + +const sameFileIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino + && left.size === right.size && left.nlink === right.nlink; + +const normalDosExecutable = (path, basename) => { + const candidate = /^\\\\\?\\[A-Za-z]:\\/.test(path) ? path.slice(4) : path; + if (!/^[A-Za-z]:\\[^\0]+$/.test(candidate) || candidate.startsWith('\\\\') + || windowsPath.isAbsolute(candidate) !== true || candidate.indexOf(':', 2) >= 0 + || windowsPath.basename(candidate).toLowerCase() !== basename + || windowsPath.basename(windowsPath.dirname(candidate)).toLowerCase() !== 'system32') { + fail('DIRECTORY_PROBE'); + } + return candidate; +}; + +const normalDosPowerShell = path => { + const candidate = /^\\\\\?\\[A-Za-z]:\\/.test(path) ? path.slice(4) : path; + if (!/^[A-Za-z]:\\[^\0]+$/.test(candidate) || candidate.startsWith('\\\\') + || windowsPath.isAbsolute(candidate) !== true || candidate.indexOf(':', 2) >= 0 + || !candidate.toLowerCase().endsWith('\\system32\\windowspowershell\\v1.0\\powershell.exe')) { + fail('DIRECTORY_PROBE'); + } + return candidate; +}; + +const resolveWindowsFixedOsFile = async (tool, canonicalPath) => { + const targetStats = await lstat(tool, { bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + if (!targetStats.isFile() || targetStats.isSymbolicLink() || targetStats.nlink < 1n + || targetStats.size <= 0n || targetStats.size > BigInt(MAX_ACL_TOOL_BYTES)) fail('DIRECTORY_PROBE'); + const target = await open(tool, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => fail('DIRECTORY_PROBE')); + let canonical; + try { + const targetBefore = await target.stat({ bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + if (!sameFileIdentity(targetBefore, targetStats)) fail('DIRECTORY_PROBE'); + canonical = canonicalPath(await realpath(tool).catch(() => fail('DIRECTORY_PROBE'))); + const canonicalStats = await lstat(canonical, { bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + if (!canonicalStats.isFile() || canonicalStats.isSymbolicLink() + || !sameFileIdentity(canonicalStats, targetBefore)) fail('DIRECTORY_PROBE'); + const canonicalHandle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => fail('DIRECTORY_PROBE')); + try { + const canonicalBefore = await canonicalHandle.stat({ bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + const targetAfter = await target.stat({ bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + const canonicalAfter = await canonicalHandle.stat({ bigint: true }).catch(() => fail('DIRECTORY_PROBE')); + if (!sameFileIdentity(targetBefore, targetAfter) || !sameFileIdentity(targetBefore, canonicalBefore) + || !sameFileIdentity(canonicalBefore, canonicalAfter)) fail('DIRECTORY_PROBE'); + } finally { await canonicalHandle.close().catch(() => undefined); } + } finally { await target.close().catch(() => undefined); } + return canonical; +}; + +// CreateProcess does not accept the fixed GLOBALROOT spelling. Retain the +// exact kernel-rooted file while realpath resolves its normal DOS spelling, +// then prove that spelling opens the same non-reparse OS file before launch. +export const resolveWindowsAclTool = async tool => { + const basename = tool === KERNEL_TAKEOWN ? 'takeown.exe' + : tool === KERNEL_ICACLS ? 'icacls.exe' + : tool === KERNEL_WHOAMI ? 'whoami.exe' : fail('DIRECTORY_PROBE'); + return resolveWindowsFixedOsFile(tool, path => normalDosExecutable(path, basename)); +}; + +export const invokeWindowsAclTool = async (tool, args, invoke = execFileAsync) => { + try { + await invoke(tool, args, { + windowsHide: true, + timeout: 30_000, + maxBuffer: 64 * 1024, + env: {}, + }); + } catch { fail('SPAWN'); } +}; + +const authorityAclTool = async (tool, args) => { + const canonical = await resolveWindowsAclTool(tool); + await invokeWindowsAclTool(canonical, args); +}; + +const canonicalAccountSid = value => { + if (typeof value !== 'string') return false; + const fields = value.split('-'); + if (fields.length !== 8 || fields[0] !== 'S' || fields[1] !== '1' + || !((fields[2] === '5' && fields[3] === '21') || (fields[2] === '12' && fields[3] === '1'))) return false; + return fields.slice(2).every(field => /^(?:0|[1-9]\d{0,9})$/.test(field) + && BigInt(field) <= 0xffff_ffffn); +}; + +// whoami.exe is resolved from the fixed protected System32 object, receives no +// inherited environment, and returns one bounded CSV record. The account name +// is deliberately ignored: only the kernel-derived token SID is authority. +export const decodeWindowsCurrentTokenSid = output => { + const text = Buffer.isBuffer(output) ? output.toString('utf8') : typeof output === 'string' ? output : ''; + if (Buffer.byteLength(text, 'utf8') > MAX_WHOAMI_OUTPUT_BYTES || text.includes('\0')) fail('BOOTSTRAP_AUTH'); + const match = /^(?:\ufeff)?"(?:[^"]|"")*","(S-[0-9-]+)"\r?\n?$/.exec(text); + if (!match || !canonicalAccountSid(match[1])) fail('BOOTSTRAP_AUTH'); + return match[1]; +}; + +export const decodeWindowsDirectoryOwnerSid = output => { + const text = Buffer.isBuffer(output) ? output.toString('utf8') : typeof output === 'string' ? output : ''; + if (Buffer.byteLength(text, 'utf8') > MAX_WHOAMI_OUTPUT_BYTES || text.includes('\0')) fail('BOOTSTRAP_AUTH'); + const match = /^(S-[0-9-]+)\r?\n?$/.exec(text); + if (!match || !canonicalAccountSid(match[1])) fail('BOOTSTRAP_AUTH'); + return match[1]; +}; + +const currentWindowsTokenSid = async root => { + const whoami = await resolveWindowsAclTool(KERNEL_WHOAMI); + let result; + try { + result = await execFileAsync(whoami, ['/user', '/fo', 'csv', '/nh'], { + windowsHide: true, + timeout: 30_000, + maxBuffer: MAX_WHOAMI_OUTPUT_BYTES, + encoding: 'utf8', + env: {}, + }); + } catch { fail('BOOTSTRAP_AUTH'); } + if (result.stderr !== '') fail('BOOTSTRAP_AUTH'); + const sid = decodeWindowsCurrentTokenSid(result.stdout); + const powershell = await resolveWindowsFixedOsFile(KERNEL_POWERSHELL, normalDosPowerShell); + const rootBytes = Buffer.from(root, 'utf16le'); + if (rootBytes.length === 0 || rootBytes.length > 2048) fail('BOOTSTRAP_AUTH'); + // Cross-check the token SID against the owner takeown just assigned. Encode + // the bounded pathname into the fixed command so PowerShell cannot reinterpret + // it as command text, and accept only the one canonical SID output record. + const ownerProbe = `$p=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${rootBytes.toString('base64')}'));` + + '[IO.Directory]::GetAccessControl($p,[Security.AccessControl.AccessControlSections]::Owner)' + + '.GetOwner([Security.Principal.SecurityIdentifier]).Value'; + const encodedOwnerProbe = Buffer.from(ownerProbe, 'utf16le').toString('base64'); + try { + result = await execFileAsync(powershell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedOwnerProbe], { + windowsHide: true, + timeout: 30_000, + maxBuffer: MAX_WHOAMI_OUTPUT_BYTES, + encoding: 'utf8', + env: {}, + }); + } catch { fail('BOOTSTRAP_AUTH'); } + if (result.stderr !== '' || decodeWindowsDirectoryOwnerSid(result.stdout) !== sid) fail('BOOTSTRAP_AUTH'); + return sid; +}; + +const exactAuthorityDirectory = async root => { + const pathStats = await lstat(root).catch(() => null); + if (!pathStats) return false; + if (!pathStats.isDirectory() || pathStats.isSymbolicLink() + || (await realpath(root).catch(() => fail('DIRECTORY_PROBE'))).toLowerCase() !== resolve(root).toLowerCase()) { + fail('DIRECTORY_PROBE'); + } + return true; +}; + +// Build steps are the only writers. Reopening a previously sealed tree is an +// explicit trusted-build transition, never part of runtime authorization. +export const prepareWindowsAuthorityBuildDirectory = async (root = WINDOWS_NATIVE_AUTHORITY_DIRECTORY) => { + if (process.platform !== 'win32') return; + await mkdir(root, { recursive: true }).catch(() => fail('DIRECTORY_PROBE')); + if (!(await exactAuthorityDirectory(root))) fail('DIRECTORY_PROBE'); + // Keep build ownership distinct from packaged authority: the build-only + // bootstrap may admit this exact current owner, while the runtime bootstrap + // must continue to reject it until sealWindowsAuthorityDirectory transfers + // ownership to SYSTEM. + await authorityAclTool(KERNEL_TAKEOWN, ['/F', root, '/R', '/SKIPSL']); + const currentSid = await currentWindowsTokenSid(root); + // /grant:r replaces only ACEs for its named trustees. Reset the complete + // tree first so a hostile explicit trustee cannot survive build staging. + await authorityAclTool(KERNEL_ICACLS, [root, '/reset', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/inheritance:r', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/grant:r', `${ADMINISTRATORS_SID}:(OI)(CI)F`, + `${SYSTEM_SID}:(OI)(CI)F`, `*${currentSid}:(OI)(CI)M`, '/T', '/C', '/Q']); + return currentSid; +}; + +// Hosted runners do not consistently materialize the recursive directory ACL +// transition as an exact protected child-file descriptor. Apply the same +// already-authorized principals directly to each newly created build artifact, +// with no inheritance flags, and set its owner to the fixed token SID that was +// independently derived and checked above. The build bootstrap revalidates +// every predicate from one held handle before loading the launcher. +const protectWindowsBuildArtifact = async (target, currentSid) => { + if (!canonicalAccountSid(currentSid)) fail('BOOTSTRAP_AUTH'); + await authorityAclTool(KERNEL_ICACLS, [target, '/reset', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [target, '/inheritance:r', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [target, '/grant:r', `${ADMINISTRATORS_SID}:F`, + `${SYSTEM_SID}:F`, `*${currentSid}:M`, '/Q']); + await authorityAclTool(KERNEL_ICACLS, [target, '/setowner', `*${currentSid}`, '/Q']); +}; + +// Publish an OS-owned, protected, read/execute-only application authority. +// The verifier independently re-reads every effective explicit and inherited +// ACE from held handles; these setup operations are never accepted as proof. +export const sealWindowsAuthorityDirectory = async (root = WINDOWS_NATIVE_AUTHORITY_DIRECTORY) => { + if (process.platform !== 'win32' || !(await exactAuthorityDirectory(root))) fail('DIRECTORY_PROBE'); + // Reset first so an explicit SID planted during the build cannot survive the + // transition merely because /grant:r only replaces ACEs for named trustees. + await authorityAclTool(KERNEL_ICACLS, [root, '/reset', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/inheritance:r', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/grant:r', `${SYSTEM_SID}:(OI)(CI)F`, + `${TRUSTED_INSTALLER_SID}:(OI)(CI)F`, `${ADMINISTRATORS_SID}:(OI)(CI)RX`, '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/setowner', SYSTEM_SID, '/T', '/C', '/Q']); +}; + +export const inspectWindowsNativeLauncherPe = (bytes, expectedArchitecture) => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > MAX_LAUNCHER_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) fail(); + const pe = bytes.readUInt32LE(0x3c); + if (pe < 0x40 || pe + 24 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0') fail(); + const machine = bytes.readUInt16LE(pe + 4); + const expectedMachine = expectedArchitecture === 'arm64' ? 0xaa64 : expectedArchitecture === 'x64' ? 0x8664 : -1; + if (machine !== expectedMachine) fail(); + return { format: 'PE', architecture: expectedArchitecture, machine: expectedMachine === 0xaa64 ? 'ARM64' : 'AMD64' }; +}; + +const heldBytes = async path => { + const canonical = await realpath(path).catch(() => fail('OUTPUT_VALIDATION')); + if ((process.platform === 'win32' ? canonical.toLowerCase() : canonical) !== (process.platform === 'win32' + ? resolve(path).toLowerCase() : resolve(path))) fail('OUTPUT_VALIDATION'); + const pathStats = await lstat(path, { bigint: true }).catch(() => fail('OUTPUT_VALIDATION')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_LAUNCHER_BYTES)) fail('OUTPUT_VALIDATION'); + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => fail('OUTPUT_VALIDATION')); + try { + const before = await handle.stat({ bigint: true }); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== 1n || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size + || BigInt(bytes.length) !== before.size) fail('OUTPUT_VALIDATION'); + return bytes; + } finally { await handle.close(); } +}; + +const publishHeldArtifact = async (target, bytes, expectedArchitecture) => { + await rm(target, { force: true }).catch(() => fail('OUTPUT_VALIDATION')); + const handle = await open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600) + .catch(() => fail('OUTPUT_VALIDATION')); + try { + await handle.writeFile(bytes); + await handle.sync(); + } catch { fail('OUTPUT_VALIDATION'); } + finally { await handle.close().catch(() => undefined); } + const published = await heldBytes(target); + if (!published.equals(bytes)) fail('OUTPUT_VALIDATION'); + inspectWindowsNativeLauncherPe(published, expectedArchitecture); +}; + +export const cleanupWindowsAuthorityBuildStaging = async (options = {}) => { + if (process.platform !== 'win32') return; + await rm(WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY, { recursive: true, force: true }) + .catch(() => fail('LEASE')); + await lstat(WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY).then( + () => fail('LEASE'), + error => { if (error?.code !== 'ENOENT') fail('LEASE'); }, + ); + if (options.fault === 'after-remove') fail('LEASE'); +}; + +let launcherBuild; + +const stageWindowsNativeLauncher = async (expected, options = {}) => { + const built = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build', 'Release', 'propr_windows_launcher.node'); + const builtBootstrap = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build', 'Release', 'propr_windows_bootstrap.node'); + const builtBuildBootstrap = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build', 'Release', + 'propr_windows_build_bootstrap.node'); + const bytes = await heldBytes(built); + const bootstrapBytes = await heldBytes(builtBootstrap); + const buildBootstrapBytes = await heldBytes(builtBuildBootstrap); + const pe = inspectWindowsNativeLauncherPe(bytes, process.arch); + const bootstrapPe = inspectWindowsNativeLauncherPe(bootstrapBytes, process.arch); + const buildBootstrapPe = inspectWindowsNativeLauncherPe(buildBootstrapBytes, process.arch); + if (expected && (expected.size !== bytes.length || expected.sha256 !== sha256(bytes) + || expected.bootstrap.size !== bootstrapBytes.length || expected.bootstrap.sha256 !== sha256(bootstrapBytes) + || expected.buildBootstrap.size !== buildBootstrapBytes.length + || expected.buildBootstrap.sha256 !== sha256(buildBootstrapBytes))) fail('OUTPUT_VALIDATION'); + nativeRebuildEvidence('OUTPUT_VERIFIED'); + await prepareWindowsAuthorityBuildDirectory(WINDOWS_NATIVE_AUTHORITY_DIRECTORY); + await prepareWindowsAuthorityBuildDirectory(WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY); + if (options.buildBootstrapOnly === true) { + const [publishedLauncher, publishedBootstrap] = await Promise.all([ + heldBytes(WINDOWS_NATIVE_LAUNCHER), heldBytes(WINDOWS_NATIVE_BOOTSTRAP), + ]); + if (!publishedLauncher.equals(bytes) || !publishedBootstrap.equals(bootstrapBytes)) fail('OUTPUT_VALIDATION'); + } else { + await publishHeldArtifact(WINDOWS_NATIVE_LAUNCHER, bytes, process.arch); + await publishHeldArtifact(WINDOWS_NATIVE_BOOTSTRAP, bootstrapBytes, process.arch); + } + await publishHeldArtifact(WINDOWS_NATIVE_BUILD_BOOTSTRAP, buildBootstrapBytes, process.arch); + // Newly created children must themselves carry protected DACLs; a protected + // parent alone does not make a child's security descriptor authoritative. + const authorityOwnerSid = await prepareWindowsAuthorityBuildDirectory(WINDOWS_NATIVE_AUTHORITY_DIRECTORY); + const stagingOwnerSid = await prepareWindowsAuthorityBuildDirectory(WINDOWS_NATIVE_BUILD_STAGING_DIRECTORY); + if (authorityOwnerSid !== stagingOwnerSid) fail('BOOTSTRAP_AUTH'); + await protectWindowsBuildArtifact(WINDOWS_NATIVE_LAUNCHER, authorityOwnerSid); + nativeRebuildEvidence('STAGED'); + return { + skipped: false, + path: WINDOWS_NATIVE_LAUNCHER, + name: 'propr-windows-launcher.node', + size: bytes.length, + sha256: sha256(bytes), + bootstrap: { + path: WINDOWS_NATIVE_BOOTSTRAP, + name: 'propr-windows-bootstrap.node', + size: bootstrapBytes.length, + sha256: sha256(bootstrapBytes), + ...bootstrapPe, + }, + // This current-owner build capability exists only in the protected, + // unshipped staging boundary and is removed before package sealing. It is + // never represented in the runtime manifest. + buildBootstrap: { + path: WINDOWS_NATIVE_BUILD_BOOTSTRAP, + size: buildBootstrapBytes.length, + sha256: sha256(buildBootstrapBytes), + ...buildBootstrapPe, + }, + ...pe, + }; +}; + +const buildWindowsNativeLauncherOnce = async () => { + if (process.platform !== 'win32') return { skipped: true }; + if (process.arch !== 'x64' && process.arch !== 'arm64') fail('OUTPUT_VALIDATION'); + await prepareWindowsAuthorityBuildDirectory(); + const nodeGyp = join(repositoryRoot, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'); + const nativeBuildDirectory = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build'); + let progressBucket = 0; + nativeRebuildEvidence('STARTED'); + const progress = setInterval(() => { + if (progressBucket >= WINDOWS_NATIVE_REBUILD_PROGRESS_BUCKETS) return; + progressBucket += 1; + nativeRebuildEvidence(`ACTIVE_${progressBucket}`); + }, WINDOWS_NATIVE_REBUILD_PROGRESS_INTERVAL_MS); + try { + await execFileAsync(process.execPath, [nodeGyp, 'rebuild', '--directory', WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, + `--arch=${process.arch}`], { + cwd: repositoryRoot, + windowsHide: true, + timeout: WINDOWS_NATIVE_REBUILD_TIMEOUT_MS, + killSignal: 'SIGKILL', + maxBuffer: WINDOWS_NATIVE_REBUILD_MAX_BUFFER_BYTES, + }); + nativeRebuildEvidence('PROCESS_COMPLETE'); + } catch (error) { + await rm(nativeBuildDirectory, { recursive: true, force: true }) + .then(() => nativeRebuildEvidence('FAILED_CLEANED'), () => undefined); + const diagnostics = sanitizeWindowsNativeBuildDiagnostics(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`); + fail(classifyWindowsNativeBuildFailure(error), diagnostics); + } finally { clearInterval(progress); } + return stageWindowsNativeLauncher(); +}; + +export const buildWindowsNativeLauncher = async (options = {}) => { + if (process.platform !== 'win32') return { skipped: true }; + if (!launcherBuild) { + launcherBuild = buildWindowsNativeLauncherOnce().catch(error => { + launcherBuild = undefined; + throw error; + }); + return launcherBuild; + } + const built = await launcherBuild; + return options.restage === true ? stageWindowsNativeLauncher(built, { buildBootstrapOnly: true }) : built; +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await buildWindowsNativeLauncher(); +} diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 new file mode 100644 index 000000000..414edbefa --- /dev/null +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -0,0 +1,1809 @@ +param( + [Parameter(Mandatory=$true)][string]$OwnershipManifest, + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$ExpectedRunId, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [string]$FixtureRoot, + [switch]$FixtureValidationDiagnostic, + [switch]$FixtureEarlyInitializationChild +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$ownerFileName = '.propr-installed-app-owner' +$ownerRegistryValue = 'ProPRInstalledAppOwner' +$cleanupFailed = $false +$manifestValidated = $false +$authorizedRunId = $null +$cleanupValidationPhase = 'HANDSHAKE' +$cleanupValidationPhases = @( + 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', + 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT', + 'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT', + 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH', + 'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE' +) + +function Write-FixtureCleanupValidationPhase([string]$Phase) { + if (!$FixtureValidationDiagnostic -or !$FixtureRoot -or + $cleanupValidationPhases -cnotcontains $Phase) { + return + } + # Diagnostic success is deliberately silent; validation exit 20 and + # post-validation exit 21 emit this single bounded child-protocol line for + # supervisor parsing. + [Console]::Out.WriteLine( + 'CLEANUP_VALIDATION_PHASE:' + $Phase + ) + [Console]::Out.Flush() +} + +function Exit-CleanupHandshakeFailure { + Write-FixtureCleanupValidationPhase 'HANDSHAKE' + if ($FixtureValidationDiagnostic -and $FixtureRoot) { exit 20 } + exit 1 +} + +try { + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { Exit-CleanupHandshakeFailure } + if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { + Exit-CleanupHandshakeFailure + } + $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) + try { + if (!$ownershipReady.WaitOne(5000)) { Exit-CleanupHandshakeFailure } + } finally { + $ownershipReady.Dispose() + } +} catch { + Exit-CleanupHandshakeFailure +} + +# This fixture runs after the ownership release but before cold type loading so +# the controller test covers descendants created at the earliest worker phase. +if ($FixtureEarlyInitializationChild) { + try { + if (!$FixtureRoot) { exit 1 } + $fixtureEarlyRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + $fixtureHostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($fixtureHostPath) -notin @('pwsh.exe', 'powershell.exe')) { + exit 1 + } + $fixtureChildStartInfo = [Diagnostics.ProcessStartInfo]::new() + $fixtureChildStartInfo.FileName = $fixtureHostPath + $fixtureChildStartInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 300' + )) { + $fixtureChildStartInfo.ArgumentList.Add($argument) + } + $fixtureChild = [Diagnostics.Process]::new() + $fixtureChild.StartInfo = $fixtureChildStartInfo + if (!$fixtureChild.Start()) { exit 1 } + $fixtureStatePath = Join-Path $fixtureEarlyRoot 'workflow-cleanup-early-processes.json' + $fixtureStateTemporaryPath = "$fixtureStatePath.$PID.new" + $fixtureStateBytes = [Text.Encoding]::ASCII.GetBytes(( + [ordered]@{ WorkerPid = $PID; DescendantPid = $fixtureChild.Id } | + ConvertTo-Json -Compress + )) + $fixtureStateStream = [IO.FileStream]::new( + $fixtureStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $fixtureStateStream.Write($fixtureStateBytes, 0, $fixtureStateBytes.Length) + $fixtureStateStream.Flush($true) + } finally { + $fixtureStateStream.Dispose() + } + [IO.File]::Move($fixtureStateTemporaryPath, $fixtureStatePath) + Start-Sleep -Seconds 300 + } catch { + exit 1 + } +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string ReadHandle(SafeFileHandle handle, bool expectDirectory) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("file-system identity handle is invalid"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + return ReadHandle(handle, expectDirectory); + } + } + + public static string Read(string path) { return ReadEntry(path, true); } +} + +public static class ProPRAtomicFile +{ + private const uint MOVEFILE_REPLACE_EXISTING = 0x1; + private const uint MOVEFILE_WRITE_THROUGH = 0x8; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "MoveFileExW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW( + string existingFileName, string newFileName, uint flags); + + public static void ReplaceSameDirectory(string temporaryPath, string destinationPath) + { + string temporaryFullPath = System.IO.Path.GetFullPath(temporaryPath); + string destinationFullPath = System.IO.Path.GetFullPath(destinationPath); + string temporaryDirectory = System.IO.Path.GetDirectoryName(temporaryFullPath); + string destinationDirectory = System.IO.Path.GetDirectoryName(destinationFullPath); + if (String.IsNullOrEmpty(temporaryDirectory) || + !String.Equals(temporaryDirectory, destinationDirectory, + StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(temporaryFullPath) || + !System.IO.File.Exists(destinationFullPath)) + { + throw new InvalidOperationException( + "atomic ownership receipt replacement precondition failed"); + } + + if (!MoveFileExW(temporaryFullPath, destinationFullPath, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, + "atomic ownership receipt replacement failed"); + } + } +} +'@ + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + $parent = Split-Path -Parent $canonicalLocalPath + $leaf = Split-Path -Leaf $canonicalLocalPath + if (!(Test-SamePath $parent $profilesDirectory) -or $leaf -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath +} + +function Test-PathWithin([string]$Path, [string]$Root) { + $fullPath = [IO.Path]::GetFullPath($Path) + $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + return $fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase) +} + +function Test-OwnerFile([string]$Directory, [string]$Token) { + if (!$Token -or !(Test-Path -LiteralPath $Directory -PathType Container)) { return $false } + $marker = Join-Path $Directory $ownerFileName + if (!(Test-Path -LiteralPath $marker -PathType Leaf)) { return $false } + $item = Get-Item -LiteralPath $marker -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 128) { + return $false + } + return ([IO.File]::ReadAllText($marker, [Text.Encoding]::ASCII) -ceq $Token) +} + +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt 65536) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority($Manifest) { + $installRootPath = if ($FixtureRoot) { $null } else { + Join-Path $env:ProgramFiles 'ProPR Desktop' + } + $installRoot = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' -and + (Test-SamePath ([string]$_.Path) $installRootPath) + }) + } + $shortcutFolderPath = if ($FixtureRoot) { $null } else { + Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop' + } + $shortcutFolder = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' -and + (Test-SamePath ([string]$_.Path) $shortcutFolderPath) + }) + } + $shortcutPath = if ($FixtureRoot) { $null } else { + Join-Path $shortcutFolderPath 'ProPR Desktop.lnk' + } + $shortcut = if ($FixtureRoot) { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + }) + } else { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and + (Test-SamePath ([string]$_.Path) $shortcutPath) + }) + } + + foreach ($candidate in @( + [PSCustomObject]@{ + Records = $installRoot; Path = $installRootPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcutFolder; Path = $shortcutFolderPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcut; Path = $shortcutPath; Directory = $false; Tree = $false + } + )) { + $candidatePath = if ($FixtureRoot -and $candidate.Records.Count -eq 1) { + [string]$candidate.Records[0].Path + } else { [string]$candidate.Path } + if ($candidate.Records.Count -ne 1) { + throw 'MSI-managed file-system authority is missing or ambiguous' + } + if (!$candidatePath -or !(Test-Path -LiteralPath $candidatePath)) { continue } + $record = $candidate.Records[0] + $entryIdentity = if ($candidate.Directory) { + [string]$record.Identity + } else { [string]$record.EntryIdentity } + if ([bool]$record.Provisional -or + $entryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $candidatePath $candidate.Directory) -cne + $entryIdentity) { + throw 'MSI-managed file-system object identity does not match' + } + if ($candidate.Tree) { + if ([string]$record.TreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileSystemTreeIdentity $candidatePath) -cne + [string]$record.TreeIdentity) { + throw 'MSI-managed file-system tree identity does not match' + } + } elseif ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $candidatePath) -cne [string]$record.Identity) { + throw 'MSI-managed shortcut content identity does not match' + } + } +} + +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority($Manifest) { + $path = [string]$Manifest.InstallerPath + if ([string]$Manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$Manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$Manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne + [string]$Manifest.InstallerEntryIdentity -or + (Get-InstallerSha256 $path) -cne [string]$Manifest.InstallerSha256) { + throw 'installer artifact no longer matches durable authority' + } +} + +function Assert-MsiProductIsUnregistered([string]$ProductCode) { + $installerCom = $null + try { + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-MsiRolledBackCleanBaseline($Manifest) { + if ($FixtureRoot -or [string]$Manifest.MsiTransactionState -cne 'ROLLED_BACK_CLEAN') { + return + } + foreach ($path in @( + (Join-Path $env:ProgramFiles 'ProPR Desktop'), + (Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop'), + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr', + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + )) { + if (Test-Path -LiteralPath $path) { + throw 'MSI rollback did not restore the exact clean baseline' + } + } + if (@($Manifest.Directories).Count -ne 0 -or @($Manifest.Files).Count -ne 0 -or + @($Manifest.RegistryKeys).Count -ne 0) { + throw 'MSI rollback receipt contains file-system or machine-registry authority' + } + $installedRecords = @($Manifest.RegistryValues) + if ($installedRecords.Count -ne 1) { + throw 'MSI rollback current-user baseline receipt is missing or ambiguous' + } + $record = $installedRecords[0] + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = if ([bool]$record.BaselineValueExisted) { + $current.Exists -and $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + } else { !$current.Exists } + $keyMatchesBaseline = (Test-Path -LiteralPath ([string]$record.Path)) -eq + [bool]$record.BaselineKeyExisted + if (!$matchesBaseline -or !$keyMatchesBaseline) { + throw 'MSI rollback did not restore the exact current-user baseline' + } + Assert-InstallerArtifactAuthority $Manifest + Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerProductCode) +} + +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + +function Test-RegistryValueIdentity($Record, $Snapshot) { + return $Snapshot.Exists -and + [string]$Record.IdentityValueKind -in @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -and + [string]$Record.IdentityValueData -match '^[A-Za-z0-9+/]*={0,2}$' -and + $Snapshot.Kind -ceq [string]$Record.IdentityValueKind -and + $Snapshot.Data -ceq [string]$Record.IdentityValueData +} + +function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { + if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } + $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' + $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) + $shortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + if ($Kind -eq 'INSTALL_ROOT') { return Test-SamePath $Path $installRoot } + if ($Kind -eq 'SHORTCUT_FOLDER') { return Test-SamePath $Path $shortcutFolder } + if ($Kind -eq 'SHORTCUT_FILE') { return Test-SamePath $Path $shortcut } + if ($Kind -eq 'SMOKE_DATA') { + $machineTempValue = [Environment]::GetEnvironmentVariable( + 'TEMP', [EnvironmentVariableTarget]::Machine) + if (!$machineTempValue) { return $false } + $machineTemp = [Environment]::ExpandEnvironmentVariables($machineTempValue) + return (Split-Path -Leaf $Path) -match '^propr-desktop-smoke-[a-f0-9]{32}$' -and + (Test-SamePath (Split-Path -Parent $Path) $machineTemp) + } + return $false +} + +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $path = [IO.Path]::GetFullPath([string]$Record.Path) + if (!(Test-AllowedFileSystemPath 'SMOKE_DATA' $path) -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data cleanup scope is invalid' + } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $path $ownerFileName + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead + } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() + } + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Resolve-SmokeDirectoryAuthority($Record, $Manifest, [string]$ManifestPath) { + if (!$Record.Owned -or [string]$Record.Kind -cne 'SMOKE_DATA') { return $false } + $recordKeys = @($Record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedKeys = @( + 'Kind','Path','Owned','Token','Identity','Provisional', + 'UserSid','CreatorSid','RootOwnerSid' + ) + if ($recordKeys.Count -ne $expectedKeys.Count -or + @($expectedKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $Record.Owned -isnot [bool] -or $Record.Provisional -isnot [bool] -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$' -or + [string]$Record.UserSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.CreatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.RootOwnerSid -cne 'S-1-5-32-544' -or + (![bool]$Record.Provisional -and [string]$Record.Identity -notmatch '^[a-f0-9]{24}$') -or + ([bool]$Record.Provisional -and $null -ne $Record.Identity)) { + throw 'smoke user-data manifest authority is invalid' + } + $ownedUsers = @($Manifest.Users | Where-Object { $_.Owned }) + if ($ownedUsers.Count -ne 1 -or [bool]$ownedUsers[0].Provisional -or + [string]$ownedUsers[0].Sid -cne [string]$Record.UserSid) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-DurableOwnershipManifest $ManifestPath $Manifest + return $true + } + if ([string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $false +} + +function Remove-OwnedSmokeDirectory($Record) { + if (!$Record.Owned -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' + } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } + } + } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' + } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop +} + +function Remove-OwnedDirectory($Record) { + if (!$Record.Owned) { return } + if ([string]$Record.Kind -ceq 'SMOKE_DATA') { + Remove-OwnedSmokeDirectory $Record + return + } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'directory cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned directory identity is invalid' + } + if ([bool]$Record.Provisional) { + throw 'provisional directory evidence cannot authorize manual cleanup' + } + $tokenMatches = Test-OwnerFile $path ([string]$Record.Token) + $identityMatches = [string]$Record.Identity -match '^[a-f0-9]{24}$' -and + (Get-DirectoryIdentity $path) -ceq [string]$Record.Identity + if (!$tokenMatches -and !$identityMatches) { + throw 'owned directory identity does not match' + } + $markerPath = Join-Path $path $ownerFileName + $children = @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop) + $unexpectedChildren = @($children | Where-Object { + ![string]::Equals($_.FullName, $markerPath, [StringComparison]::OrdinalIgnoreCase) + }) + if ($unexpectedChildren.Count -ne 0) { + throw 'owned directory contains an unexpected descendant' + } + if ($children.Count -ne 0) { + if (!$tokenMatches -or $children.Count -ne 1) { + throw 'owned directory marker identity does not match' + } + Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop + } + if (@(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned directory is not empty' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned directory cleanup did not complete' } +} + +function Remove-OwnedFile($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'file cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!($item -is [IO.FileInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned file identity is invalid' + } + if ([bool]$Record.Provisional) { + throw 'provisional file evidence cannot authorize manual cleanup' + } + if ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $path) -cne [string]$Record.Identity) { + throw 'owned file content identity does not match' + } + if ([string]$Record.EntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne [string]$Record.EntryIdentity) { + throw 'owned file entry identity does not match' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned file cleanup did not complete' } +} + +function Remove-OwnedRegistryKey($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + $productionPaths = @{ + PROTOCOL = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + APP_PATH = 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + } elseif (!$productionPaths.ContainsKey($kind) -or + ![string]::Equals($path, $productionPaths[$kind], [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { return } + if ([bool]$Record.Provisional) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($FixtureRoot) { + $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop + if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$Record.Identity) { + throw 'owned registry identity does not match' + } + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned registry cleanup did not complete' } + if ($FixtureRoot) { + $runRoot = Split-Path -Parent $path + if ((Test-Path -LiteralPath $runRoot) -and + @(Get-ChildItem -LiteralPath $runRoot -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $runRoot -Force -ErrorAction Stop + } + } +} + +function Restore-OwnedRegistryValue($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $name = [string]$Record.Name + if ([string]$Record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + $path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or $name -cne 'installed') { + throw 'registry value cleanup scope is invalid' + } + + $current = Get-RegistryValueSnapshot $path $name + $baselineValueExists = [bool]$Record.BaselineValueExisted + $baselineKind = [string]$Record.BaselineValueKind + $baselineData = [string]$Record.BaselineValueData + $matchesBaseline = $baselineValueExists -and $current.Exists -and + $current.Kind -ceq $baselineKind -and $current.Data -ceq $baselineData + if ([bool]$Record.Provisional -and $current.Exists -and !$matchesBaseline) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($current.Exists -and !$matchesBaseline -and + !(Test-RegistryValueIdentity $Record $current)) { + throw 'registry value ownership changed' + } + + if ($baselineValueExists) { + if (!(Test-Path -LiteralPath $path)) { + [void](New-Item -Path $path -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse([Microsoft.Win32.RegistryValueKind], $baselineKind, $false) + $bytes = [Convert]::FromBase64String($baselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $path -ErrorAction Stop).SetValue($name, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $path -Name $name -Force -ErrorAction Stop + } + + if ([bool]$Record.KeyCreatedByRun -and (Test-Path -LiteralPath $path)) { + $key = Get-Item -LiteralPath $path -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + + $after = Get-RegistryValueSnapshot $path $name + if ($baselineValueExists) { + if (!$after.Exists -or $after.Kind -cne $baselineKind -or $after.Data -cne $baselineData) { + throw 'registry baseline restoration did not complete' + } + } elseif ($after.Exists) { + throw 'owned registry value cleanup did not complete' + } +} + +function Write-DurableOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.new" + $replacementCompleted = $false + try { + $bytes = [Text.Encoding]::UTF8.GetBytes(( + $Manifest | ConvertTo-Json -Depth 6 -Compress + )) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + if ($PSVersionTable.PSEdition -ceq 'Core') { + # Native pwsh provides the atomic same-directory overwrite overload. + [IO.File]::Move($temporaryPath, $Path, $true) + } else { + # .NET Framework File.Replace is unsuitable for the real PS5.1 reader + # flow. Use one same-directory Windows rename with no cross-volume-copy + # flag, replacing the existing pathname and waiting for durable completion. + [ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path) + } + $replacementCompleted = $true + } finally { + if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) } + } +} + +function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { + # Build the final receipt independently. If serialization or replacement + # fails, the caller and canonical pathname both retain ACTIVE authority. + $emptyReceipt = $Manifest.PSObject.Copy() + $emptyReceipt.State = 'EMPTY' + $emptyReceipt.BaselineClean = $false + $emptyReceipt.InstallAttempted = $false + $emptyReceipt.MsiTransactionState = 'NONE' + $emptyReceipt.Directories = @() + $emptyReceipt.Files = @() + $emptyReceipt.RegistryKeys = @() + $emptyReceipt.RegistryValues = @() + $emptyReceipt.Users = @() + $emptyReceipt.Profiles = @() + Write-DurableOwnershipManifest $Path $emptyReceipt +} + +function Resolve-ProvisionalOwnedUser($Record) { + if (!$Record.Owned -or [string]$Record.Sid -match '^S-\d+(?:-\d+)+$') { + return $false + } + if (!$Record.Provisional) { throw 'owned user SID is invalid' } + $name = [string]$Record.Name + $ownershipMarker = [string]$Record.OwnershipMarker + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return $false } + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -notmatch '^S-\d+(?:-\d+)+$') { + throw 'provisional local-user ownership marker does not match' + } + $Record.Sid = [string]$user.SID.Value + $Record.Provisional = $false + return $true +} + +function Promote-UncapturedOwnedProfiles($UserRecord, $Manifest) { + if (!$UserRecord.Owned) { return $false } + $name = [string]$UserRecord.Name + $sid = [string]$UserRecord.Sid + $ownershipMarker = [string]$UserRecord.OwnershipMarker + if ($sid -notmatch '^S-\d+(?:-\d+)+$' -and $UserRecord.Provisional) { + return $false + } + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$' -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or + $ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$') { + throw 'profile promotion identity is invalid' + } + $durableProfiles = @($Manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + }) + if ($durableProfiles.Count -ne 0) { return $false } + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $sid }) + if ($profiles.Count -eq 0) { return $false } + + # An absent profile record can be promoted only while the exact run-created + # account still authenticates both the marker and SID. A durable path record + # is published by the caller before any profile deletion is attempted. + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user -or [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -cne $sid) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + $promoted = @() + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $sid) { + throw 'profile SID changed during ownership promotion' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if (@($promoted | Where-Object { + Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath + }).Count -ne 0) { + throw 'profile ownership promotion is ambiguous' + } + $promoted += [ordered]@{ + Sid = $sid + LocalPath = $canonicalLocalPath + Owned = $true + } + } + $Manifest.Profiles = @($Manifest.Profiles) + @($promoted) + return $true +} + +function Remove-OwnedProfiles($UserRecord, $ProfileRecords) { + if (!$UserRecord.Owned) { return } + $name = [string]$UserRecord.Name + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $sid = [string]$UserRecord.Sid + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + if ($UserRecord.Provisional -and + $null -eq (Get-LocalUser -Name $name -ErrorAction SilentlyContinue)) { return } + throw 'owned user SID was not durably resolved' + } + for ($attempt = 0; $attempt -lt 10; $attempt += 1) { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + if ($profiles.Count -eq 0) { return } + try { + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $sid) { + throw 'profile lacks exact durable SID and path ownership' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $matchingRecords = @() + foreach ($record in @($ProfileRecords | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + })) { + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $name + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { + throw 'profile lacks exact durable SID and path ownership' + } + # Re-resolve the live path and its one durable record at the deletion + # boundary so a changed root, ancestor, depth, leaf, SID, or path fails closed. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $name + if ([string]$profile.SID -cne $sid -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } catch { + if ($attempt -eq 9) { throw } + Start-Sleep -Milliseconds 500 + } + } + throw 'owned profile cleanup did not complete' +} + +function Remove-ExplicitOwnedProfile($Record, $UserRecord) { + if (!$Record.Owned) { return } + $sid = [string]$Record.Sid + $localPath = [string]$Record.LocalPath + $name = [string]$UserRecord.Name + if (!$UserRecord.Owned -or [string]$UserRecord.Sid -cne $sid -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or ![IO.Path]::IsPathRooted($localPath)) { + throw 'profile cleanup identity is invalid' + } + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + foreach ($profile in $profiles) { + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { + throw 'profile path ownership changed' + } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } +} + +function Remove-OwnedUser($Record) { + if (!$Record.Owned) { return } + $name = [string]$Record.Name + $sid = [string]$Record.Sid + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return } + $ownershipMarker = [string]$Record.OwnershipMarker + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker) { + throw 'local-user ownership marker does not match' + } + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + throw 'owned local-user SID was not durably resolved' + } + if ($user.SID.Value -cne $sid) { throw 'local-user SID ownership changed' } + Remove-LocalUser -Name $name -ErrorAction Stop + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + throw 'owned local-user cleanup did not complete' + } +} + +try { + $cleanupValidationPhase = 'FILE_AUTHORITY' + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + !(Test-SamePath (Split-Path -Parent $manifestPath) $tempRoot)) { + throw 'ownership manifest path is invalid' + } + # Durable manifests are replaced atomically. Read from one authenticated + # ordinary-file handle while permitting that protocol's delete sharing, then + # prove the pathname still names the same entry before trusting the bytes. + $manifestStream = [IO.FileStream]::new( + $manifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan + ) + try { + if ($manifestStream.Length -le 0 -or $manifestStream.Length -gt 65536) { + throw 'ownership manifest metadata is invalid' + } + $manifestEntryIdentity = [ProPRDirectoryIdentity]::ReadHandle( + $manifestStream.SafeFileHandle, + $false + ) + $manifestBytes = [byte[]]::new([int]$manifestStream.Length) + $manifestOffset = 0 + while ($manifestOffset -lt $manifestBytes.Length) { + $read = $manifestStream.Read( + $manifestBytes, + $manifestOffset, + $manifestBytes.Length - $manifestOffset + ) + if ($read -eq 0) { throw 'ownership manifest read was incomplete' } + $manifestOffset += $read + } + if ($manifestStream.ReadByte() -ne -1) { throw 'ownership manifest changed during read' } + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $manifestItem.Length -ne $manifestBytes.Length -or + [ProPRDirectoryIdentity]::ReadEntry($manifestPath, $false) -cne + $manifestEntryIdentity) { + throw 'ownership manifest entry changed during read' + } + } finally { + $manifestStream.Dispose() + } + $cleanupValidationPhase = 'UTF8_DECODE' + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $manifestJson = $strictUtf8.GetString($manifestBytes) + + $cleanupValidationPhase = 'JSON_PARSE' + $manifest = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + + $cleanupValidationPhase = 'EXACT_KEY_SET' + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', + 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys', + 'RegistryValues','Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0) { + throw 'ownership manifest key set is invalid' + } + + $cleanupValidationPhase = 'BOOLEAN_TYPES' + # Windows PowerShell 5.1 can retain an incidental PSObject wrapper around a + # JSON primitive. Inspect the explicit base object while still rejecting + # strings, numbers, and every other truthy value. + if ($null -eq $manifest.Fixture -or + $manifest.Fixture.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.BaselineClean -or + $manifest.BaselineClean.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.InstallAttempted -or + $manifest.InstallAttempted.PSObject.BaseObject.GetType() -ne [bool]) { + throw 'ownership manifest Boolean types are invalid' + } + + $cleanupValidationPhase = 'TRANSACTION_ENUM' + if ([string]$manifest.MsiTransactionState -cnotin @( + 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' + )) { + throw 'ownership manifest transaction enum is invalid' + } + + $cleanupValidationPhase = 'SCHEMA_TYPE_STATE' + if ( + $manifest.SchemaVersion -ne 3 -or + [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$manifest.State -cnotin @('ACTIVE','EMPTY')) { + throw 'ownership manifest schema version, type, or state is invalid' + } + + $cleanupValidationPhase = 'RUN_ID_FORMAT' + $runIdBaseObject = if ($null -eq $manifest.RunId) { + $null + } else { $manifest.RunId.PSObject.BaseObject } + if ($null -eq $runIdBaseObject -or + $runIdBaseObject.GetType() -ne [string] -or + [string]$runIdBaseObject -cnotmatch '^[a-f0-9]{32}$') { + throw 'ownership manifest run identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_ENTRY_ID_FORMAT' + $installerEntryIdBaseObject = if ($null -eq $manifest.InstallerEntryIdentity) { + $null + } else { $manifest.InstallerEntryIdentity.PSObject.BaseObject } + if ($null -eq $installerEntryIdBaseObject -or + $installerEntryIdBaseObject.GetType() -ne [string] -or + [string]$installerEntryIdBaseObject -cnotmatch '^[a-f0-9]{24}$') { + throw 'ownership manifest installer entry identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_SHA256_FORMAT' + $installerSha256BaseObject = if ($null -eq $manifest.InstallerSha256) { + $null + } else { $manifest.InstallerSha256.PSObject.BaseObject } + if ($null -eq $installerSha256BaseObject -or + $installerSha256BaseObject.GetType() -ne [string] -or + [string]$installerSha256BaseObject -cnotmatch '^[a-f0-9]{64}$') { + throw 'ownership manifest installer digest format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_PRODUCT_CODE_FORMAT' + $installerProductCodeBaseObject = if ($null -eq $manifest.InstallerProductCode) { + $null + } else { $manifest.InstallerProductCode.PSObject.BaseObject } + if ($null -eq $installerProductCodeBaseObject -or + $installerProductCodeBaseObject.GetType() -ne [string] -or + [string]$installerProductCodeBaseObject -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'ownership manifest installer product-code format is invalid' + } + # Keep the validated JSON wire strings, not host-specific PSObject display + # representations, for every downstream authority comparison and receipt. + $manifest.RunId = [string]$runIdBaseObject + $manifest.InstallerEntryIdentity = [string]$installerEntryIdBaseObject + $manifest.InstallerSha256 = [string]$installerSha256BaseObject + $manifest.InstallerProductCode = [string]$installerProductCodeBaseObject + if (!$manifest.Fixture -and ( + ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) -or + ([string]$manifest.MsiTransactionState -in @( + 'PENDING','COMMITTED','ROLLED_BACK_CLEAN' + ) -and (!([bool]$manifest.BaselineClean) -or + !([bool]$manifest.InstallAttempted))))) { + throw 'MSI transaction receipt state is inconsistent' + } + $cleanupValidationPhase = 'RUN_ID' + $authorizedRunId = [string]$manifest.RunId + $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( + 'propr-installed-app-ownership-'.Length) + if ($authorizedRunId -cne $pathRunId -or $authorizedRunId -cne $ExpectedRunId) { + throw 'ownership manifest run identity is invalid' + } + $cleanupValidationPhase = 'LIFETIME' + $createdUtcTicks = [int64]$manifest.CreatedUtcTicks + $expiresUtcTicks = [int64]$manifest.ExpiresUtcTicks + $nowUtcTicks = [DateTime]::UtcNow.Ticks + if ($createdUtcTicks -le 0 -or $expiresUtcTicks -le $createdUtcTicks -or + $expiresUtcTicks - $createdUtcTicks -gt ([TimeSpan]::TicksPerHour * 3) -or + $createdUtcTicks -gt $nowUtcTicks + ([TimeSpan]::TicksPerMinute * 5) -or + $expiresUtcTicks -lt $nowUtcTicks) { + throw 'ownership manifest lifetime is invalid' + } + $cleanupValidationPhase = 'INSTALLER_PATH' + $resolvedInstaller = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + if (!(Test-SamePath ([string]$manifest.InstallerPath) $resolvedInstaller)) { + throw 'ownership manifest installer identity is invalid' + } + $cleanupValidationPhase = 'FIXTURE_SCOPE' + if ($FixtureRoot) { + $FixtureRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + if (!$manifest.Fixture -or !(Test-SamePath ([string]$manifest.FixtureRoot) $FixtureRoot)) { + throw 'ownership manifest fixture scope is invalid' + } + } elseif ($manifest.Fixture) { + throw 'fixture ownership manifest was not authorized' + } + + # A worker that is terminated before its first marker cannot promote any + # resource authority. Accept only the exact supervisor-created fixture state: + # authenticated schema-v3 ACTIVE authority, no baseline or install attempt, + # transaction NONE, and no resource records. Revalidate the durable installer + # authority before atomically converting it to the ordinary EMPTY receipt. + $cleanupValidationPhase = 'INITIAL_ACTIVE_MATCH' + $initialActiveFixtureManifest = $manifest.Fixture -and + [string]$manifest.State -ceq 'ACTIVE' -and + !$manifest.BaselineClean -and !$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'NONE' -and + @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and + @($manifest.RegistryKeys).Count -eq 0 -and + @($manifest.RegistryValues).Count -eq 0 -and @($manifest.Users).Count -eq 0 -and + @($manifest.Profiles).Count -eq 0 + if ($FixtureValidationDiagnostic -and !$initialActiveFixtureManifest) { + throw 'initial fixture ownership authority does not match' + } + if ($initialActiveFixtureManifest) { + $cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK' + Assert-InstallerArtifactAuthority $manifest + $manifestValidated = $true + $cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE' + Write-EmptyOwnershipReceipt $manifestPath $manifest + exit 0 + } + + if ([string]$manifest.State -ceq 'EMPTY') { + if ($manifest.BaselineClean -or $manifest.InstallAttempted -or + [string]$manifest.MsiTransactionState -cne 'NONE' -or + @($manifest.Directories).Count -ne 0 -or @($manifest.Files).Count -ne 0 -or + @($manifest.RegistryKeys).Count -ne 0 -or @($manifest.RegistryValues).Count -ne 0 -or + @($manifest.Users).Count -ne 0 -or @($manifest.Profiles).Count -ne 0) { + throw 'empty ownership receipt is invalid' + } + $manifestValidated = $true + exit 0 + } + + foreach ($record in @($manifest.Directories)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'directory manifest scope is invalid' + } + if ($record.Owned -and [string]$record.Kind -ceq 'SMOKE_DATA') { + [void](Resolve-SmokeDirectoryAuthority $record $manifest $manifestPath) + } + } + foreach ($record in @($manifest.Files)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'file manifest scope is invalid' + } + if ($record.Owned -and !$record.Provisional -and + ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + [string]$record.EntryIdentity -notmatch '^[a-f0-9]{24}$')) { + throw 'file manifest durable identity is invalid' + } + } + foreach ($record in @($manifest.Users)) { + if ($record.Owned -and ($record.Owned -isnot [bool] -or + $record.Provisional -isnot [bool])) { + throw 'user manifest ownership state is invalid' + } + if ($record.Owned -and [string]$record.Name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'user manifest identity is invalid' + } + if ($record.Owned -and !$record.Provisional -and + [string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$') { + throw 'user manifest SID is invalid' + } + if ($record.Owned -and + [string]$record.OwnershipMarker -notmatch + '^prpr-own-[a-f0-9]{32}$') { + throw 'user manifest ownership marker is invalid' + } + } + foreach ($record in @($manifest.Profiles)) { + if ($record.Owned -and ([string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$' -or + ![IO.Path]::IsPathRooted([string]$record.LocalPath))) { + throw 'profile manifest identity is invalid' + } + } + + $allowAuthenticatedMsiUninstall = !$manifest.Fixture -and + [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED' + foreach ($record in @($manifest.RegistryKeys)) { + if (!$record.Owned) { continue } + $path = [string]$record.Path + $kind = [string]$record.Kind + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ([string](Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue ` + -ErrorAction Stop) -cne [string]$record.Token) { + throw 'registry manifest token is invalid' + } + } else { + $expectedPath = if ($kind -eq 'PROTOCOL') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + } elseif ($kind -eq 'APP_PATH') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } else { $null } + if (!$expectedPath -or + ![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ([bool]$record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$record.Identity) { + throw 'registry manifest ownership identity is invalid' + } + } + } + foreach ($record in @($manifest.RegistryValues)) { + $recordKeys = @($record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedRecordKeys = @( + 'Kind','Path','Name','Owned','Provisional','BaselineKeyExisted', + 'BaselineValueExisted','BaselineValueKind','BaselineValueData', + 'IdentityValueKind','IdentityValueData','KeyCreatedByRun' + ) + if ($recordKeys.Count -ne $expectedRecordKeys.Count -or + @($expectedRecordKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $record.Owned -isnot [bool] -or $record.Provisional -isnot [bool] -or + $record.BaselineKeyExisted -isnot [bool] -or + $record.BaselineValueExisted -isnot [bool] -or + $record.KeyCreatedByRun -isnot [bool] -or + [string]$record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + [string]$record.Path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or [string]$record.Name -cne 'installed' -or + ([bool]$record.KeyCreatedByRun -and [bool]$record.BaselineKeyExisted)) { + throw 'registry value manifest scope is invalid' + } + if ([bool]$record.BaselineValueExisted) { + if (![bool]$record.BaselineKeyExisted -or + [string]$record.BaselineValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.BaselineValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value baseline is invalid' + } + try { + $baselineBytes = [Convert]::FromBase64String([string]$record.BaselineValueData) + if (([string]$record.BaselineValueKind -ceq 'DWord' -and + $baselineBytes.Length -ne 4) -or + ([string]$record.BaselineValueKind -ceq 'QWord' -and + $baselineBytes.Length -ne 8)) { + throw 'invalid baseline width' + } + if ([string]$record.BaselineValueKind -in @('String','ExpandString')) { + [void]([Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes)) + } elseif ([string]$record.BaselineValueKind -ceq 'MultiString') { + $multiStringJson = [Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes) + $multiStringValue = ConvertFrom-Json -InputObject $multiStringJson ` + -NoEnumerate -ErrorAction Stop + if ($multiStringValue -isnot [array] -or + @($multiStringValue | Where-Object { $_ -isnot [string] }).Count -ne 0) { + throw 'invalid multi-string baseline' + } + } + } catch { + throw 'registry value baseline is invalid' + } + } elseif ($null -ne $record.BaselineValueKind -or + $null -ne $record.BaselineValueData) { + throw 'registry value empty baseline is invalid' + } + if ($record.Owned -and !$record.Provisional) { + if ([string]$record.IdentityValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.IdentityValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value ownership identity is invalid' + } + } elseif ($null -ne $record.IdentityValueKind -or $null -ne $record.IdentityValueData) { + throw 'provisional registry value identity is invalid' + } + } + if (@($manifest.RegistryValues).Count -gt 1 -or + (!$manifest.Fixture -and $manifest.InstallAttempted -and + @($manifest.RegistryValues).Count -ne 1) -or + ($manifest.Fixture -and @($manifest.RegistryValues).Count -ne 0)) { + throw 'registry value manifest cardinality is invalid' + } + if (!$manifest.Fixture -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + $ownedDirectoryKinds = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') + } | ForEach-Object { [string]$_.Kind }) + $ownedFileKinds = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + } | ForEach-Object { [string]$_.Kind }) + $ownedRegistryKinds = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') + } | ForEach-Object { [string]$_.Kind }) + if ($ownedDirectoryKinds.Count -ne 2 -or + @($ownedDirectoryKinds | Where-Object { + $_ -notin @('INSTALL_ROOT','SHORTCUT_FOLDER') + }).Count -ne 0 -or + @($ownedDirectoryKinds | Select-Object -Unique).Count -ne 2 -or + $ownedFileKinds.Count -ne 1 -or $ownedFileKinds[0] -cne 'SHORTCUT_FILE' -or + $ownedRegistryKinds.Count -ne 2 -or + @($ownedRegistryKinds | Where-Object { + $_ -notin @('PROTOCOL','APP_PATH') + }).Count -ne 0 -or + @($ownedRegistryKinds | Select-Object -Unique).Count -ne 2 -or + @($manifest.Directories | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.Files | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryKeys | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryValues | Where-Object { + !$_.Owned -or $_.Provisional + }).Count -ne 0) { + throw 'committed MSI transaction receipt is incomplete or provisional' + } + } + $manifestValidated = $true + # ACTIVE authority is inseparable from the exact installer entry captured by + # the supervisor. A same-path replacement blocks every cleanup mutation, + # including fixture/manual fallbacks that do not otherwise need Windows Installer. + Assert-InstallerArtifactAuthority $manifest + if (!$manifest.Fixture) { + if ([string]$manifest.MsiTransactionState -ceq 'PENDING') { + throw 'MSI transaction has no durable cleanup authority receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) { + throw 'MSI install attempt has no transaction receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN') { + Assert-MsiRolledBackCleanBaseline $manifest + } + } + $ownershipPromoted = $false + foreach ($record in @($manifest.Users)) { + if (Resolve-ProvisionalOwnedUser $record) { $ownershipPromoted = $true } + if (Promote-UncapturedOwnedProfiles $record $manifest) { + $ownershipPromoted = $true + } + } + if ($ownershipPromoted) { + Write-DurableOwnershipManifest $manifestPath $manifest + } + foreach ($record in @($manifest.RegistryValues)) { + if (!$record.Owned) { continue } + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = [bool]$record.BaselineValueExisted -and $current.Exists -and + $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + if (!$matchesBaseline -and $current.Exists -and + (([bool]$record.Provisional -and + !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) -or + (![bool]$record.Provisional -and !(Test-RegistryValueIdentity $record $current)))) { + $cleanupFailed = $true + } + } + if ([string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + Assert-MsiManagedFileSystemAuthority $manifest + } + if ($allowAuthenticatedMsiUninstall -and !$cleanupFailed) { + $msiExitCode = 1618 + for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { + if ($attempt -ne 0) { Start-Sleep -Seconds 2 } + Assert-MsiManagedFileSystemAuthority $manifest + Assert-InstallerArtifactAuthority $manifest + $msi = Start-Process msiexec.exe -ArgumentList @( + '/x', [string]$manifest.InstallerProductCode, '/qn', '/norestart' + ) -PassThru -WindowStyle Hidden -ErrorAction Stop + try { + [void]$msi.WaitForExit() + $msiExitCode = $msi.ExitCode + } finally { + $msi.Dispose() + } + } + if ($msiExitCode -notin @(0, 1605, 1614, 1641, 3010)) { $cleanupFailed = $true } + } + + foreach ($record in @($manifest.Files)) { + try { Remove-OwnedFile $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryKeys)) { + try { Remove-OwnedRegistryKey $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryValues)) { + try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } + } + $profileCleanupFailed = $false + foreach ($record in @($manifest.Profiles)) { + try { + $profileOwners = @($manifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$record.Sid + }) + if ($record.Owned -and $profileOwners.Count -ne 1) { + throw 'profile durable owner identity is ambiguous' + } + if ($record.Owned) { Remove-ExplicitOwnedProfile $record $profileOwners[0] } + } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } + } + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedProfiles $record $manifest.Profiles } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } + } + if (!$profileCleanupFailed) { + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedUser $record } catch { $cleanupFailed = $true } + } + } + $directories = @($manifest.Directories) | Sort-Object { + ([string]$_.Path).Length + } -Descending + foreach ($record in $directories) { + try { Remove-OwnedDirectory $record } catch { + $cleanupFailed = $true + } + } + if (!$cleanupFailed) { Write-EmptyOwnershipReceipt $manifestPath $manifest } +} catch { + $cleanupFailed = $true +} + +if ($cleanupFailed) { + Write-FixtureCleanupValidationPhase $cleanupValidationPhase + if ($manifestValidated) { exit 21 } + exit 20 +} +exit 0 diff --git a/apps/desktop/scripts/inspect-packaged-windows-authority.mjs b/apps/desktop/scripts/inspect-packaged-windows-authority.mjs new file mode 100644 index 000000000..141b87c5f --- /dev/null +++ b/apps/desktop/scripts/inspect-packaged-windows-authority.mjs @@ -0,0 +1,223 @@ +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, realpath, rename } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inspectAnyCpuPe } from './build-windows-authority-helper.mjs'; +import { inspectWindowsNativeLauncherPe } from './build-windows-native-launcher.mjs'; + +const EXECUTABLE_NAME = 'propr-windows-authority.exe'; +const MANIFEST_NAME = 'propr-windows-authority.manifest.json'; +const LAUNCHER_NAME = 'propr-windows-launcher.node'; +const BOOTSTRAP_NAME = 'propr-windows-bootstrap.node'; +const MANIFEST_KEYS = [ + 'schemaVersion', 'name', 'format', 'architecture', 'machine', 'clr', 'size', 'sha256', 'sourceSha256', + 'protocol', 'trust', 'publisher', 'compiler', + 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256', + 'bootstrap', 'launcher', +]; +const MAX_HELPER_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 16 * 1024; + +const fail = () => { throw new Error('Packaged Windows authority helper inspection failed'); }; +const exactKeys = (value, keys) => Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +const parseManifest = bytes => { + if (bytes.length <= 1 || bytes.length > MAX_MANIFEST_BYTES || bytes.at(-1) !== 0x0a) fail(); + let manifest; + try { manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, -1))); } + catch { fail(); } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !exactKeys(manifest, MANIFEST_KEYS) + || !manifest.compiler || typeof manifest.compiler !== 'object' || Array.isArray(manifest.compiler) + || !manifest.launcher || typeof manifest.launcher !== 'object' || Array.isArray(manifest.launcher) + || !manifest.bootstrap || typeof manifest.bootstrap !== 'object' || Array.isArray(manifest.bootstrap) + || !exactKeys(manifest.compiler, ['kind', 'framework']) || manifest.schemaVersion !== 1 + || !exactKeys(manifest.launcher, ['name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', + 'publisher', 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256']) + || !exactKeys(manifest.bootstrap, ['name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', + 'publisher', 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256']) + || manifest.name !== EXECUTABLE_NAME || manifest.format !== 'PE32' || manifest.architecture !== 'anycpu' + || manifest.machine !== 'I386' || manifest.clr !== true || !Number.isSafeInteger(manifest.size) + || manifest.size <= 0 || manifest.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.sha256) + || !/^[a-f0-9]{64}$/.test(manifest.sourceSha256) || manifest.protocol !== 'propr-windows-authority-v1' + || !['unsigned-validation', 'production-signed'].includes(manifest.trust) + || (manifest.trust === 'unsigned-validation' && manifest.publisher !== null) + || (manifest.trust === 'production-signed' && (typeof manifest.publisher !== 'string' || !manifest.publisher)) + || !Array.isArray(manifest.signerPins) || manifest.signerPins.length > 16 + || manifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(manifest.signerPins).size !== manifest.signerPins.length + || manifest.signerPins.join(',') !== [...manifest.signerPins].sort().join(',') + || (manifest.trust === 'unsigned-validation' + && (manifest.signerPins.length !== 0 || manifest.signerCertificateSha256 !== null + || manifest.signerSpkiSha256 !== null)) + || (manifest.trust === 'production-signed' + && (manifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(manifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.signerSpkiSha256)) + || !manifest.signerPins.some(pin => pin === `certificate-sha256:${manifest.signerCertificateSha256}` + || pin === `spki-sha256:${manifest.signerSpkiSha256}`))) + || manifest.launcher.name !== LAUNCHER_NAME || manifest.launcher.format !== 'PE' + || !['x64', 'arm64'].includes(manifest.launcher.architecture) + || (manifest.launcher.architecture === 'x64' ? manifest.launcher.machine !== 'AMD64' + : manifest.launcher.machine !== 'ARM64') + || !Number.isSafeInteger(manifest.launcher.size) || manifest.launcher.size <= 0 + || manifest.launcher.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.launcher.sha256) + || manifest.launcher.trust !== manifest.trust || manifest.launcher.publisher !== manifest.publisher + || JSON.stringify(manifest.launcher.signerPins) !== JSON.stringify(manifest.signerPins) + || manifest.launcher.signerCertificateSha256 !== manifest.signerCertificateSha256 + || manifest.launcher.signerSpkiSha256 !== manifest.signerSpkiSha256 + || manifest.bootstrap.name !== BOOTSTRAP_NAME || manifest.bootstrap.format !== 'PE' + || manifest.bootstrap.architecture !== manifest.launcher.architecture + || manifest.bootstrap.machine !== manifest.launcher.machine + || !Number.isSafeInteger(manifest.bootstrap.size) || manifest.bootstrap.size <= 0 + || manifest.bootstrap.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.bootstrap.sha256) + || manifest.bootstrap.trust !== manifest.trust || manifest.bootstrap.publisher !== manifest.publisher + || JSON.stringify(manifest.bootstrap.signerPins) !== JSON.stringify(manifest.signerPins) + || manifest.bootstrap.signerCertificateSha256 !== manifest.signerCertificateSha256 + || manifest.bootstrap.signerSpkiSha256 !== manifest.signerSpkiSha256 + || manifest.compiler.kind !== 'windows-fixed-system-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(manifest.compiler.framework)) fail(); + return manifest; +}; + +const openCanonicalRegular = async (trustedRoot, path, expectedName) => { + const canonicalRoot = await realpath(trustedRoot).catch(fail); + const canonical = await realpath(path).catch(fail); + const expected = resolve(path); + const child = relative(canonicalRoot, canonical); + if (basename(path).toLowerCase() !== expectedName.toLowerCase() + || !child || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child) + || (process.platform === 'win32' + ? canonicalRoot.toLowerCase() !== resolve(trustedRoot).toLowerCase() + : canonicalRoot !== resolve(trustedRoot)) + || (process.platform === 'win32' ? canonical.toLowerCase() !== expected.toLowerCase() : canonical !== expected)) fail(); + const pathStats = await lstat(path, { bigint: true }).catch(fail); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n) fail(); + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(fail); + const heldStats = await handle.stat({ bigint: true }); + if (heldStats.dev !== pathStats.dev || heldStats.ino !== pathStats.ino || heldStats.size !== pathStats.size + || heldStats.nlink !== pathStats.nlink) { await handle.close(); fail(); } + return { handle, stats: heldStats }; +}; + +export const refreshPackagedWindowsAuthorityManifest = async (executablePath, manifestPath, env = process.env) => { + const trustedRoot = dirname(executablePath); + if (trustedRoot !== dirname(manifestPath)) fail(); + const executable = await openCanonicalRegular(trustedRoot, executablePath, EXECUTABLE_NAME); + const launcher = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, LAUNCHER_NAME), LAUNCHER_NAME); + const bootstrap = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, BOOTSTRAP_NAME), BOOTSTRAP_NAME); + const heldManifest = await openCanonicalRegular(trustedRoot, manifestPath, MANIFEST_NAME); + try { + const bytes = await executable.handle.readFile(); + const launcherBytes = await launcher.handle.readFile(); + const bootstrapBytes = await bootstrap.handle.readFile(); + inspectAnyCpuPe(bytes); + const manifest = parseManifest(await heldManifest.handle.readFile()); + try { inspectWindowsNativeLauncherPe(launcherBytes, manifest.launcher.architecture); } catch { fail(); } + try { inspectWindowsNativeLauncherPe(bootstrapBytes, manifest.bootstrap.architecture); } catch { fail(); } + const production = env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1'; + const publisher = production ? String(env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY || '') : null; + const signerPins = production ? String(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS || '').split(',') : []; + const signerCertificateSha256 = production + ? String(env.PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256 || '') : null; + const signerSpkiSha256 = production ? String(env.PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256 || '') : null; + if (production && (!publisher || signerPins.length === 0 + || signerPins.some(pin => !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(signerPins).size !== signerPins.length + || signerPins.join(',') !== [...signerPins].sort().join(',') + || !/^[a-f0-9]{64}$/.test(signerCertificateSha256) + || !/^[a-f0-9]{64}$/.test(signerSpkiSha256) + || !signerPins.some(pin => pin === `certificate-sha256:${signerCertificateSha256}` + || pin === `spki-sha256:${signerSpkiSha256}`))) fail(); + const refreshed = Buffer.from(`${JSON.stringify({ + ...manifest, + size: bytes.length, + sha256: digest(bytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + launcher: { + ...manifest.launcher, + size: launcherBytes.length, + sha256: digest(launcherBytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + }, + bootstrap: { + ...manifest.bootstrap, + size: bootstrapBytes.length, + sha256: digest(bootstrapBytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + }, + })}\n`, 'utf8'); + const temporary = `${manifestPath}.${process.pid}.tmp`; + const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + try { await handle.writeFile(refreshed); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, manifestPath); + } finally { + await executable.handle.close(); + await launcher.handle.close(); + await bootstrap.handle.close(); + await heldManifest.handle.close(); + } +}; + +export const inspectPackagedWindowsAuthority = async (executablePath, manifestPath) => { + if (dirname(executablePath) !== dirname(manifestPath)) fail(); + const trustedRoot = dirname(executablePath); + const executable = await openCanonicalRegular(trustedRoot, executablePath, EXECUTABLE_NAME); + const launcher = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, LAUNCHER_NAME), LAUNCHER_NAME); + const bootstrap = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, BOOTSTRAP_NAME), BOOTSTRAP_NAME); + const heldManifest = await openCanonicalRegular(trustedRoot, manifestPath, MANIFEST_NAME); + try { + const manifest = parseManifest(await heldManifest.handle.readFile()); + const bytes = await executable.handle.readFile(); + const launcherBytes = await launcher.handle.readFile(); + const bootstrapBytes = await bootstrap.handle.readFile(); + inspectAnyCpuPe(bytes); + try { inspectWindowsNativeLauncherPe(launcherBytes, manifest.launcher.architecture); } catch { fail(); } + try { inspectWindowsNativeLauncherPe(bootstrapBytes, manifest.bootstrap.architecture); } catch { fail(); } + if (bytes.length !== manifest.size || digest(bytes) !== manifest.sha256 + || launcherBytes.length !== manifest.launcher.size || digest(launcherBytes) !== manifest.launcher.sha256 + || bootstrapBytes.length !== manifest.bootstrap.size || digest(bootstrapBytes) !== manifest.bootstrap.sha256) fail(); + const after = await executable.handle.stat({ bigint: true }); + const manifestAfter = await heldManifest.handle.stat({ bigint: true }); + const launcherAfter = await launcher.handle.stat({ bigint: true }); + const bootstrapAfter = await bootstrap.handle.stat({ bigint: true }); + if (after.dev !== executable.stats.dev || after.ino !== executable.stats.ino || after.size !== executable.stats.size + || manifestAfter.dev !== heldManifest.stats.dev || manifestAfter.ino !== heldManifest.stats.ino + || manifestAfter.size !== heldManifest.stats.size + || launcherAfter.dev !== launcher.stats.dev || launcherAfter.ino !== launcher.stats.ino + || launcherAfter.size !== launcher.stats.size + || bootstrapAfter.dev !== bootstrap.stats.dev || bootstrapAfter.ino !== bootstrap.stats.ino + || bootstrapAfter.size !== bootstrap.stats.size) fail(); + return manifest; + } finally { + await executable.handle.close(); + await launcher.handle.close(); + await bootstrap.handle.close(); + await heldManifest.handle.close(); + } +}; + +const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + const refresh = process.argv[2] === '--refresh'; + const [executablePath, manifestPath] = refresh ? process.argv.slice(3) : process.argv.slice(2); + if (!executablePath || !manifestPath || (refresh ? process.argv.length !== 5 : process.argv.length !== 4)) fail(); + await (refresh + ? refreshPackagedWindowsAuthorityManifest(executablePath, manifestPath) + : inspectPackagedWindowsAuthority(executablePath, manifestPath)); + process.stdout.write(`Packaged Windows authority helper ${refresh ? 'manifest refreshed' : 'verified'}\n`); +} diff --git a/apps/desktop/scripts/make-dmg.mjs b/apps/desktop/scripts/make-dmg.mjs new file mode 100644 index 000000000..17abea7e2 --- /dev/null +++ b/apps/desktop/scripts/make-dmg.mjs @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { access, cp, mkdir, mkdtemp, open, readFile, rename, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { basename, join, resolve } from 'node:path'; + +const execFileAsync = promisify(execFile); +const HDIUTIL = '/usr/bin/hdiutil'; +if (process.platform !== 'darwin') throw new Error('DMG artifacts must be built on a native macOS host'); + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const version = process.env.PROPR_DESKTOP_VERSION?.trim() || packageJson.version; +const archArgument = process.argv.find(argument => argument.startsWith('--arch=')); +const arch = archArgument?.slice('--arch='.length) || process.arch; +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error(`Invalid desktop release version: ${version}`); +} +if (arch !== 'x64' && arch !== 'arm64') throw new Error(`Unsupported macOS architecture: ${arch}`); + +const appPath = resolve('out', `propr-desktop-darwin-${arch}`, 'propr-desktop.app'); +const outputDirectory = resolve('out', 'make', 'dmg', arch); +const outputPath = resolve(outputDirectory, `ProPR-Desktop-${version}-macos-${arch}.dmg`); +await access(appPath); +await mkdir(outputDirectory, { recursive: true }); +let created = false; +for (let attempt = 0; attempt < 2 && !created; attempt += 1) { + const stagingDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-layout-')); + const temporaryOutput = join(outputDirectory, `.propr-dmg-${randomUUID()}.partial.dmg`); + try { + await cp(appPath, join(stagingDirectory, basename(appPath)), { recursive: true, verbatimSymlinks: true }); + await symlink('/Applications', join(stagingDirectory, 'Applications')); + await execFileAsync(HDIUTIL, [ + 'create', + '-volname', 'ProPR Desktop', + '-srcfolder', stagingDirectory, + '-format', 'UDZO', + temporaryOutput, + ]); + await rename(temporaryOutput, outputPath); + // Publish only after both the image and containing directory have reached + // stable storage, and close every maker handle before a verifier opens it. + const image = await open(outputPath, 'r'); + try { await image.sync(); } finally { await image.close(); } + const directory = await open(outputDirectory, 'r'); + try { await directory.sync(); } finally { await directory.close(); } + created = true; + } catch (error) { + const resourceBusy = typeof error === 'object' && error !== null + && typeof error.stderr === 'string' + && /^hdiutil: create failed - Resource busy\s*$/.test(error.stderr); + if (!resourceBusy || attempt !== 0) { + throw new Error(resourceBusy + ? 'Native DMG creation repeatedly reported resource busy' + : 'Native DMG creation failed'); + } + console.warn('Native DMG creation reported one transient resource-busy result; retrying once'); + } finally { + try { await rm(temporaryOutput, { force: true }); } finally { + await rm(stagingDirectory, { recursive: true, force: true }); + } + } +} +console.log(outputPath); diff --git a/apps/desktop/scripts/packaged-connect-evidence.mjs b/apps/desktop/scripts/packaged-connect-evidence.mjs new file mode 100644 index 000000000..5d6ab13f5 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-evidence.mjs @@ -0,0 +1,82 @@ +export const PACKAGED_CONNECT_EVIDENCE_FAILURE_EVENT = 'packaged_connect.journey_evidence_failed'; +export const PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT = 10; + +export const PACKAGED_CONNECT_EVIDENCE_FAILURE_CODES = Object.freeze([ + 'DISCOVERY_COUNT_MISMATCH', + 'DISCOVERY_AUTHORIZATION_PRESENT', + 'PAIRING_START_MISSING', + 'PAIRING_START_DUPLICATE', + 'PAIRING_BROWSER_COUNT_MISMATCH', + 'PAIRING_POLL_COUNT_MISMATCH', + 'PAIRING_ACTIVATION_COUNT_MISMATCH', + 'PAIRING_METHOD_MISMATCH', + 'PAIRING_BROWSER_CREDENTIAL_PRESENT', + 'PAIRING_INTENT_SEQUENCE_MISMATCH', + 'PAIRING_LIFECYCLE_ISOLATION_FAILED', + 'PAIRING_REQUEST_AFTER_TERMINAL', + 'DELAYED_APPROVAL_READINESS_MISSING', + 'BOOTSTRAP_AUTHORIZATION_PRESENT', + 'AUTHENTICATED_REST_COUNT_MISMATCH', + 'AUTHENTICATED_SOCKET_COUNT_MISMATCH', + 'REST_SCOPE_MISMATCH', + 'SOCKET_SCOPE_MISSING', + 'SOCKET_SCOPE_BINDING_MISMATCH', + 'SOCKET_SCOPE_ROTATION_MISMATCH', + 'PLAINTEXT_CREDENTIAL_PERSISTED', + 'PUBLIC_IDENTITY_MISSING', + 'PUBLIC_IDENTITY_ORDER_MISMATCH', +]); + +export const collectAcceptedSocketEvidence = ({ requests, authorization }) => { + const authenticatedSockets = requests.filter(request => + request.socketIo === true + && request.accepted === true + && request.authorization === authorization); + const socketScopes = new Set(authenticatedSockets.map(request => request.transportScope)); + return { + authenticatedSocketCount: authenticatedSockets.length, + socketHasNullScope: socketScopes.has(null), + socketScopeBindingMismatch: authenticatedSockets.some(request => + request.socketQueryScopeCount !== 1 || request.socketAuthScope !== request.transportScope), + socketScopeCount: socketScopes.size, + }; +}; + +const failureChecks = Object.freeze([ + // Pair contributes eight discoveries. The fresh reprobe process contributes + // its profile probe plus the mandatory pre-Socket.IO identity gate. + ['DISCOVERY_COUNT_MISMATCH', evidence => + evidence.discoveryCount !== PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT], + ['DISCOVERY_AUTHORIZATION_PRESENT', evidence => evidence.discoveryAuthorizationPresent], + ['PAIRING_START_MISSING', evidence => evidence.pairingStartCount < 3], + ['PAIRING_START_DUPLICATE', evidence => evidence.pairingStartCount > 3], + ['PAIRING_BROWSER_COUNT_MISMATCH', evidence => evidence.pairingBrowserCount !== 3], + ['PAIRING_POLL_COUNT_MISMATCH', evidence => evidence.pairingPollCount !== 1], + ['PAIRING_ACTIVATION_COUNT_MISMATCH', evidence => evidence.pairingActivationCount !== 1], + ['PAIRING_METHOD_MISMATCH', evidence => !evidence.pairingMethodBoundaryValid], + ['PAIRING_BROWSER_CREDENTIAL_PRESENT', evidence => evidence.pairingBrowserCredentialPresent], + ['PAIRING_INTENT_SEQUENCE_MISMATCH', evidence => !evidence.pairingIntentSequenceValid], + ['PAIRING_LIFECYCLE_ISOLATION_FAILED', evidence => !evidence.pairingLifecycleIsolated], + ['PAIRING_REQUEST_AFTER_TERMINAL', evidence => evidence.pairingRequestAfterTerminal], + ['DELAYED_APPROVAL_READINESS_MISSING', evidence => !evidence.delayedApprovalReadinessProven], + ['BOOTSTRAP_AUTHORIZATION_PRESENT', evidence => evidence.bootstrapAuthorizationPresent], + ['AUTHENTICATED_REST_COUNT_MISMATCH', evidence => evidence.authenticatedRestCount < 2], + ['AUTHENTICATED_SOCKET_COUNT_MISMATCH', evidence => evidence.authenticatedSocketCount < 2], + ['REST_SCOPE_MISMATCH', evidence => evidence.restScopeCount !== 1 || !evidence.restHasOnlyNullScope], + ['SOCKET_SCOPE_MISSING', evidence => evidence.socketHasNullScope], + ['SOCKET_SCOPE_BINDING_MISMATCH', evidence => evidence.socketScopeBindingMismatch], + ['SOCKET_SCOPE_ROTATION_MISMATCH', evidence => evidence.socketScopeCount < 2], + ['PLAINTEXT_CREDENTIAL_PERSISTED', evidence => evidence.plaintextCredentialPersisted], + ['PUBLIC_IDENTITY_MISSING', evidence => evidence.firstIdentityIndex < 0], + ['PUBLIC_IDENTITY_ORDER_MISMATCH', evidence => evidence.firstBearerIndex <= evidence.firstIdentityIndex], +]); + +/** Return only the first fixed, secret-free failed invariant in protocol order. */ +export const evaluatePackagedConnectEvidence = evidence => { + const failed = failureChecks.find(([, check]) => check(evidence)); + if (!failed) return null; + return { + event: PACKAGED_CONNECT_EVIDENCE_FAILURE_EVENT, + code: failed[0], + }; +}; diff --git a/apps/desktop/scripts/packaged-connect-evidence.test.mjs b/apps/desktop/scripts/packaged-connect-evidence.test.mjs new file mode 100644 index 000000000..3883cf3a7 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-evidence.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + collectAcceptedSocketEvidence, + evaluatePackagedConnectEvidence, + PACKAGED_CONNECT_EVIDENCE_FAILURE_CODES, + PACKAGED_CONNECT_EVIDENCE_FAILURE_EVENT, + PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT, +} from './packaged-connect-evidence.mjs'; + +const passingEvidence = () => ({ + discoveryCount: PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT, + discoveryAuthorizationPresent: false, + pairingStartCount: 3, + pairingBrowserCount: 3, + pairingPollCount: 1, + pairingActivationCount: 1, + pairingMethodBoundaryValid: true, + pairingBrowserCredentialPresent: false, + pairingIntentSequenceValid: true, + pairingLifecycleIsolated: true, + pairingRequestAfterTerminal: false, + delayedApprovalReadinessProven: true, + bootstrapAuthorizationPresent: false, + authenticatedRestCount: 2, + authenticatedSocketCount: 2, + restScopeCount: 1, + restHasOnlyNullScope: true, + socketHasNullScope: false, + socketScopeBindingMismatch: false, + socketScopeCount: 2, + plaintextCredentialPersisted: false, + firstIdentityIndex: 1, + firstBearerIndex: 2, +}); + +const failingEvidence = Object.freeze({ + DISCOVERY_COUNT_MISMATCH: { discoveryCount: 8 }, + DISCOVERY_AUTHORIZATION_PRESENT: { discoveryAuthorizationPresent: true }, + PAIRING_START_MISSING: { pairingStartCount: 2 }, + PAIRING_START_DUPLICATE: { pairingStartCount: 4 }, + PAIRING_BROWSER_COUNT_MISMATCH: { pairingBrowserCount: 2 }, + PAIRING_POLL_COUNT_MISMATCH: { pairingPollCount: 2 }, + PAIRING_ACTIVATION_COUNT_MISMATCH: { pairingActivationCount: 2 }, + PAIRING_METHOD_MISMATCH: { pairingMethodBoundaryValid: false }, + PAIRING_BROWSER_CREDENTIAL_PRESENT: { pairingBrowserCredentialPresent: true }, + PAIRING_INTENT_SEQUENCE_MISMATCH: { pairingIntentSequenceValid: false }, + PAIRING_LIFECYCLE_ISOLATION_FAILED: { pairingLifecycleIsolated: false }, + PAIRING_REQUEST_AFTER_TERMINAL: { pairingRequestAfterTerminal: true }, + DELAYED_APPROVAL_READINESS_MISSING: { delayedApprovalReadinessProven: false }, + BOOTSTRAP_AUTHORIZATION_PRESENT: { bootstrapAuthorizationPresent: true }, + AUTHENTICATED_REST_COUNT_MISMATCH: { authenticatedRestCount: 1 }, + AUTHENTICATED_SOCKET_COUNT_MISMATCH: { authenticatedSocketCount: 1 }, + REST_SCOPE_MISMATCH: { restScopeCount: 2 }, + SOCKET_SCOPE_MISSING: { socketHasNullScope: true }, + SOCKET_SCOPE_BINDING_MISMATCH: { socketScopeBindingMismatch: true }, + SOCKET_SCOPE_ROTATION_MISMATCH: { socketScopeCount: 1 }, + PLAINTEXT_CREDENTIAL_PERSISTED: { plaintextCredentialPersisted: true }, + PUBLIC_IDENTITY_MISSING: { firstIdentityIndex: -1 }, + PUBLIC_IDENTITY_ORDER_MISMATCH: { firstBearerIndex: 1 }, +}); + +describe('packaged Connect aggregate evidence', () => { + test('accepts the complete fixed protocol evidence', () => { + assert.equal(evaluatePackagedConnectEvidence(passingEvidence()), null); + }); + + test('accepts valid rotated Socket.IO bindings alongside the expected stale-auth rejection', () => { + const socketEvidence = collectAcceptedSocketEvidence({ + authorization: 'Bearer fixture-token', + requests: [ + { + socketIo: true, + accepted: true, + authorization: 'Bearer fixture-token', + transportScope: 'scope-before-rotation', + socketQueryScopeCount: 1, + socketAuthScope: 'scope-before-rotation', + }, + { + socketIo: true, + accepted: true, + authorization: 'Bearer fixture-token', + transportScope: 'scope-after-rotation', + socketQueryScopeCount: 1, + socketAuthScope: 'scope-after-rotation', + }, + { + socketIo: true, + accepted: false, + authorization: 'Bearer fixture-token', + transportScope: 'scope-after-rotation', + socketQueryScopeCount: 1, + socketAuthScope: 'scope-before-rotation', + }, + ], + }); + + assert.deepEqual(socketEvidence, { + authenticatedSocketCount: 2, + socketHasNullScope: false, + socketScopeBindingMismatch: false, + socketScopeCount: 2, + }); + assert.equal(evaluatePackagedConnectEvidence({ + ...passingEvidence(), + ...socketEvidence, + }), null); + }); + + test('requires exactly eight pair discoveries and two fresh-process reprobe discoveries', () => { + assert.equal(PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT, 10); + for (const discoveryCount of [8, 9, 11]) { + assert.deepEqual( + evaluatePackagedConnectEvidence({ ...passingEvidence(), discoveryCount }), + { + event: PACKAGED_CONNECT_EVIDENCE_FAILURE_EVENT, + code: 'DISCOVERY_COUNT_MISMATCH', + }, + ); + } + }); + + for (const code of PACKAGED_CONNECT_EVIDENCE_FAILURE_CODES) { + test(`reports only fixed evidence for ${code}`, () => { + const record = evaluatePackagedConnectEvidence({ + ...passingEvidence(), + ...failingEvidence[code], + hostileUrl: 'https://private.example.test/path', + hostileToken: 'secret-SENTINEL', + hostileCount: 9_999_999, + }); + assert.deepEqual(record, { + event: PACKAGED_CONNECT_EVIDENCE_FAILURE_EVENT, + code, + }); + assert.deepEqual(Object.keys(record).sort(), ['code', 'event']); + assert.doesNotMatch(JSON.stringify(record), /private|secret|999/u); + }); + } +}); diff --git a/apps/desktop/scripts/packaged-connect-launch.mjs b/apps/desktop/scripts/packaged-connect-launch.mjs new file mode 100644 index 000000000..bf2f16544 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-launch.mjs @@ -0,0 +1,13 @@ +export const createPackagedConnectLaunchArguments = ({ platform, userDataPath }) => Object.freeze([ + '--disable-gpu', + `--user-data-dir=${userDataPath}`, + ...(platform === 'linux' ? ['--password-store=gnome-libsecret'] : []), +]); + +/** Keep the tested lifecycle argv identical at the real packaged-binary spawn boundary. */ +export const spawnPackagedConnectBinary = ({ + binaryPath, + launchArguments, + options, + spawn, +}) => spawn(binaryPath, launchArguments, options); diff --git a/apps/desktop/scripts/packaged-connect-launch.test.mjs b/apps/desktop/scripts/packaged-connect-launch.test.mjs new file mode 100644 index 000000000..35fec5ec0 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-launch.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, test } from 'node:test'; +import { + createPackagedConnectLaunchArguments, + spawnPackagedConnectBinary, +} from './packaged-connect-launch.mjs'; + +describe('packaged Connect launch boundary', () => { + test('passes the one effective Linux argv through the actual binary spawn', () => { + const launchArguments = createPackagedConnectLaunchArguments({ + platform: 'linux', + userDataPath: '/tmp/propr-connect-smoke', + }); + let invocation; + const child = {}; + assert.equal(spawnPackagedConnectBinary({ + binaryPath: '/package/propr-desktop', + launchArguments, + options: { shell: false }, + spawn: (file, args, options) => { + invocation = { file, args, options }; + return child; + }, + }), child); + assert.deepEqual(invocation, { + file: '/package/propr-desktop', + args: [ + '--disable-gpu', + '--user-data-dir=/tmp/propr-connect-smoke', + '--password-store=gnome-libsecret', + ], + options: { shell: false }, + }); + assert.equal(invocation.args, launchArguments); + }); + + test('does not add the Linux password-store selection on Darwin', () => { + assert.deepEqual(createPackagedConnectLaunchArguments({ + platform: 'darwin', + userDataPath: '/tmp/propr-connect-smoke', + }), [ + '--disable-gpu', + '--user-data-dir=/tmp/propr-connect-smoke', + ]); + }); + + test('the lifecycle and real binary spawn share the derived argv source', async () => { + const source = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + assert.match(source, /const launchArguments = createPackagedConnectLaunchArguments\(\{/u); + assert.match(source, /spawnPackagedConnectBinary\(\{[\s\S]*?launchArguments: args,/u); + assert.match(source, /runPackagedConnectLifecycle\(\{[\s\S]*?args: launchArguments,/u); + assert.doesNotMatch(source, /spawn\(binaryPath, \['--disable-gpu'/u); + }); +}); diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs new file mode 100644 index 000000000..77ca73c10 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -0,0 +1,883 @@ +import { spawn as nodeSpawn } from 'node:child_process'; +import { lstat, realpath, rm } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative } from 'node:path'; +import { TextDecoder } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone'; +export const CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage'; +export const CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; +export const CONNECT_NETWORK_PERMISSION_EVENT = 'desktop.renderer.connect_network_permission'; +export const CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; +export const CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; +export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024; +export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20; + +const RECORD_MAX_BYTES = 8 * 1024; +const RECORD_MAX_COUNT = 128; +const WINDOWS_PID_MAX = 0xffff_ffff; +const FIXTURE_LEAF_PATTERN = /^propr-desktop-connect-smoke-[A-Za-z0-9]{6}$/u; +const ISOLATED_CLEANUP_ARGUMENT = '--internal-isolated-connect-fixture-cleanup'; +const MODULE_PATH = fileURLToPath(import.meta.url); +const isIsolatedCleanupProcess = process.argv[1] === MODULE_PATH + && process.argv[2] === ISOLATED_CLEANUP_ARGUMENT; + +const diagnosticEvents = new Set([ + 'desktop.app.ready', + 'desktop.app.start_failed', + 'desktop.log.write_failed', + 'desktop.main_process.uncaught_exception', + CONNECT_READY_EVENT, + CONNECT_DISCOVERY_MILESTONE_EVENT, + CONNECT_JOURNEY_STAGE_EVENT, + CONNECT_JOURNEY_FAILURE_EVENT, + CONNECT_NETWORK_PERMISSION_EVENT, + CONNECT_JOURNEY_OPERATION_EVENT, + CONNECT_RENDERER_OWNERSHIP_EVENT, + 'desktop.renderer.connect_discovery.phase', + 'desktop.renderer.connect_discovery.status', + 'desktop.renderer.gone', + 'desktop.renderer.ready', +]); +const diagnosticCodes = new Set([ + 'CONNECT_STATUS_INCOMPATIBLE', + 'CONNECT_STATUS_INTERNAL_FAILURE', + 'CONNECT_STATUS_INVALID_CONFIG', + 'CONNECT_STATUS_NOT_READY', + 'CONNECT_STATUS_READY', + 'CONNECT_STATUS_TIMEOUT', + 'DETAIL_REDACTED', + 'LOG_WRITE_FAILED', + 'OPERATION_FAILED', + 'UNCAUGHT_EXCEPTION', +]); +const journeyStageCodes = new Set([ + 'JOURNEY_DISCOVERY_RENDERER', + 'JOURNEY_DISCOVERY_VALIDATED', + 'JOURNEY_STORAGE_BACKEND', + 'JOURNEY_NEGATIVE_MALFORMED', + 'JOURNEY_NEGATIVE_OVERSIZED', + 'JOURNEY_NEGATIVE_EXPIRY', + 'JOURNEY_NEGATIVE_CANCEL', + 'JOURNEY_NEGATIVE_STATE', + 'JOURNEY_PAIR_MANUAL_FORM', + 'JOURNEY_PAIR_BROWSER_APPROVAL', + 'JOURNEY_PAIR_ACTIVATION_DASHBOARD', + 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED', + 'JOURNEY_PAIR_CREDENTIAL_COMMITTED', + 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY', + 'JOURNEY_PAIR_ACTIVATION_COMMITTED', + 'JOURNEY_PAIR_ACTIVATION_PUBLISHED', + 'JOURNEY_PAIR_REACT_CONNECTED', + 'JOURNEY_PAIR_TRANSPORT', + 'JOURNEY_PAIR_COMPLETE', + 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD', + 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY', + 'JOURNEY_REPROBE_ACTIVATION_COMMITTED', + 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED', + 'JOURNEY_REPROBE_REACT_CONNECTED', + 'JOURNEY_REPROBE_TRANSPORT', + 'JOURNEY_REPROBE_COMPLETE', +]); +const journeyFailurePhases = new Set(['pair', 'reprobe']); +const journeyFailureReasons = new Set([ + 'APPROVAL_REJECTED', + 'JOURNEY_FAILED', + 'RENDERER_STAGE_TIMEOUT', + 'RENDERER_STATE_TIMEOUT', + 'TRANSPORT_EVIDENCE_TIMEOUT', +]); +const diagnosticPhases = new Set([ + 'config-read', + 'addon-integrity-type', + 'addon-load', + 'descriptor-operation', + 'authority-inspection', + 'status-resolution', +]); +const diagnosticPhaseCodes = new Set(['STARTED', 'PASSED', 'FAILED']); +const diagnosticSubsteps = new Set(['directory-open', 'addon-open', 'fstat-type']); +const diagnosticCategories = new Set([ + 'access-denied', + 'invalid-argument', + 'io-failure', + 'missing-entry', + 'not-directory', + 'symlink-refused', + 'type-mismatch', + 'unexpected', +]); +const networkPermissionCategories = new Set([ + 'local-network-access', + 'local-network', + 'loopback-network', +]); +const networkPermissionDecisions = new Set(['check', 'request']); +const networkPermissionBooleanFields = [ + 'activeBindingCurrent', + 'webContentsPresent', + 'webContentsEqualsMainWindow', + 'mainWindowPresent', + 'isMainFrame', + 'requestingUrlPresent', + 'requestingUrlTrusted', + 'rendererDocumentUrlTrusted', + 'requestingOriginAuthorityValid', + 'requestingOriginAuthorityEqual', +]; +const journeyOperations = new Set(['PROFILE_SAVE', 'PAIR', 'PROBE', 'ACTIVATE']); +const journeyOperationStatuses = new Set([ + 'COMPLETED', 'READY', 'AUTHENTICATION_REQUIRED', 'INCOMPATIBLE', 'OFFLINE', 'REJECTED', +]); +const rendererOwnershipResourceCategories = new Set(['xhr', 'webSocket', 'other']); +const rendererOwnershipBooleanFields = [ + 'mainRendererPresent', + 'mainRendererLive', + 'webContentsIdMatches', + 'webContentsAbsentOrMatches', + 'mainFrameLive', + 'rendererDocumentTrusted', + 'rendererDocumentAuthorityEqual', + 'frameOmitted', + 'framePresent', + 'frameMatchesMainFrame', + 'frameExplicitlyForeign', + 'rendererOwned', +]; + +const boundedNetworkPermissionEvidence = record => { + if (record.schemaVersion !== 1 + || !networkPermissionCategories.has(record.permissionCategory) + || !networkPermissionDecisions.has(record.decision) + || typeof record.allowed !== 'boolean' + || networkPermissionBooleanFields.some(field => typeof record[field] !== 'boolean')) return {}; + return { + schemaVersion: 1, + permissionCategory: record.permissionCategory, + decision: record.decision, + allowed: record.allowed, + ...Object.fromEntries(networkPermissionBooleanFields.map(field => [field, record[field]])), + }; +}; + +const boundedJourneyOperationEvidence = record => { + if (!journeyOperations.has(record.operation) || !journeyOperationStatuses.has(record.status)) return {}; + return { operation: record.operation, status: record.status }; +}; + +const boundedRendererOwnershipEvidence = record => { + if (record.schemaVersion !== 1 + || !rendererOwnershipResourceCategories.has(record.resourceCategory) + || rendererOwnershipBooleanFields.some(field => typeof record[field] !== 'boolean')) return {}; + return { + schemaVersion: 1, + resourceCategory: record.resourceCategory, + ...Object.fromEntries(rendererOwnershipBooleanFields.map(field => [field, record[field]])), + }; +}; + +export const boundedChildDiagnostics = records => { + const diagnostics = records.flatMap(record => { + if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; + if (record.event === CONNECT_NETWORK_PERMISSION_EVENT) { + return [{ event: record.event, ...boundedNetworkPermissionEvidence(record) }]; + } + if (record.event === CONNECT_JOURNEY_OPERATION_EVENT) { + return [{ event: record.event, ...boundedJourneyOperationEvidence(record) }]; + } + if (record.event === CONNECT_JOURNEY_FAILURE_EVENT) { + return [{ + event: record.event, + ...(journeyFailurePhases.has(record.phase) + && (record.stage === 'JOURNEY_NOT_STARTED' || journeyStageCodes.has(record.stage)) + && journeyFailureReasons.has(record.reason) + ? { phase: record.phase, stage: record.stage, reason: record.reason } + : {}), + }]; + } + if (record.event === CONNECT_RENDERER_OWNERSHIP_EVENT) { + return [{ event: record.event, ...boundedRendererOwnershipEvidence(record) }]; + } + const nestedCode = record.error && typeof record.error === 'object' ? record.error.code : undefined; + const candidateCode = typeof record.code === 'string' ? record.code : nestedCode; + const phase = typeof record.phase === 'string' ? record.phase : undefined; + const substep = typeof record.substep === 'string' ? record.substep : undefined; + const category = typeof record.category === 'string' ? record.category : undefined; + return [{ + event: record.event, + ...(journeyStageCodes.has(candidateCode) + && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT + || record.event === CONNECT_JOURNEY_STAGE_EVENT) + ? { + code: candidateCode, + ...(candidateCode === 'JOURNEY_STORAGE_BACKEND' + && (record.storageBackend === 'gnome_libsecret' + || record.storageBackend === 'os-protected') + ? { storageBackend: record.storageBackend } + : {}), + } + : diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode) + ? { + phase, + code: candidateCode, + ...(candidateCode === 'FAILED' && diagnosticSubsteps.has(substep) ? { substep } : {}), + ...(candidateCode === 'FAILED' && diagnosticCategories.has(category) ? { category } : {}), + } + : diagnosticCodes.has(candidateCode) ? { code: candidateCode } : {}), + }]; + }); + const bounded = diagnostics.slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS); + if (diagnostics.length > CHILD_DIAGNOSTIC_MAX_RECORDS) { + const latestCriticalEvidence = [ + diagnostics.findLast(record => record.event === CONNECT_JOURNEY_OPERATION_EVENT), + diagnostics.findLast(record => record.event === CONNECT_RENDERER_OWNERSHIP_EVENT), + diagnostics.findLast(record => typeof record.code === 'string' + && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT + || record.event === CONNECT_JOURNEY_STAGE_EVENT)), + diagnostics.findLast(record => record.event === CONNECT_JOURNEY_FAILURE_EVENT), + ].filter(Boolean); + const withoutLatestCriticalEvidence = bounded.filter(record => !latestCriticalEvidence.includes(record)); + return withoutLatestCriticalEvidence + .slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS - latestCriticalEvidence.length) + .concat(latestCriticalEvidence); + } + return bounded; +}; + +const exactKeys = (record, expected) => { + const actual = Object.keys(record).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +}; + +export const isExactReadyRecord = (record, { platform, arch, authorityMechanism }) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return false; + if (!exactKeys(record, [ + 'authorityMechanism', 'event', 'level', 'rendererSchemaValid', + 'selectedArch', 'selectedPlatform', 'timestamp', + ])) return false; + return record.event === CONNECT_READY_EVENT + && record.level === 'info' + && typeof record.timestamp === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp) + && record.selectedPlatform === platform + && record.selectedArch === arch + && record.authorityMechanism === authorityMechanism + && record.rendererSchemaValid === true; +}; + +const createRecordCapture = ({ sensitiveNeedles, onRecord, onSensitiveOutput }) => { + let capturedBytes = 0; + let captureTruncated = false; + let recordCount = 0; + let sensitiveOutput = false; + const streams = new Map(); + const endedStreams = new Set(); + const normalizedNeedles = sensitiveNeedles.filter(value => typeof value === 'string' && value.length > 0); + const maximumNeedleLength = Math.max(1, ...normalizedNeedles.map(value => value.length)); + const reportSensitiveOutput = () => { + if (sensitiveOutput) return; + sensitiveOutput = true; + onSensitiveOutput(); + }; + + const parsedContentIsSensitive = parsed => { + const pending = [parsed]; + while (pending.length > 0) { + const value = pending.pop(); + if (typeof value === 'string') { + if (normalizedNeedles.some(needle => value.includes(needle))) return true; + } else if (Array.isArray(value)) { + pending.push(...value); + } else if (value && typeof value === 'object') { + for (const [key, nested] of Object.entries(value)) pending.push(key, nested); + } + } + return false; + }; + + const streamState = name => { + if (!streams.has(name)) streams.set(name, { + decoder: new TextDecoder('utf-8', { fatal: false }), + line: '', + lineBytes: 0, + discardingLine: false, + scanTail: '', + }); + return streams.get(name); + }; + + const inspectLine = line => { + const framed = line.endsWith('\r') ? line.slice(0, -1) : line; + if (!framed || recordCount >= RECORD_MAX_COUNT) { + if (recordCount >= RECORD_MAX_COUNT) captureTruncated = true; + return; + } + let record; + try { record = JSON.parse(framed); } catch { return; } + // JSON escaping can hide a decoded path (notably Windows backslashes) from + // the raw stream scan, so inspect every bounded parsed string before the + // record can contribute either readiness or diagnostics. + if (parsedContentIsSensitive(record)) reportSensitiveOutput(); + if (!record || typeof record !== 'object' || Array.isArray(record)) return; + recordCount += 1; + onRecord(record); + }; + + const scan = (state, text) => { + const candidate = `${state.scanTail}${text}`; + if (normalizedNeedles.some(needle => candidate.includes(needle))) reportSensitiveOutput(); + state.scanTail = maximumNeedleLength > 1 ? candidate.slice(-(maximumNeedleLength - 1)) : ''; + }; + + const write = (name, chunk) => { + const state = streamState(name); + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, CHILD_CAPTURE_MAX_BYTES - capturedBytes); + const accepted = bytes.subarray(0, remaining); + capturedBytes += accepted.byteLength; + if (accepted.byteLength < bytes.byteLength) captureTruncated = true; + + // Secret detection continues with a constant-size tail even after structured capture is full. + scan(state, state.decoder.decode(bytes, { stream: true })); + if (accepted.byteLength === 0) return; + const text = new TextDecoder('utf-8', { fatal: false }).decode(accepted); + for (const character of text) { + if (character === '\n') { + if (!state.discardingLine) inspectLine(state.line); + state.line = ''; + state.lineBytes = 0; + state.discardingLine = false; + continue; + } + state.lineBytes += Buffer.byteLength(character, 'utf8'); + if (state.lineBytes > RECORD_MAX_BYTES) { + state.line = ''; + state.discardingLine = true; + captureTruncated = true; + } else if (!state.discardingLine) { + state.line += character; + } + } + }; + + const end = name => { + if (endedStreams.has(name)) return; + endedStreams.add(name); + const state = streamState(name); + scan(state, state.decoder.decode()); + if (state.line || state.discardingLine) captureTruncated = true; + state.line = ''; + state.discardingLine = false; + }; + + return { + write, + end, + finish: () => { + end('stdout'); + end('stderr'); + }, + result: () => ({ + capture: captureTruncated ? 'truncated' : 'complete', + sensitiveOutput, + }), + }; +}; + +const deferred = () => { + let resolvePromise; + const promise = new Promise(resolve => { resolvePromise = resolve; }); + return { promise, resolve: resolvePromise }; +}; + +const boundedDelay = milliseconds => new Promise(resolveDelay => { + setTimeout(resolveDelay, milliseconds); +}); + +const withTimeout = (promise, milliseconds) => new Promise(resolveBounded => { + let settled = false; + const finish = result => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveBounded(result); + }; + const timer = setTimeout(() => finish({ timedOut: true }), milliseconds); + promise.then(value => finish({ timedOut: false, value }), () => finish({ timedOut: false })); +}); + +const validPid = pid => Number.isSafeInteger(pid) && pid > 0 && pid <= WINDOWS_PID_MAX; + +const waitForClose = (child, milliseconds) => { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ closed: true, code: child.exitCode, signal: child.signalCode }); + } + return new Promise(resolveWait => { + let finished = false; + const finish = result => { + if (finished) return; + finished = true; + clearTimeout(timer); + child.removeListener('close', onClose); + resolveWait(result); + }; + const onClose = (code, signal) => finish({ closed: true, code, signal }); + const timer = setTimeout(() => finish({ closed: false }), milliseconds); + child.once('close', onClose); + }); +}; + +const drainStream = (stream, milliseconds) => { + if (!stream || stream.destroyed || stream.readableEnded) return Promise.resolve(true); + return new Promise(resolveDrain => { + let finished = false; + const finish = value => { + if (finished) return; + finished = true; + clearTimeout(timer); + stream.removeListener('end', onDrain); + stream.removeListener('close', onDrain); + resolveDrain(value); + }; + const onDrain = () => finish(true); + const timer = setTimeout(() => finish(false), milliseconds); + stream.once('end', onDrain); + stream.once('close', onDrain); + }); +}; + +const drainChildStreams = async (child, milliseconds) => { + const drained = await Promise.all([ + drainStream(child.stdout, milliseconds), + drainStream(child.stderr, milliseconds), + ]); + return drained.every(Boolean); +}; + +const runWindowsTreeKiller = async ({ spawn, treeKillerPath, pid, timeoutMs }) => { + if (typeof treeKillerPath !== 'string' || !isAbsolute(treeKillerPath) || !validPid(pid)) return false; + let killer; + try { + killer = spawn(treeKillerPath, ['/PID', String(pid), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { return false; } + let captured = 0; + const discard = chunk => { captured = Math.min(CHILD_CAPTURE_MAX_BYTES, captured + chunk.length); }; + killer.stdout?.on('data', discard); + killer.stderr?.on('data', discard); + const closePromise = new Promise(resolveKiller => { + killer.once('error', () => resolveKiller({ ok: false })); + killer.once('close', (code, signal) => resolveKiller({ ok: code === 0 && signal === null })); + }); + const boundedClose = await withTimeout(closePromise, timeoutMs); + if (boundedClose.timedOut) { + try { killer.kill('SIGKILL'); } catch { /* The bounded helper has already failed. */ } + const finalDrainBound = Math.min(1_000, timeoutMs); + await Promise.all([ + withTimeout(closePromise, finalDrainBound), + drainStream(killer.stdout, finalDrainBound), + drainStream(killer.stderr, finalDrainBound), + ]); + killer.stdout?.destroy(); + killer.stderr?.destroy(); + killer.unref?.(); + return false; + } + const streamsDrained = await Promise.all([ + drainStream(killer.stdout, timeoutMs), + drainStream(killer.stderr, timeoutMs), + ]); + return boundedClose.value?.ok === true && streamsDrained.every(Boolean); +}; + +const terminateOwnedProcess = async ({ child, platform, spawn, treeKillerPath, timeoutMs }) => { + if (!validPid(child.pid)) return false; + if (platform === 'win32') { + const treeKilled = await runWindowsTreeKiller({ spawn, treeKillerPath, pid: child.pid, timeoutMs }); + if (!treeKilled) { + // This cannot prove descendant termination, but it prevents a failed helper from + // leaving the directly owned Electron process alive while the fixed failure is reported. + try { child.kill('SIGKILL'); } catch { /* Preserve the tree-termination result. */ } + } + return treeKilled; + } + try { return child.kill('SIGKILL'); } catch { return false; } +}; + +const closeIsClean = close => close?.closed && close.code === 0 && close.signal === null; + +/** + * Own one packaged app from spawn through proof, shutdown, tree termination, and stream drain. + * The returned object contains only fixed categories and allowlisted child diagnostics. + */ +export const runPackagedConnectLifecycle = async ({ + binaryPath, + args, + env, + cwd, + platform, + arch, + authorityMechanism, + expectedStorageBackend, + sensitiveNeedles = [], + treeKillerPath, + spawn = nodeSpawn, + readyTimeoutMs = 240_000, + shutdownGraceMs = 5_000, + terminationTimeoutMs = 10_000, + streamDrainTimeoutMs = 5_000, + requestShutdown = () => undefined, +}) => { + const records = []; + const first = deferred(); + let firstSettled = false; + let invalidReadyObserved = false; + let reportedStorageBackend; + let child; + const settleFirst = value => { + if (firstSettled) return; + firstSettled = true; + first.resolve(value); + }; + const capture = createRecordCapture({ + sensitiveNeedles, + onSensitiveOutput: () => settleFirst({ category: 'output-rejected' }), + onRecord: record => { + if (records.length < RECORD_MAX_COUNT) records.push(record); + if (record.event === CONNECT_JOURNEY_STAGE_EVENT + && record.code === 'JOURNEY_STORAGE_BACKEND') { + reportedStorageBackend = record.storageBackend; + } + if (record.event !== CONNECT_READY_EVENT) return; + const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism }) + && (expectedStorageBackend === undefined + || reportedStorageBackend === expectedStorageBackend); + if (!valid) invalidReadyObserved = true; + settleFirst(valid ? { category: 'ready' } : { category: 'ready-validation' }); + }, + }); + + try { + child = spawn(binaryPath, args, { + cwd, + env, + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + return { ok: false, category: 'spawn-error', capture: 'complete', records: [] }; + } + + child.stdout?.on('data', chunk => capture.write('stdout', chunk)); + child.stderr?.on('data', chunk => capture.write('stderr', chunk)); + child.stdout?.once('end', () => capture.end('stdout')); + child.stderr?.once('end', () => capture.end('stderr')); + child.once('error', () => settleFirst({ category: 'spawn-error' })); + child.once('close', (code, signal) => settleFirst({ category: 'child-exit', close: { closed: true, code, signal } })); + + const readyTimer = setTimeout(() => settleFirst({ category: 'timeout-before-ready' }), readyTimeoutMs); + const trigger = await first.promise; + clearTimeout(readyTimer); + + let primary = trigger.category; + let close = trigger.close; + let terminationAttempted = false; + let terminationSucceeded = false; + let streamsDrained = false; + + if (primary === 'ready') { + try { requestShutdown(child); } catch { /* The app also self-requests quit after logging proof. */ } + close = await waitForClose(child, shutdownGraceMs); + if (closeIsClean(close)) { + primary = 'ready-clean-exit'; + } else if (close.closed) { + primary = 'child-exit-after-ready'; + } else { + terminationAttempted = true; + terminationSucceeded = await terminateOwnedProcess({ + child, platform, spawn, treeKillerPath, timeoutMs: terminationTimeoutMs, + }); + close = await waitForClose(child, streamDrainTimeoutMs); + streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); + primary = closeIsClean(close) && streamsDrained + ? 'ready-clean-exit' + : terminationSucceeded && close.closed && streamsDrained + ? 'ready-forced-exit' + : 'tree-termination'; + } + } else if (primary === 'child-exit') { + primary = 'child-exit-before-ready'; + } else { + const alreadyClosed = child.exitCode !== null || child.signalCode !== null; + if (!alreadyClosed && validPid(child.pid)) { + terminationAttempted = true; + terminationSucceeded = await terminateOwnedProcess({ + child, platform, spawn, treeKillerPath, timeoutMs: terminationTimeoutMs, + }); + } + close = await waitForClose(child, streamDrainTimeoutMs); + } + + if (!streamsDrained) streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); + capture.finish(); + const captureResult = capture.result(); + if (primary === 'ready-clean-exit' || primary === 'ready-forced-exit') { + if (captureResult.sensitiveOutput || captureResult.capture === 'truncated') { + primary = 'output-rejected'; + } else if (invalidReadyObserved) primary = 'ready-validation'; + } + const secondary = []; + if (terminationAttempted && !terminationSucceeded && primary !== 'ready-clean-exit') { + secondary.push('tree-termination-failed'); + } + if (!close?.closed) secondary.push('child-close-unconfirmed'); + if (!streamsDrained) secondary.push('stream-drain-failed'); + if (!close?.closed || !streamsDrained) { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref?.(); + } + return { + ok: primary === 'ready-clean-exit' || primary === 'ready-forced-exit', + category: primary, + capture: captureResult.capture, + records: boundedChildDiagnostics(records), + ...(secondary.length ? { secondary } : {}), + }; +}; + +const createCleanupPhaseDeadline = milliseconds => { + let timedOut = false; + let timer; + const timeout = new Promise(resolveTimeout => { + timer = setTimeout(() => { + timedOut = true; + resolveTimeout({ status: 'timed-out' }); + }, Math.max(0, milliseconds)); + }); + return { + run: operation => { + if (timedOut) return Promise.resolve({ status: 'timed-out' }); + let pending; + try { pending = operation(); } catch (error) { + return Promise.resolve({ status: 'rejected', error }); + } + return Promise.race([ + Promise.resolve(pending).then( + value => ({ status: 'fulfilled', value }), + error => ({ status: 'rejected', error }), + ), + timeout, + ]); + }, + dispose: () => clearTimeout(timer), + }; +}; + +const fixtureIdentityIsAuthorized = async ({ + fixture, + canonicalTemporaryParent, + generatedLeaf, + lstatImpl, + realpathImpl, + runBeforeDeadline, +}) => { + if (typeof fixture !== 'string' || typeof canonicalTemporaryParent !== 'string' + || typeof generatedLeaf !== 'string' || !FIXTURE_LEAF_PATTERN.test(generatedLeaf) + || basename(fixture) !== generatedLeaf || dirname(fixture) !== canonicalTemporaryParent + || relative(canonicalTemporaryParent, fixture) !== generatedLeaf) return { authorized: false }; + const fixtureStats = await runBeforeDeadline(() => lstatImpl(fixture)); + if (fixtureStats.status === 'timed-out') return { timedOut: true }; + if (fixtureStats.status === 'rejected') { + return { authorized: fixtureStats.error?.code === 'ENOENT' }; + } + const identity = await Promise.all([ + runBeforeDeadline(() => realpathImpl(canonicalTemporaryParent)), + runBeforeDeadline(() => realpathImpl(fixture)), + runBeforeDeadline(() => lstatImpl(canonicalTemporaryParent)), + ]); + if (identity.some(result => result.status === 'timed-out')) return { timedOut: true }; + if (identity.some(result => result.status === 'rejected')) return { authorized: false }; + const [parentPath, fixturePath, parentStats] = identity.map(result => result.value); + const stats = fixtureStats.value; + try { + return { + authorized: parentPath === canonicalTemporaryParent + && fixturePath === fixture + && parentStats.isDirectory() + && !parentStats.isSymbolicLink() + && stats.isDirectory() + && !stats.isSymbolicLink(), + }; + } catch { return { authorized: false }; } +}; + +const isolatedCleanupResult = async ({ + fixture, + canonicalTemporaryParent, + generatedLeaf, + retryBoundMs, + retryDelayMs, + phase, +}) => { + if (!isAbsolute(process.execPath)) return { ok: false, category: 'fixture-cleanup-failed' }; + let child; + try { + child = nodeSpawn(process.execPath, [MODULE_PATH, ISOLATED_CLEANUP_ARGUMENT], { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { return { ok: false, category: 'fixture-cleanup-failed' }; } + let stdout = ''; + let stdoutOverflow = false; + child.stdout.on('data', chunk => { + if (stdoutOverflow) return; + stdout += chunk.toString('utf8'); + if (Buffer.byteLength(stdout, 'utf8') > RECORD_MAX_BYTES) { + stdout = ''; + stdoutOverflow = true; + } + }); + child.stderr.on('data', () => undefined); + child.stdin.on('error', () => undefined); + const close = new Promise(resolveClose => { + let settled = false; + const finish = result => { + if (settled) return; + settled = true; + resolveClose(result); + }; + child.once('error', () => finish({ closed: false })); + child.once('close', (code, signal) => finish({ closed: true, code, signal })); + }); + child.stdin.end(JSON.stringify({ + fixture, canonicalTemporaryParent, generatedLeaf, retryBoundMs, retryDelayMs, + })); + const boundedClose = await phase.run(() => close); + if (boundedClose.status !== 'fulfilled' || !boundedClose.value.closed + || boundedClose.value.code !== 0 || boundedClose.value.signal !== null || stdoutOverflow) { + try { child.kill('SIGKILL'); } catch { /* The fixed cleanup failure is already selected. */ } + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + return { ok: false, category: 'fixture-cleanup-failed' }; + } + try { + const result = JSON.parse(stdout); + const keys = Object.keys(result).sort(); + if (result.ok === true && keys.length === 1 && keys[0] === 'ok') return result; + if (result.ok === false && keys.length === 2 && keys[0] === 'category' && keys[1] === 'ok' + && ['fixture-cleanup-authorization-failed', 'fixture-cleanup-failed'].includes(result.category)) { + return result; + } + } catch { /* Return only the fixed failure below. */ } + return { ok: false, category: 'fixture-cleanup-failed' }; +}; + +export const removeAuthorizedConnectFixture = async ({ + fixture, + canonicalTemporaryParent, + generatedLeaf = basename(fixture), + platform = process.platform, + retryBoundMs = 10_000, + retryDelayMs = 100, + lstatImpl = lstat, + realpathImpl = realpath, + rmImpl = rm, +}) => { + const phase = createCleanupPhaseDeadline(retryBoundMs); + try { + if (platform === 'win32' && !isIsolatedCleanupProcess + && lstatImpl === lstat && realpathImpl === realpath && rmImpl === rm) { + return await isolatedCleanupResult({ + fixture, canonicalTemporaryParent, generatedLeaf, retryBoundMs, retryDelayMs, phase, + }); + } + const authorize = () => fixtureIdentityIsAuthorized({ + fixture, canonicalTemporaryParent, generatedLeaf, lstatImpl, realpathImpl, + runBeforeDeadline: phase.run, + }); + const initialAuthorization = await authorize(); + if (initialAuthorization.timedOut) { + return { ok: false, category: 'fixture-cleanup-failed' }; + } + if (!initialAuthorization.authorized) { + return { ok: false, category: 'fixture-cleanup-authorization-failed' }; + } + while (true) { + const removal = await phase.run(() => rmImpl(fixture, { + recursive: true, force: true, maxRetries: 0, + })); + if (removal.status === 'fulfilled') return { ok: true }; + if (removal.status === 'timed-out') { + return { ok: false, category: 'fixture-cleanup-failed' }; + } + const retryable = platform === 'win32' + && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(removal.error?.code); + if (!retryable) return { ok: false, category: 'fixture-cleanup-failed' }; + const delay = await phase.run(() => boundedDelay(retryDelayMs)); + if (delay.status !== 'fulfilled') return { ok: false, category: 'fixture-cleanup-failed' }; + const retryAuthorization = await authorize(); + if (retryAuthorization.timedOut) { + return { ok: false, category: 'fixture-cleanup-failed' }; + } + if (!retryAuthorization.authorized) { + return { ok: false, category: 'fixture-cleanup-authorization-failed' }; + } + } + } finally { + phase.dispose(); + } +}; + +export const preservePrimaryWithCleanup = (outcome, cleanup) => cleanup.ok ? outcome : ({ + ...outcome, + secondary: [...new Set([...(outcome.secondary ?? []), cleanup.category])], +}); + +export const createIdempotentJourneyFixtureClose = ({ + closeSocketServer, + closeHttpServer, +}) => { + let closePromise; + return () => { + closePromise ??= (async () => { + await closeSocketServer(); + try { + await closeHttpServer(); + } catch (error) { + if (error?.code !== 'ERR_SERVER_NOT_RUNNING') throw error; + } + })(); + return closePromise; + }; +}; + +if (isIsolatedCleanupProcess) { + let input = ''; + try { + for await (const chunk of process.stdin) { + input += chunk; + if (Buffer.byteLength(input, 'utf8') > RECORD_MAX_BYTES) throw new Error('invalid cleanup input'); + } + const options = JSON.parse(input); + const result = await removeAuthorizedConnectFixture({ + fixture: options.fixture, + canonicalTemporaryParent: options.canonicalTemporaryParent, + generatedLeaf: options.generatedLeaf, + platform: 'win32', + retryBoundMs: options.retryBoundMs, + retryDelayMs: options.retryDelayMs, + }); + process.stdout.write(JSON.stringify(result)); + } catch { + process.stdout.write(JSON.stringify({ ok: false, category: 'fixture-cleanup-failed' })); + } +} diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs new file mode 100644 index 000000000..8754d7261 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -0,0 +1,828 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { lstat, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { describe, test } from 'node:test'; +import { + CHILD_CAPTURE_MAX_BYTES, + CONNECT_DISCOVERY_MILESTONE_EVENT, + CONNECT_JOURNEY_FAILURE_EVENT, + CONNECT_JOURNEY_STAGE_EVENT, + CONNECT_JOURNEY_OPERATION_EVENT, + CONNECT_NETWORK_PERMISSION_EVENT, + CONNECT_RENDERER_OWNERSHIP_EVENT, + CONNECT_READY_EVENT, + createIdempotentJourneyFixtureClose, + isExactReadyRecord, + preservePrimaryWithCleanup, + removeAuthorizedConnectFixture, + runPackagedConnectLifecycle, +} from './packaged-connect-lifecycle.mjs'; + +const expected = Object.freeze({ + platform: 'win32', + arch: 'x64', + authorityMechanism: 'inherited-standard-handle', +}); +const privateWindowsPath = String.raw`C:\Users\private-user\private-path-SENTINEL`; + +const readyRecord = (overrides = {}) => ({ + timestamp: '2026-09-01T22:00:00.000Z', + level: 'info', + event: CONNECT_READY_EVENT, + selectedPlatform: expected.platform, + selectedArch: expected.arch, + authorityMechanism: expected.authorityMechanism, + rendererSchemaValid: true, + ...overrides, +}); + +class FakeChild extends EventEmitter { + constructor(pid = 4242) { + super(); + this.pid = pid; + this.exitCode = null; + this.signalCode = null; + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + } + + write(record, stream = this.stdout) { + stream.write(typeof record === 'string' ? record : `${JSON.stringify(record)}\n`); + } + + close(code = 0, signal = null) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.signalCode = signal; + this.stdout.end(); + this.stderr.end(); + queueMicrotask(() => this.emit('close', code, signal)); + } + + kill() { + this.close(null, 'SIGKILL'); + return true; + } +} + +const run = ({ app = new FakeChild(), onApp, onKiller, ...options } = {}) => { + const invocations = []; + const spawn = (file, args, spawnOptions) => { + invocations.push({ file, args, options: spawnOptions }); + if (file === '/system/taskkill.exe') { + const killer = new FakeChild(4343); + queueMicrotask(() => onKiller?.(killer, app)); + return killer; + } + queueMicrotask(() => onApp?.(app)); + return app; + }; + return runPackagedConnectLifecycle({ + binaryPath: '/package/propr-desktop.exe', + args: ['--disable-gpu'], + env: {}, + ...expected, + sensitiveNeedles: ['secret-SENTINEL', '/private/path-SENTINEL', privateWindowsPath], + treeKillerPath: '/system/taskkill.exe', + spawn, + readyTimeoutMs: 15, + shutdownGraceMs: 5, + terminationTimeoutMs: 5, + streamDrainTimeoutMs: 5, + ...options, + }).then(result => ({ result, invocations })); +}; + +describe('packaged Connect bounded child lifecycle', () => { + test('requires exact three starts, three browser approvals, one poll, and one activation', async () => { + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const accounting = harness.slice( + harness.indexOf('const evidenceFailure = evaluatePackagedConnectEvidence'), + harness.indexOf('if (evidenceFailure)'), + ); + assert.match(accounting, /pairingStartCount: pairingStarts\.length/u); + assert.match(accounting, /pairingBrowserCount: pairingBrowsers\.length/u); + assert.match(accounting, /pairingPollCount: pairingPolls\.length/u); + assert.match(accounting, /pairingActivationCount: pairingActivations\.length/u); + + const evaluator = await readFile(new URL('./packaged-connect-evidence.mjs', import.meta.url), 'utf8'); + assert.match(evaluator, /evidence\.pairingStartCount < 3/u); + assert.match(evaluator, /evidence\.pairingStartCount > 3/u); + assert.match(evaluator, /evidence\.pairingBrowserCount !== 3/u); + assert.match(evaluator, /evidence\.pairingPollCount !== 1/u); + assert.match(evaluator, /evidence\.pairingActivationCount !== 1/u); + assert.match(harness, /request\.method === 'POST'[\s\S]*?request\.url === '\/api\/desktop\/pairings'/u); + assert.match(harness, /request\.method === 'GET'[\s\S]*?\/\\\/browser\$\//u); + assert.match(harness, /pairingBrowserCredentialPresent: pairingBrowsers\.some/u); + assert.doesNotMatch(evaluator, /pairingPollCount < 3/u); + assert.match(harness, /const approvalReadinessDelayMs = process\.platform === 'darwin' \? 300 : 0/u); + assert.match(harness, /pairingIntentSequenceValid: hasExactModes\(pairingStarts\) && hasExactModes\(pairingBrowsers\)/u); + assert.match(harness, /pairingRequestAfterTerminal: bootstrap\.length !== pairingRequestCountAtPairTerminal/u); + assert.match(harness, /delayedApprovalReadinessProven: process\.platform !== 'darwin'/u); + }); + + test('accepts an exact ready proof followed by a clean exit', async () => { + const { result, invocations } = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.deepEqual(result, { + ok: true, + category: 'ready-clean-exit', + capture: 'complete', + records: [{ event: CONNECT_READY_EVENT }], + }); + assert.equal(invocations.length, 1); + }); + + test('requires and preserves the expected fixed storage-backend report before readiness', async () => { + const accepted = await run({ + expectedStorageBackend: 'os-protected', + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend: 'os-protected', + }); + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(accepted.result.ok, true); + assert.deepEqual(accepted.result.records, [ + { + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend: 'os-protected', + }, + { event: CONNECT_READY_EVENT }, + ]); + + for (const storageBackend of [undefined, 'gnome_libsecret']) { + const rejected = await run({ + expectedStorageBackend: 'os-protected', + onApp: app => { + if (storageBackend) { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend, + }); + } + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(rejected.result.ok, false); + assert.equal(rejected.result.category, 'ready-validation'); + } + }); + + test('does not accept an intermediate discovery milestone as terminal readiness', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_DISCOVERY_MILESTONE_EVENT, + code: 'JOURNEY_DISCOVERY_VALIDATED', + ignored: 'bounded-extra-field', + }); + app.close(0, null); + }, + }); + assert.deepEqual(result, { + ok: false, + category: 'child-exit-before-ready', + capture: 'complete', + records: [{ + event: CONNECT_DISCOVERY_MILESTONE_EVENT, + code: 'JOURNEY_DISCOVERY_VALIDATED', + }], + }); + }); + + test('returns only exact allowlisted journey stages', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_PAIR_TRANSPORT', + url: 'https://not-returned.example.test/private', + }); + app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'UNBOUNDED_STAGE' }); + app.write({ + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'PROBE', + status: 'AUTHENTICATION_REQUIRED', + error: 'not-returned', + }); + app.write({ + event: CONNECT_RENDERER_OWNERSHIP_EVENT, + schemaVersion: 1, + resourceCategory: 'xhr', + mainRendererPresent: true, + mainRendererLive: true, + webContentsIdMatches: true, + webContentsAbsentOrMatches: true, + mainFrameLive: true, + rendererDocumentTrusted: true, + rendererDocumentAuthorityEqual: true, + frameOmitted: true, + framePresent: false, + frameMatchesMainFrame: false, + frameExplicitlyForeign: false, + rendererOwned: false, + url: 'not-returned', + }); + app.close(0, null); + }, + }); + assert.equal(result.category, 'child-exit-before-ready'); + assert.deepEqual(result.records, [ + { event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_TRANSPORT' }, + { event: CONNECT_JOURNEY_STAGE_EVENT }, + { + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'PROBE', + status: 'AUTHENTICATION_REQUIRED', + }, + { + event: CONNECT_RENDERER_OWNERSHIP_EVENT, + schemaVersion: 1, + resourceCategory: 'xhr', + mainRendererPresent: true, + mainRendererLive: true, + webContentsIdMatches: true, + webContentsAbsentOrMatches: true, + mainFrameLive: true, + rendererDocumentTrusted: true, + rendererDocumentAuthorityEqual: true, + frameOmitted: true, + framePresent: false, + frameMatchesMainFrame: false, + frameExplicitlyForeign: false, + rendererOwned: false, + }, + ]); + assert.doesNotMatch(JSON.stringify(result), /not-returned|UNBOUNDED_STAGE|url|error/u); + }); + + test('retains the latest bounded journey stage when earlier diagnostics fill the cap', async () => { + const { result } = await run({ + onApp: app => { + for (let index = 0; index < 20; index += 1) { + app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' }); + } + app.write({ + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'ACTIVATE', + status: 'REJECTED', + error: 'not-returned', + }); + app.write({ event: CONNECT_JOURNEY_STAGE_EVENT, code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD' }); + app.close(0, null); + }, + }); + assert.equal(result.records.length, 20); + assert.deepEqual(result.records.at(-2), { + event: CONNECT_JOURNEY_OPERATION_EVENT, + operation: 'ACTIVATE', + status: 'REJECTED', + }); + assert.deepEqual(result.records.at(-1), { + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_PAIR_ACTIVATION_DASHBOARD', + }); + assert.doesNotMatch(JSON.stringify(result), /not-returned/u); + }); + + test('retains only fixed terminal journey failure evidence when diagnostics fill the cap', async () => { + const { result } = await run({ + onApp: app => { + for (let index = 0; index < 20; index += 1) { + app.write({ event: 'desktop.app.ready', code: 'DETAIL_REDACTED' }); + } + app.write({ + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'pair', + stage: 'JOURNEY_PAIR_REACT_CONNECTED', + reason: 'RENDERER_STATE_TIMEOUT', + error: 'secret-SENTINEL', + url: 'https://not-returned.example.test/private', + responseBody: 'not-returned', + token: 'not-returned', + path: privateWindowsPath, + environment: 'not-returned', + }); + app.write({ event: 'desktop.app.start_failed', error: 'secret-SENTINEL' }); + app.close(1, null); + }, + }); + assert.equal(result.records.length, 20); + assert.deepEqual(result.records.at(-1), { + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'pair', + stage: 'JOURNEY_PAIR_REACT_CONNECTED', + reason: 'RENDERER_STATE_TIMEOUT', + }); + assert.doesNotMatch( + JSON.stringify(result), + /secret-SENTINEL|not-returned|private-user|url|responseBody|token|path|environment/u, + ); + }); + + test('drops non-allowlisted terminal journey failure fields', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_FAILURE_EVENT, + phase: 'hostile-phase', + stage: 'HOSTILE_STAGE', + reason: 'hostile-reason', + error: 'secret-SENTINEL', + }); + app.close(1, null); + }, + }); + assert.deepEqual(result.records, [{ event: CONNECT_JOURNEY_FAILURE_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /hostile|secret-SENTINEL/u); + }); + + test('returns only fixed secret-free Local Network Access decision evidence', async () => { + const fixed = { + event: CONNECT_NETWORK_PERMISSION_EVENT, + schemaVersion: 1, + permissionCategory: 'loopback-network', + decision: 'request', + allowed: true, + activeBindingCurrent: true, + webContentsPresent: true, + webContentsEqualsMainWindow: true, + mainWindowPresent: true, + isMainFrame: true, + requestingUrlPresent: true, + requestingUrlTrusted: true, + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityValid: true, + requestingOriginAuthorityEqual: true, + }; + const { result } = await run({ + onApp: app => { + app.write({ ...fixed, url: 'not-returned' }); + app.write({ ...fixed, permissionCategory: 'notifications', requestingUrl: 'not-returned' }); + app.close(0, null); + }, + }); + assert.deepEqual(result.records, [ + fixed, + { event: CONNECT_NETWORK_PERMISSION_EVENT }, + ]); + assert.doesNotMatch(JSON.stringify(result), /not-returned|"url":|"requestingUrl":/u); + }); + + test('fails closed when an otherwise allowlisted journey stage contains a secret', async () => { + const { result } = await run({ + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_REPROBE_TRANSPORT', + detail: 'secret-SENTINEL', + }); + app.close(0, null); + }, + }); + assert.equal(result.category, 'output-rejected'); + assert.deepEqual(result.records, [{ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_REPROBE_TRANSPORT', + }]); + assert.doesNotMatch(JSON.stringify(result), /SENTINEL|detail/u); + }); + + test('publishes the sole terminal READY only after each real journey phase', async () => { + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.equal((main.match(/'desktop\.renderer\.connect_discovery\.ready'/gu) ?? []).length, 1); + const connectBranch = main.slice( + main.indexOf('if (connectSmoke) {'), + main.indexOf('} else if (transportSmoke)'), + ); + const discovery = connectBranch.indexOf('await runPackagedConnectDiscoverySmoke'); + const journey = connectBranch.indexOf('await runPackagedConnectJourneySmoke'); + const ready = connectBranch.indexOf('await publishPackagedConnectReady'); + assert.ok(discovery >= 0 && discovery < journey && journey < ready); + + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const pair = harness.indexOf("outcome = await runPhase('pair')"); + const reprobe = harness.indexOf("outcome = await runPhase('reprobe')"); + const persistedEvidence = harness.indexOf('const applicationRequests = journeyFixture.requests'); + assert.ok(pair >= 0 && pair < reprobe && reprobe < persistedEvidence); + + const manual = main.indexOf("'JOURNEY_PAIR_MANUAL_FORM'"); + const browser = main.indexOf("reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL')"); + const credential = main.indexOf("'JOURNEY_PAIR_CREDENTIAL_COMMITTED'"); + const reprobeReady = main.indexOf("'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY'"); + const activation = main.indexOf("'JOURNEY_PAIR_ACTIVATION_COMMITTED'"); + const publication = main.indexOf("'JOURNEY_PAIR_ACTIVATION_PUBLISHED'"); + const react = main.indexOf("'JOURNEY_PAIR_REACT_CONNECTED'"); + assert.ok(manual >= 0 && browser >= 0 && credential >= 0 && reprobeReady >= 0 + && activation >= 0 && publication >= 0 && react >= 0); + assert.match(main, /await stages\.waitFor\('CREDENTIAL_COMMITTED'\)[\s\S]*?await stages\.waitFor\('AUTHENTICATED_REPROBE_READY'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_COMMITTED'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_PUBLISHED'\)[\s\S]*?await stages\.waitFor\('REACT_CONNECTED'\)/u); + assert.match(main, /Packaged pairing expiry classification failed[\s\S]*?await waitForApprovalIdle\(\)[\s\S]*?JOURNEY_NEGATIVE_CANCEL[\s\S]*?const approvalReady = waitForNextApproval\(\)[\s\S]*?await approvalReady[\s\S]*?Packaged pairing cancellation classification failed[\s\S]*?await waitForApprovalIdle\(\)/u); + assert.match(main, /await waitForApprovalIdle\(\);\s+reportPackagedConnectJourneyStage\(phase === 'pair'\s+\? 'JOURNEY_PAIR_COMPLETE'/u); + assert.match(main, /if \(packagedSmokeTest && !transportSmoke && !connectJourney\)/u); + assert.match(main, /if \(packagedSmokeTest && !connectJourney\) \{/u); + assert.doesNotMatch(main, /JOURNEY_PAIR_RENDERER|JOURNEY_REPROBE_RENDERER/u); + }); + + test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => { + const { result, invocations } = await run({ + onApp: app => app.write(readyRecord()), + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.equal(result.ok, true); + assert.equal(result.category, 'ready-forced-exit'); + assert.equal(invocations.length, 2); + assert.deepEqual(invocations[1].args, ['/PID', '4242', '/T', '/F']); + assert.equal(invocations[1].options.shell, false); + }); + + test('keeps timeout-before-ready primary while terminating and draining the tree', async () => { + const { result } = await run({ + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'timeout-before-ready'); + assert.equal(result.secondary, undefined); + }); + + test('classifies asynchronous spawn errors without exposing their message', async () => { + const app = new FakeChild(undefined); + const { result } = await run({ + app, + onApp: child => { + child.emit('error', new Error('/private/path-SENTINEL secret-SENTINEL')); + child.close(null, null); + }, + }); + assert.equal(result.category, 'spawn-error'); + assert.doesNotMatch(JSON.stringify(result), /private|SENTINEL/u); + }); + + test('settles close/timeout races once and never upgrades an early exit to success', async () => { + const { result } = await run({ + readyTimeoutMs: 0, + onApp: app => app.close(0, null), + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.ok(['timeout-before-ready', 'child-exit-before-ready'].includes(result.category)); + assert.equal(result.ok, false); + }); + + test('accepts a clean post-proof close racing a taskkill no-process result', async () => { + const { result } = await run({ + onApp: app => app.write(readyRecord()), + onKiller: (killer, app) => { + app.close(0, null); + killer.close(128, null); + }, + }); + assert.deepEqual(result, { + ok: true, + category: 'ready-clean-exit', + capture: 'complete', + records: [{ event: CONNECT_READY_EVENT }], + }); + }); + + test('rejects malformed, partial, truncated, and extra-field ready records', async () => { + assert.equal(isExactReadyRecord(readyRecord(), expected), true); + for (const invalid of [ + readyRecord({ selectedArch: 'arm64' }), + readyRecord({ rendererSchemaValid: 'true' }), + readyRecord({ secret: 'secret-SENTINEL' }), + ]) assert.equal(isExactReadyRecord(invalid, expected), false); + + const { result } = await run({ + onApp: app => { + app.write(`${JSON.stringify(readyRecord()).slice(0, -2)}\n`); + app.write(`${'x'.repeat(70 * 1024)}\n`); + app.close(0, null); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'child-exit-before-ready'); + assert.equal(result.capture, 'truncated'); + }); + + test('terminates an exact-event record whose platform proof is invalid', async () => { + const { result } = await run({ + onApp: app => app.write(readyRecord({ selectedPlatform: 'linux' })), + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'ready-validation'); + assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); + }); + + test('fails after proof when Windows tree termination cannot be proven', async () => { + const { result } = await run({ + onApp: app => app.write(readyRecord()), + onKiller: killer => killer.close(1, null), + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'tree-termination'); + assert.deepEqual(result.secondary, ['tree-termination-failed']); + }); + + test('never returns secret-bearing raw output or non-allowlisted record fields', async () => { + const { result } = await run({ + onApp: app => app.write(JSON.stringify({ + event: 'desktop.app.start_failed', + error: { code: 'OPERATION_FAILED', message: '/private/path-SENTINEL secret-SENTINEL' }, + }) + '\n'), + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.equal(result.category, 'output-rejected'); + assert.deepEqual(result.records, [{ event: 'desktop.app.start_failed', code: 'OPERATION_FAILED' }]); + assert.doesNotMatch(JSON.stringify(result), /private|SENTINEL|message/u); + }); + + test('revokes success when sensitive output arrives after the exact ready proof', async () => { + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => { + app.write('late secret-SENTINEL\n'); + app.close(0, null); + }); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'output-rejected'); + assert.doesNotMatch(JSON.stringify(result), /SENTINEL/u); + }); + + test('rejects a JSON-escaped Windows path in a non-allowlisted record before readiness', async () => { + const encoded = JSON.stringify({ event: 'untrusted.event', detail: { path: privateWindowsPath } }); + assert.equal(encoded.includes(privateWindowsPath), false); + const { result } = await run({ + onApp: app => { + app.write(`${encoded}\n`); + app.write(readyRecord()); + }, + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'output-rejected'); + assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /private-user|private-path-SENTINEL/u); + }); + + test('revokes success for a JSON-escaped Windows path after the exact ready proof', async () => { + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => { + app.write({ event: 'untrusted.event', detail: { path: privateWindowsPath } }); + app.close(0, null); + }); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'output-rejected'); + assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /private-user|private-path-SENTINEL/u); + }); + + test('revokes success when a JSON-escaped Windows path follows the record-count cap', async () => { + const encodedSensitiveRecord = JSON.stringify({ + event: 'untrusted.event', detail: { path: privateWindowsPath }, + }); + assert.equal(encodedSensitiveRecord.includes(privateWindowsPath), false); + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => { + for (let index = 1; index < 128; index += 1) { + app.write({ event: 'untrusted.event', index }); + } + app.write(`${encodedSensitiveRecord}\n`); + app.close(0, null); + }); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'output-rejected'); + assert.equal(result.capture, 'truncated'); + assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /private-user|private-path-SENTINEL/u); + }); + + test('revokes success when a JSON-escaped Windows path follows the byte cap', async () => { + const encodedSensitiveRecord = JSON.stringify({ + event: 'untrusted.event', detail: { path: privateWindowsPath }, + }); + assert.equal(encodedSensitiveRecord.includes(privateWindowsPath), false); + const benignRecord = `${JSON.stringify({ + event: 'untrusted.event', detail: 'x'.repeat(7 * 1024), + })}\n`; + const recordsToExceedBudget = Math.ceil( + CHILD_CAPTURE_MAX_BYTES / Buffer.byteLength(benignRecord), + ) + 1; + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => { + app.write(benignRecord.repeat(recordsToExceedBudget)); + app.write(`${encodedSensitiveRecord}\n`); + app.close(0, null); + }); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'output-rejected'); + assert.equal(result.capture, 'truncated'); + assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); + assert.doesNotMatch(JSON.stringify(result), /private-user|private-path-SENTINEL/u); + }); +}); + +describe('packaged Connect fixture cleanup', () => { + const fixture = '/canonical-temp/propr-desktop-connect-smoke-AbC123'; + const stats = { isDirectory: () => true, isSymbolicLink: () => false }; + const identityOptions = { + fixture, + canonicalTemporaryParent: '/canonical-temp', + generatedLeaf: 'propr-desktop-connect-smoke-AbC123', + platform: 'win32', + retryBoundMs: 20, + retryDelayMs: 1, + lstatImpl: async () => stats, + realpathImpl: async value => value, + }; + const settlesWithin = async (promise, milliseconds = 250) => { + let timer; + try { + return await Promise.race([ + promise, + new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('cleanup exceeded its test bound')), milliseconds); + }), + ]); + } finally { + clearTimeout(timer); + } + }; + + test('closes the journey fixture once and tolerates only the already-stopped server condition', async () => { + let socketCloses = 0; + let httpCloses = 0; + const close = createIdempotentJourneyFixtureClose({ + closeSocketServer: async () => { socketCloses += 1; }, + closeHttpServer: async () => { + httpCloses += 1; + throw Object.assign(new Error('server already stopped'), { code: 'ERR_SERVER_NOT_RUNNING' }); + }, + }); + const first = close(); + const second = close(); + assert.equal(first, second); + await Promise.all([first, second, close()]); + assert.equal(socketCloses, 1); + assert.equal(httpCloses, 1); + + const failure = createIdempotentJourneyFixtureClose({ + closeSocketServer: async () => undefined, + closeHttpServer: async () => { + throw Object.assign(new Error('/private/path-SENTINEL'), { code: 'EIO' }); + }, + }); + await assert.rejects(failure(), { code: 'EIO' }); + assert.equal(failure(), failure()); + }); + + test('retries a transient Windows EBUSY only inside the authorized fixture', async () => { + let attempts = 0; + const result = await removeAuthorizedConnectFixture({ + ...identityOptions, + rmImpl: async removed => { + assert.equal(removed, fixture); + attempts += 1; + if (attempts === 1) throw Object.assign(new Error('busy private path'), { code: 'EBUSY' }); + }, + }); + assert.deepEqual(result, { ok: true }); + assert.equal(attempts, 2); + }); + + test('redacts cleanup failure and preserves the primary lifecycle outcome', async () => { + const cleanup = await removeAuthorizedConnectFixture({ + ...identityOptions, + retryBoundMs: 0, + rmImpl: async () => { throw Object.assign(new Error('/private/path-SENTINEL'), { code: 'EBUSY' }); }, + }); + const combined = preservePrimaryWithCleanup({ + ok: false, + category: 'timeout-before-ready', + capture: 'complete', + records: [], + }, cleanup); + assert.equal(combined.category, 'timeout-before-ready'); + assert.deepEqual(combined.secondary, ['fixture-cleanup-failed']); + assert.doesNotMatch(JSON.stringify(combined), /private|SENTINEL/u); + }); + + test('bounds a never-settling removal and preserves the primary result', async () => { + const cleanup = await settlesWithin(removeAuthorizedConnectFixture({ + ...identityOptions, + retryBoundMs: 10, + rmImpl: () => new Promise(() => {}), + })); + assert.deepEqual(cleanup, { ok: false, category: 'fixture-cleanup-failed' }); + const primary = { + ok: false, + category: 'timeout-before-ready', + capture: 'complete', + records: [], + }; + assert.deepEqual(preservePrimaryWithCleanup(primary, cleanup), { + ...primary, + secondary: ['fixture-cleanup-failed'], + }); + }); + + test('bounds a never-settling authorization call as a fixed cleanup failure', async () => { + let removalAttempted = false; + const cleanup = await settlesWithin(removeAuthorizedConnectFixture({ + ...identityOptions, + retryBoundMs: 10, + lstatImpl: () => new Promise(() => {}), + rmImpl: async () => { removalAttempted = true; }, + })); + assert.deepEqual(cleanup, { ok: false, category: 'fixture-cleanup-failed' }); + assert.equal(removalAttempted, false); + const primary = { ok: false, category: 'spawn-error', capture: 'complete', records: [] }; + assert.deepEqual(preservePrimaryWithCleanup(primary, cleanup), { + ...primary, + secondary: ['fixture-cleanup-failed'], + }); + }); + + test('isolates default Windows filesystem cleanup from the harness process', async () => { + const canonicalTemporaryParent = await realpath(tmpdir()); + const isolatedFixture = await mkdtemp(join( + canonicalTemporaryParent, 'propr-desktop-connect-smoke-', + )); + try { + const cleanup = await removeAuthorizedConnectFixture({ + fixture: isolatedFixture, + canonicalTemporaryParent, + platform: 'win32', + retryBoundMs: 2_000, + }); + assert.deepEqual(cleanup, { ok: true }); + await assert.rejects(lstat(isolatedFixture), { code: 'ENOENT' }); + } finally { + await rm(isolatedFixture, { recursive: true, force: true }); + } + }); + + test('refuses a link, renamed leaf, or fixture outside the canonical temporary parent', async () => { + for (const options of [ + { fixture: '/elsewhere/propr-desktop-connect-smoke-AbC123' }, + { generatedLeaf: 'propr-desktop-connect-smoke-Different' }, + { lstatImpl: async () => ({ isDirectory: () => true, isSymbolicLink: () => true }) }, + ]) { + let removed = false; + const result = await removeAuthorizedConnectFixture({ + ...identityOptions, + ...options, + rmImpl: async () => { removed = true; }, + }); + assert.deepEqual(result, { ok: false, category: 'fixture-cleanup-authorization-failed' }); + assert.equal(removed, false); + } + }); +}); diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs new file mode 100644 index 000000000..2e24d3207 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import { execFile as nodeExecFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { describe, test } from 'node:test'; +import { promisify } from 'node:util'; + +const execFile = promisify(nodeExecFile); + +const workflow = await readFile( + new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url), + 'utf8', +); +const darwinRunner = await readFile( + new URL('./run-packaged-darwin-connect-smoke.sh', import.meta.url), + 'utf8', +); +const forgeConfig = await readFile(new URL('../forge.config.ts', import.meta.url), 'utf8'); +const darwinSigner = await readFile( + new URL('./sign-darwin-packaged-connect.mjs', import.meta.url), + 'utf8', +); +const darwinVerifier = await readFile( + new URL('./verify-darwin-packaged-connect-signature.mjs', import.meta.url), + 'utf8', +); +const packagedConnectSmoke = await readFile( + new URL('./smoke-packaged-connect.mjs', import.meta.url), + 'utf8', +); +const desktopMain = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); +const boundedDarwinRunner = await readFile( + new URL('./run-bounded-darwin-command.mjs', import.meta.url), + 'utf8', +); + +describe('packaged Connect target-native credential setup', () => { + test('Linux retains one isolated unlocked libsecret session and rejects plaintext fallback', async () => { + const linux = workflow.slice( + workflow.indexOf('- name: Run packaged Linux main-to-renderer discovery'), + workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'), + ); + assert.match(linux, /keyring_root="\$\(mktemp -d\)"/u); + assert.match(linux, /export XDG_DATA_HOME="\$1"/u); + assert.match(linux, /export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="\$1"/u); + assert.match(linux, /gnome-keyring-daemon --unlock --components=secrets/u); + + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match(main, /process\.platform === 'linux' \? 'gnome_libsecret' : 'os-protected'/u); + assert.match(main, /security\.backend !== requiredStorageBackend/u); + }); + + test('inspects the ordinary unsigned package before adding the Darwin-only acceptance identity', () => { + const darwin = workflow.slice( + workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'), + workflow.indexOf('- name: Run packaged Windows main-to-renderer discovery'), + ); + const inspect = workflow.indexOf('- name: Inspect the unsigned target-native desktop app'); + const darwinLaunch = workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'); + assert.ok(inspect >= 0 && inspect < darwinLaunch); + assert.match(workflow, /- name: Inspect the unsigned target-native desktop app\n\s+run: npm run desktop:smoke:inspect/u); + assert.match(darwin, /node apps\/desktop\/scripts\/run-bounded-darwin-command\.mjs[\s\S]*?--timeout-ms 480000[\s\S]*?-- bash apps\/desktop\/scripts\/run-packaged-darwin-connect-smoke\.sh '\$\{\{ matrix\.arch \}\}'/u); + assert.doesNotMatch(forgeConfig, /PACKAGED_CONNECT.*SIGN|SMOKE.*SIGN/iu); + assert.match(forgeConfig, /\.\.\.\(macSigning \? \{[\s\S]*?osxSign: \{[\s\S]*?identity: macSigning\.PROPR_DESKTOP_MAC_SIGNING_IDENTITY/u); + assert.doesNotMatch(`${forgeConfig}\n${darwinRunner}\n${darwinSigner}`, /Developer ID Application/u); + }); + + test('Darwin creates one ephemeral certificate-backed identity and proves it across both launches', () => { + assert.match(darwinRunner, /keychain_root="\$\(run_bounded_forward[^\n]*\/usr\/bin\/mktemp -d\)"/u); + assert.match(darwinRunner, /keychain_password="\$\(run_bounded_forward[\s\S]*?\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /identity_password="\$\(run_bounded_forward[\s\S]*?\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /x509_extensions = leaf_extensions/u); + assert.match(darwinRunner, /basicConstraints = critical,CA:FALSE/u); + assert.match(darwinRunner, /extendedKeyUsage = critical,codeSigning/u); + assert.match(darwinRunner, /openssl req -new -x509 -newkey rsa:2048[\s\S]*?-days 1[\s\S]*?-config "\$leaf_config"/u); + assert.doesNotMatch(darwinRunner, /root_(?:private_key|certificate|config)|leaf_request|-CA(?:key)?\b/u); + assert.doesNotMatch(darwinRunner, /add-trusted-cert|remove-trusted-cert|trustRoot/u); + assert.match(darwinRunner, /\/usr\/bin\/security import "\$identity_archive" \\[\s\S]*?-T \/usr\/bin\/codesign/u); + assert.match(darwinRunner, /\/usr\/bin\/security set-key-partition-list \\[\s\S]*?-S apple-tool:,apple:,codesign:/u); + assert.match(darwinRunner, /run_bounded_forward "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + assert.doesNotMatch(darwinSigner, /from '@electron\/osx-sign'/u); + assert.match(darwinSigner, /discoverDarwinSignablePaths/u); + assert.match(darwinSigner, /'--sign', certificateSha1/u); + assert.match(darwinSigner, /'--keychain', keychain/u); + assert.match(darwinSigner, /'--timestamp=none'/u); + assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); + assert.match(darwinVerifier, /\['-d', '--verbose=4', application\]/u); + assert.doesNotMatch(darwinVerifier, /'--test-requirement'|['"`]?-R(?:=|['"`])/u); + assert.match(darwinVerifier, /fingerprints\.length !== 1 \|\| fingerprints\[0\] !== expectedSha1/u); + assert.match(darwinVerifier, /ADHOC_SIGNATURE_LINE = \/\^\\s\*signature\\s\*=\\s\*adhoc\\s\*\$\/iu/u); + assert.match(darwinVerifier, /identifiers\.length !== 1/u); + assert.match(darwinVerifier, /identifiers\[0\] !== REQUIRED_IDENTIFIER/u); + assert.match(darwinVerifier, /signatureSizes\.length !== 1/u); + assert.match(darwinVerifier, /POSITIVE_SIGNATURE_SIZE\.test\(signatureSizes\[0\]\)/u); + assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_PREFIX/u); + assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_GRAMMAR/u); + assert.match(darwinVerifier, /designatedLines\.length !== 1/u); + assert.match(darwinVerifier, /requirementMatch\[1\] !== REQUIRED_IDENTIFIER/u); + assert.match(darwinVerifier, /requirementMatch\[2\]\.toUpperCase\(\) !== expectedSha1/u); + assert.doesNotMatch(darwinVerifier, /Authority=/u); + assert.doesNotMatch(darwinVerifier, /extract-certificates/u); + assert.doesNotMatch(darwinVerifier, /find-identity/u); + assert.match(darwinSigner, /certificate leaf = H"\$\{certificateSha1\}"/u); + assert.match(darwinSigner, /filter\(filePath => !PACKAGED_CONNECT_NATIVE_ARTIFACTS\.test\(filePath\)\)/u); + assert.match(darwinSigner, /'--verify', '--deep', '--strict', application/u); + assert.match(darwinVerifier, /'--verify', '--deep', '--strict', application/u); + assert.match(darwinVerifier, /previousDesignatedRequirement !== normalizedRequirement/u); + assert.match(desktopMain, /storageBackend: requiredStorageBackend/u); + assert.match(packagedConnectSmoke, /expectedStorageBackend: 'os-protected'/u); + assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);[\s\S]*?outcome = await runPhase\('reprobe'\)/u); + assert.match(packagedConnectSmoke, /authenticatedRestCount: authenticatedRest\.length/u); + assert.match(packagedConnectSmoke, /authenticatedSocketCount: socketEvidence\.authenticatedSocketCount/u); + const establish = darwinRunner.indexOf('node "$signature_verifier" establish'); + const smoke = darwinRunner.indexOf('node "$script_directory/smoke-packaged-connect.mjs"'); + const stable = darwinRunner.indexOf('node "$signature_verifier" stable'); + assert.ok(establish >= 0 && establish < smoke && smoke < stable); + assert.match(packagedConnectSmoke, /const runPhase = async phase => await runPackagedConnectLifecycle\([\s\S]*?spawn: spawnLifecycleProcess/u); + assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);\s*if \(outcome\.ok && journeyFixture\) \{\s*const pairingRequestCountAtPairTerminal = journeyFixture\.requests\.filter\(request =>[^{};]+\)\.length;\s*outcome = await runPhase\('reprobe'\);/u); + assert.match(workflow, /target: darwin-x64\s+runner: macos-15-intel\s+platform: darwin\s+arch: x64/u); + assert.match(workflow, /target: darwin-arm64\s+runner: macos-15\s+platform: darwin\s+arch: arm64/u); + }); + + test('Darwin root signing sets, but never preserves, the required identifier', () => { + assert.match(darwinSigner, /isApplication \? \[\s*'--identifier', REQUIRED_IDENTIFIER,\s*'--preserve-metadata=entitlements,flags',\s*\] : \[\s*'--preserve-metadata=identifier,entitlements,flags',\s*\]/u); + const rootMetadataBranch = /isApplication \? \[([\s\S]*?)\] : \[/u.exec(darwinSigner)?.[1]; + assert.ok(rootMetadataBranch); + assert.match(rootMetadataBranch, /'--identifier', REQUIRED_IDENTIFIER/u); + assert.match(rootMetadataBranch, /'--preserve-metadata=entitlements,flags'/u); + assert.doesNotMatch(rootMetadataBranch, /--preserve-metadata=identifier,/u); + }); + + test('Darwin emits only allowlisted fixed stage markers around every blocking phase', async () => { + const expectedStages = [ + 'KEY_CERTIFICATE_GENERATION', + 'KEYCHAIN_CREATION_SELECTION', + 'IDENTITY_IMPORT', + 'PARTITION_LIST_UPDATE', + 'APPLICATION_SIGNING', + 'INITIAL_SIGNATURE_VERIFICATION', + 'PAIR_REPROBE_JOURNEY', + 'STABLE_SIGNATURE_VERIFICATION', + 'KEYCHAIN_RESTORATION_DELETION', + 'TEMPORARY_FILE_CLEANUP', + ]; + const invokedStages = [...darwinRunner.matchAll(/^\s*run_stage ([A-Z_]+)\b/gmu)] + .map(match => match[1]); + assert.deepEqual(new Set(invokedStages), new Set(expectedStages)); + assert.equal(invokedStages.length, expectedStages.length); + assert.match(darwinRunner, /case "\$code" in\n\s+STARTED\|PASSED\|FAILED\)/u); + assert.match(darwinRunner, /printf 'DARWIN_PACKAGED_CONNECT_SETUP:%s:%s\\n' "\$stage" "\$code"/u); + assert.doesNotMatch(darwinRunner, /stage_marker[^\n]*(?:password|certificate_serial|identity_sha1)/u); + + const markerFunction = darwinRunner.slice( + darwinRunner.indexOf('stage_marker() {'), + darwinRunner.indexOf('\n\nrun_bounded()'), + ); + const markerCalls = expectedStages + .flatMap(stage => ['STARTED', 'PASSED', 'FAILED'] + .map(code => `stage_marker ${stage} ${code}`)) + .join('\n'); + const { stdout } = await execFile('/bin/bash', ['-c', `${markerFunction}\n${markerCalls}`], { + encoding: 'utf8', timeout: 2_000, maxBuffer: 16 * 1024, + }); + assert.deepEqual(stdout.trim().split('\n'), expectedStages.flatMap(stage => [ + 'STARTED', 'PASSED', 'FAILED', + ].map(code => `DARWIN_PACKAGED_CONNECT_SETUP:${stage}:${code}`))); + await assert.rejects(execFile('/bin/bash', ['-c', `${markerFunction}\nstage_marker BAD SECRET`], { + encoding: 'utf8', timeout: 2_000, maxBuffer: 16 * 1024, + })); + }); + + test('Darwin bounds setup, nested signing, verification, journey, cleanup, and the wrapper', () => { + assert.match(boundedDarwinRunner, /case 'node': return spawn\(process\.execPath, arguments_, options\)/u); + assert.match(boundedDarwinRunner, /case 'security': return spawn\('\/usr\/bin\/security', arguments_, options\)/u); + assert.doesNotMatch(boundedDarwinRunner, /nodeSpawn\(argv\[1\]/u); + assert.match(boundedDarwinRunner, /detached: platform !== 'win32'/u); + assert.match(boundedDarwinRunner, /process\.kill\(-child\.pid, signal\)/u); + assert.match(boundedDarwinRunner, /GROUP_GUARD_RELEASE/u); + assert.match(boundedDarwinRunner, /prevents the PGID from being reused/u); + assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGTERM'/u); + assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGKILL'/u); + assert.match(boundedDarwinRunner, /maximumBytes - state\.bytes/u); + assert.match(darwinVerifier, /runBoundedProcess/u); + assert.match(darwinVerifier, /timeoutMs: VERIFICATION_TIMEOUT_MS/u); + assert.match(darwinVerifier, /maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES/u); + assert.match(darwinSigner, /runBoundedProcess/u); + assert.match(darwinSigner, /timeoutMs: CODESIGN_TIMEOUT_MS/u); + assert.match(darwinSigner, /forwardOutput: false/u); + assert.match(darwinRunner, /run_bounded "\$COMMAND_TIMEOUT_MS" \/usr\/bin\/security/gmu); + assert.match(darwinRunner, /run_bounded "\$COMMAND_TIMEOUT_MS" \/usr\/bin\/openssl/gmu); + assert.match(darwinRunner, /run_bounded_forward "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + assert.match(darwinRunner, /cd "\$repository_root\/apps\/desktop"[\s\S]*?run_bounded_forward "\$JOURNEY_TIMEOUT_MS" node "\$script_directory\/smoke-packaged-connect\.mjs"/u); + }); + + test('Darwin failure diagnostics are fixed, classified, and secret-safe', () => { + for (const diagnostic of [ + 'MISSING_IDENTITY_OR_CHAIN', + 'TRUST_REJECTION', + 'REQUIREMENTS_FAILURE', + 'CODESIGN_FAILURE', + ]) { + assert.match(darwinSigner, new RegExp(`['"]${diagnostic}['"]`, 'u')); + } + for (const diagnostic of [ + 'CERTIFICATE_LOOKUP_FAILURE', + 'SIGNATURE_DISPLAY_FAILURE', + 'EMBEDDED_REQUIREMENT_FAILURE', + 'STRICT_VERIFY_FAILURE', + 'KEYCHAIN_EVIDENCE_FAILURE', + 'ADHOC_SIGNATURE_FAILURE', + 'IDENTIFIER_METADATA_FAILURE', + 'SIGNATURE_METADATA_FAILURE', + 'REQUIREMENT_EVIDENCE_FAILURE', + 'EVIDENCE_ASSERTION_FAILURE', + ]) { + assert.match(darwinVerifier, new RegExp(`['"]${diagnostic}['"]`, 'u')); + } + assert.match(darwinSigner, /DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:\$\{classifyDarwinSigningFailure\(error\)\}/u); + assert.doesNotMatch(darwinSigner, /process\.stderr\.write\([^\n]*(?:application|keychain|certificateSha1|stderr|stdout)/u); + assert.match(darwinRunner, /run_bounded_forward "\$COMMAND_TIMEOUT_MS" node "\$signature_verifier" establish/u); + assert.match(darwinRunner, /run_bounded_forward "\$COMMAND_TIMEOUT_MS" node "\$signature_verifier" stable/u); + }); + + test('Darwin restores keychain state and deletes identity, credentials, and files on every exit', () => { + assert.match(darwinRunner, /trap cleanup_keychain EXIT/u); + assert.match(darwinRunner, /trap 'exit_for_signal 129' HUP/u); + assert.match(darwinRunner, /trap 'exit_for_signal 130' INT/u); + assert.match(darwinRunner, /trap 'exit_for_signal 143' TERM/u); + assert.match(darwinRunner, /if \[\[ -n "\$active_stage" \]\]; then\n\s+stage_marker "\$active_stage" FAILED/u); + assert.doesNotMatch(darwinRunner, /add-trusted-cert|remove-trusted-cert|trustRoot/u); + assert.match(darwinRunner, /\/usr\/bin\/security list-keychains -d user -s \\[\s\S]*?"\$\{original_keychains\[@\]\}"/u); + assert.match(darwinRunner, /\/usr\/bin\/security default-keychain -d user -s \\[\s\S]*?"\$original_default"/u); + assert.match(darwinRunner, /\/usr\/bin\/security delete-keychain "\$keychain_path"/u); + assert.doesNotMatch(darwinRunner, /certificate_prefix/u); + assert.match(darwinRunner, /"\$requirement_proof" "\$keychain_path"/u); + assert.match(darwinRunner, /run_bounded "\$CLEANUP_TIMEOUT_MS" \/bin\/rm -rf -- "\$keychain_root"/u); + assert.match(darwinRunner, /if \(\( cleanup_status != 0 \)\)[\s\S]*?primary_status=1/u); + }); + + test('Darwin smoke has no static or production identity and does not widen or pre-seed Safe Storage', async () => { + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.doesNotMatch(darwinRunner, /add-generic-password|Safe Storage|-A(?:\s|$)/u); + assert.doesNotMatch(darwinRunner, /Developer ID|notari|APPLE_|PROPR_DESKTOP_MAC_/iu); + assert.doesNotMatch(darwinRunner, /(?:keychain|identity)_password=['"][^$]/u); + assert.doesNotMatch(workflow, /secrets\.[^\n]*Packaged Connect|Packaged Connect[^\n]*secrets\./u); + assert.match(main, /const requiredStorageBackend = process\.platform === 'linux' \? 'gnome_libsecret' : 'os-protected'/u); + assert.match(main, /security\.backend !== requiredStorageBackend/u); + }); +}); diff --git a/apps/desktop/scripts/packaged-layout.d.mts b/apps/desktop/scripts/packaged-layout.d.mts new file mode 100644 index 000000000..4970d65ef --- /dev/null +++ b/apps/desktop/scripts/packaged-layout.d.mts @@ -0,0 +1,3 @@ +export const parseEventRecord: (smokeOutput: string, expectedEvent: string) => Record | undefined; +export const parseEventLayout: (smokeOutput: string, expectedEvent: string) => unknown; +export const assertPackagedLayout: (layout: unknown, platform?: NodeJS.Platform) => void; diff --git a/apps/desktop/scripts/packaged-layout.mjs b/apps/desktop/scripts/packaged-layout.mjs new file mode 100644 index 000000000..2d4658b38 --- /dev/null +++ b/apps/desktop/scripts/packaged-layout.mjs @@ -0,0 +1,128 @@ +const EXPECTED_WINDOW_SIZE = { width: 1280, height: 820 }; +const MINIMUM_WINDOW_SIZE = { width: 880, height: 620 }; + +export const parseEventRecord = (smokeOutput, expectedEvent) => { + for (const line of smokeOutput.split(/\r?\n/)) { + if (!line.includes(expectedEvent)) continue; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + if (record.event === expectedEvent) return record; + } catch { + // Ignore non-JSON Chromium output that happens to mention the event name. + } + } + return undefined; +}; + +export const parseEventLayout = (smokeOutput, expectedEvent) => ( + parseEventRecord(smokeOutput, expectedEvent)?.layout +); + +const fail = message => { + throw new Error(message); +}; + +const assertPositiveDimensions = (name, bounds) => { + if (!bounds + || !Number.isFinite(bounds.width) || bounds.width <= 0 + || !Number.isFinite(bounds.height) || bounds.height <= 0) { + fail(`Packaged ${name} does not have positive bounds: ${JSON.stringify(bounds)}`); + } +}; + +const assertElementBounds = (name, bounds) => { + assertPositiveDimensions(name, bounds); + if (![bounds.left, bounds.top, bounds.right, bounds.bottom].every(Number.isFinite) + || bounds.right - bounds.left !== bounds.width + || bounds.bottom - bounds.top !== bounds.height) { + fail(`Packaged ${name} has inconsistent bounds: ${JSON.stringify(bounds)}`); + } +}; + +const contains = (outer, inner) => inner.left >= outer.left + && inner.top >= outer.top + && inner.right <= outer.right + && inner.bottom <= outer.bottom; + +export const assertPackagedLayout = (layout, platform = process.platform) => { + if (!layout) fail('Packaged desktop did not report renderer layout bounds'); + if (layout.missing?.length) { + fail(`Packaged renderer layout was missing: ${layout.missing.join(', ')}`); + } + + assertPositiveDimensions('window', layout.windowBounds); + assertPositiveDimensions('visible work area', layout.workArea); + if (![layout.windowBounds.x, layout.windowBounds.y, layout.workArea.x, layout.workArea.y].every(Number.isFinite)) { + fail(`Packaged window or visible work area has invalid coordinates: ${JSON.stringify({ + windowBounds: layout.windowBounds, + workArea: layout.workArea, + })}`); + } + + if (platform === 'linux') { + if (layout.windowBounds.width !== EXPECTED_WINDOW_SIZE.width + || layout.windowBounds.height !== EXPECTED_WINDOW_SIZE.height) { + fail(`Packaged Linux window was not 1280x820: ${JSON.stringify(layout.windowBounds)}`); + } + } else if (platform === 'win32') { + if (layout.windowBounds.width < MINIMUM_WINDOW_SIZE.width + || layout.windowBounds.height < MINIMUM_WINDOW_SIZE.height + || layout.windowBounds.width > EXPECTED_WINDOW_SIZE.width + || layout.windowBounds.height > EXPECTED_WINDOW_SIZE.height) { + fail(`Packaged Windows window was outside the safe clamped range: ${JSON.stringify(layout.windowBounds)}`); + } + } else { + fail(`Packaged layout assertion does not support ${platform}`); + } + + const windowRight = layout.windowBounds.x + layout.windowBounds.width; + const windowBottom = layout.windowBounds.y + layout.windowBounds.height; + const workAreaRight = layout.workArea.x + layout.workArea.width; + const workAreaBottom = layout.workArea.y + layout.workArea.height; + if (layout.windowBounds.x < layout.workArea.x + || layout.windowBounds.y < layout.workArea.y + || windowRight > workAreaRight + || windowBottom > workAreaBottom) { + fail(`Packaged window extends outside the visible work area: ${JSON.stringify({ + windowBounds: layout.windowBounds, + workArea: layout.workArea, + })}`); + } + + assertPositiveDimensions('renderer viewport', layout.viewport); + if (layout.viewport.width > layout.windowBounds.width || layout.viewport.height > layout.windowBounds.height) { + fail(`Packaged renderer viewport extends outside the window: ${JSON.stringify(layout.viewport)}`); + } + if (platform === 'linux' && (layout.viewport.width < 1200 || layout.viewport.height < 740)) { + fail(`Packaged Linux renderer viewport is unexpectedly small: ${JSON.stringify(layout.viewport)}`); + } + + const elementNames = ['entry', 'card', 'logo', 'heading', 'connectButton', 'connectDescription']; + for (const name of elementNames) assertElementBounds(name, layout[name]); + const viewportBounds = { + top: 0, + left: 0, + right: layout.viewport.width, + bottom: layout.viewport.height, + }; + if (elementNames.some(name => !contains(viewportBounds, layout[name]))) { + fail('Packaged welcome-card content extends outside the renderer viewport'); + } + if (!contains(layout.entry, layout.card) + || !contains(layout.card, layout.logo) + || !contains(layout.card, layout.heading) + || !contains(layout.card, layout.connectButton) + || !contains(layout.connectButton, layout.connectDescription)) { + fail('Packaged welcome-card content extends outside its layout container'); + } + + if (layout.logo.height < 30 || layout.logo.height > 34 || layout.logo.width < 30 || layout.logo.width > 34) { + fail(`Packaged welcome-card logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`); + } + if (layout.card.width < 540 || layout.card.width > 620 || layout.connectButton.height < 60) { + fail(`Packaged welcome card or connection control has unreasonable bounds: ${JSON.stringify(layout)}`); + } + if (layout.heading.top <= layout.logo.bottom || layout.connectButton.top <= layout.heading.bottom) { + fail('Packaged welcome-card content is overlapping or out of order'); + } +}; diff --git a/apps/desktop/scripts/packaged-layout.test.mjs b/apps/desktop/scripts/packaged-layout.test.mjs new file mode 100644 index 000000000..d7a2b3aec --- /dev/null +++ b/apps/desktop/scripts/packaged-layout.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { assertPackagedLayout, parseEventRecord } from './packaged-layout.mjs'; + +const bounds = (left, top, width, height) => ({ + bottom: top + height, + height, + left, + right: left + width, + top, + width, +}); + +const layout = ({ + windowWidth = 1280, + windowHeight = 820, + viewportWidth = 1280, + viewportHeight = 780, + workAreaWidth = 1280, + workAreaHeight = 900, +} = {}) => ({ + windowBounds: { x: 0, y: 0, width: windowWidth, height: windowHeight }, + workArea: { x: 0, y: 0, width: workAreaWidth, height: workAreaHeight }, + viewport: { width: viewportWidth, height: viewportHeight }, + entry: bounds(0, 0, viewportWidth, viewportHeight), + card: bounds((viewportWidth - 580) / 2, 40, 580, 640), + logo: bounds((viewportWidth - 32) / 2, 72, 32, 32), + heading: bounds((viewportWidth - 420) / 2, 132, 420, 58), + connectButton: bounds((viewportWidth - 520) / 2, 230, 520, 76), + connectDescription: bounds((viewportWidth - 300) / 2, 270, 300, 18), +}); + +describe('packaged desktop event parsing', () => { + it('returns the first full record for the exact matching event', () => { + const firstProof = { + event: 'desktop.renderer.mvp_flows.ready', + localProfile: true, + remoteActiveProfile: true, + lifecycleBoundary: true, + connectUiPopulated: true, + }; + const output = [ + 'not JSON: desktop.renderer.mvp_flows.ready', + JSON.stringify({ event: 'desktop.renderer.mvp_flows.ready.extra', localProfile: false }), + JSON.stringify({ event: 'desktop.renderer.other', note: 'desktop.renderer.mvp_flows.ready' }), + JSON.stringify(firstProof), + JSON.stringify({ event: 'desktop.renderer.mvp_flows.ready', localProfile: false }), + ].join('\n'); + + assert.deepEqual(parseEventRecord(output, firstProof.event), firstProof); + }); + + it('returns undefined when the event is absent', () => { + const output = [ + '{malformed', + JSON.stringify({ event: 'desktop.renderer.other' }), + ].join('\n'); + + assert.equal(parseEventRecord(output, 'desktop.renderer.mvp_flows.ready'), undefined); + }); +}); + +describe('packaged desktop layout assertions', () => { + it('retains the exact 1280x820 Linux Xvfb proof', () => { + assert.doesNotThrow(() => assertPackagedLayout(layout(), 'linux')); + assert.throws( + () => assertPackagedLayout(layout({ windowWidth: 1279 }), 'linux'), + /Linux window was not 1280x820/, + ); + }); + + it('accepts a safe 1024x720 Windows display clamp with intact contained content', () => { + assert.doesNotThrow(() => assertPackagedLayout(layout({ + windowWidth: 1024, + windowHeight: 720, + viewportWidth: 1024, + viewportHeight: 681, + workAreaWidth: 1024, + workAreaHeight: 720, + }), 'win32')); + }); + + it('rejects unsafe Windows clamps and content outside the visible work area', () => { + assert.throws( + () => assertPackagedLayout(layout({ windowWidth: 879 }), 'win32'), + /outside the safe clamped range/, + ); + assert.throws( + () => assertPackagedLayout(layout({ workAreaWidth: 1024 }), 'win32'), + /outside the visible work area/, + ); + }); +}); diff --git a/apps/desktop/scripts/packaged-smoke-plan.mjs b/apps/desktop/scripts/packaged-smoke-plan.mjs new file mode 100644 index 000000000..b4bde6fcd --- /dev/null +++ b/apps/desktop/scripts/packaged-smoke-plan.mjs @@ -0,0 +1,83 @@ +export const READY_EVENT = 'desktop.renderer.ready'; +export const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true'; +export const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready'; +export const TRANSPORT_PROOF = 'desktop.renderer.transport_smoke.ready'; +export const MVP_FLOWS_PROOF = 'desktop.renderer.mvp_flows.ready'; +export const LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; +export const REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; +export const CONNECT_DEEP_LINK = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; + +export const PACKAGED_SMOKE_LAUNCH_MODES = Object.freeze([ + 'release-guard', + 'success', + 'retry', + 'forced-timeout', +]); + +export const TRANSPORT_SMOKE_ENVIRONMENT_NAMES = Object.freeze([ + 'PROPR_DESKTOP_SMOKE_FIRST_ORIGIN', + 'PROPR_DESKTOP_SMOKE_SECOND_ORIGIN', + 'PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE', +]); + +const releaseGuardMarkers = Object.freeze([ + READY_EVENT, + PRELOAD_BRIDGE_PROOF, + PROFILE_API_PROOF, + MVP_FLOWS_PROOF, + LAYOUT_READY_EVENT, + REDUCED_NATIVE_WINDOW_READY_EVENT, +]); +const transportMarkers = Object.freeze([ + READY_EVENT, + PRELOAD_BRIDGE_PROOF, + TRANSPORT_PROOF, + MVP_FLOWS_PROOF, + LAYOUT_READY_EVENT, + REDUCED_NATIVE_WINDOW_READY_EVENT, +]); + +export const createPackagedSmokeLaunch = ({ + mode, + platform, + userDataPath, + baseChildEnvironment, + firstOrigin, + secondOrigin, + dbusSessionAddress, +}) => { + if (!PACKAGED_SMOKE_LAUNCH_MODES.includes(mode)) { + throw new Error(`Unknown packaged smoke launch mode: ${mode}`); + } + const transport = mode !== 'release-guard'; + for (const name of TRANSPORT_SMOKE_ENVIRONMENT_NAMES) { + if (Object.hasOwn(baseChildEnvironment, name)) { + throw new Error(`Packaged smoke base environment unexpectedly contains ${name}`); + } + } + + const launchArguments = [ + '--disable-gpu', + '--propr-smoke-test', + `--user-data-dir=${userDataPath}`, + ...(platform === 'linux' ? ['--password-store=gnome-libsecret'] : []), + ...(!transport ? [CONNECT_DEEP_LINK] : []), + ]; + const childEnvironment = { + ...baseChildEnvironment, + ...(platform === 'linux' ? { DBUS_SESSION_BUS_ADDRESS: dbusSessionAddress } : {}), + ...(transport ? { + PROPR_DESKTOP_SMOKE_FIRST_ORIGIN: firstOrigin, + PROPR_DESKTOP_SMOKE_SECOND_ORIGIN: secondOrigin, + PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE: mode, + } : {}), + }; + + return Object.freeze({ + mode, + transport, + launchArguments: Object.freeze(launchArguments), + childEnvironment: Object.freeze(childEnvironment), + requiredMarkers: transport ? transportMarkers : releaseGuardMarkers, + }); +}; diff --git a/apps/desktop/scripts/packaged-smoke-support.mjs b/apps/desktop/scripts/packaged-smoke-support.mjs new file mode 100644 index 000000000..86ade3ef3 --- /dev/null +++ b/apps/desktop/scripts/packaged-smoke-support.mjs @@ -0,0 +1,342 @@ +import { chmod, lstat, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, win32 } from 'node:path'; +import windowSizing from '../window-sizing.json' with { type: 'json' }; + +export const PREFERRED_WINDOW_SIZE = Object.freeze({ ...windowSizing.preferred }); +export const MINIMUM_WINDOW_SIZE = Object.freeze({ ...windowSizing.minimum }); + +const MAX_DISPLAY_DIMENSION = 32_768; +const MAX_XAUTHORITY_BYTES = 64 * 1024; +const PRIVATE_SMOKE_PREFIX = 'propr-desktop-smoke-'; +const createdProfiles = new WeakSet(); + +const assertDimension = (value, description) => { + if (!Number.isInteger(value) || value <= 0 || value > MAX_DISPLAY_DIMENSION) { + throw new Error(`Packaged layout reported invalid ${description}`); + } +}; + +const assertDimensions = (value, description) => { + if (!value || typeof value !== 'object') { + throw new Error(`Packaged layout did not report ${description}`); + } + assertDimension(value.width, `${description} width`); + assertDimension(value.height, `${description} height`); +}; + +const assertRectangle = (value, description) => { + assertDimensions(value, description); + if (!Number.isInteger(value.x) || !Number.isInteger(value.y)) { + throw new Error(`Packaged layout reported invalid ${description} position`); + } +}; + +const assertGap = (before, after, minimum, description) => { + const gap = after.top - before.bottom; + if (gap < minimum) { + throw new Error(`Packaged layout ${description} gap was ${gap}px; expected at least ${minimum}px`); + } +}; + +export const assertPackagedNativeWindowSizing = (layout, { requireReducedWorkArea = false } = {}) => { + if (!layout) throw new Error('Packaged desktop did not report native window sizing'); + assertDimensions(layout.windowBounds, 'native window bounds'); + assertDimensions(layout.minimumSize, 'native minimum window size'); + assertDimensions(layout.workArea, 'window sizing work area'); + + if ( + requireReducedWorkArea + && (layout.workArea.width >= MINIMUM_WINDOW_SIZE.width || layout.workArea.height >= MINIMUM_WINDOW_SIZE.height) + ) { + throw new Error('Packaged reduced native window work area did not exercise both clamped minimum dimensions'); + } + if (requireReducedWorkArea) { + assertRectangle(layout.displayWorkArea, 'native display work area'); + assertRectangle(layout.workArea, 'reduced window sizing work area'); + assertRectangle(layout.windowBounds, 'reduced native window bounds'); + if ( + layout.workArea.x < layout.displayWorkArea.x + || layout.workArea.y < layout.displayWorkArea.y + || layout.workArea.x + layout.workArea.width > layout.displayWorkArea.x + layout.displayWorkArea.width + || layout.workArea.y + layout.workArea.height > layout.displayWorkArea.y + layout.displayWorkArea.height + ) { + throw new Error('Packaged reduced native window work area extends beyond its selected display'); + } + } + + const expectedWindow = { + width: Math.min(PREFERRED_WINDOW_SIZE.width, layout.workArea.width), + height: Math.min(PREFERRED_WINDOW_SIZE.height, layout.workArea.height), + }; + const expectedMinimum = { + width: Math.min(MINIMUM_WINDOW_SIZE.width, layout.workArea.width), + height: Math.min(MINIMUM_WINDOW_SIZE.height, layout.workArea.height), + }; + if (layout.windowBounds.width !== expectedWindow.width || layout.windowBounds.height !== expectedWindow.height) { + throw new Error('Packaged window does not equal its preferred size clamped to the available work area'); + } + if (layout.minimumSize.width !== expectedMinimum.width || layout.minimumSize.height !== expectedMinimum.height) { + throw new Error('Packaged window minimum size is not clamped to the available work area'); + } + if (layout.windowBounds.width > layout.workArea.width || layout.windowBounds.height > layout.workArea.height) { + throw new Error('Packaged window extends beyond the available work area'); + } + if ( + requireReducedWorkArea + && (layout.windowBounds.x !== layout.workArea.x || layout.windowBounds.y !== layout.workArea.y) + ) { + throw new Error('Packaged reduced native window was not constructed inside its selected work area'); + } +}; + +export const assertPackagedLayout = layout => { + if (!layout) throw new Error('Packaged desktop did not report renderer layout bounds'); + if (layout.missing?.length) { + throw new Error(`Packaged renderer layout was missing: ${layout.missing.join(', ')}`); + } + + assertPackagedNativeWindowSizing(layout); + assertDimensions(layout.contentBounds, 'native content bounds'); + assertDimensions(layout.viewport, 'renderer viewport'); + assertDimensions(layout.screen, 'renderer screen dimensions'); + + if (layout.workArea.width > layout.screen.width || layout.workArea.height > layout.screen.height) { + throw new Error('Packaged renderer available work area exceeds its screen dimensions'); + } + + if ( + layout.contentBounds.width > layout.windowBounds.width + || layout.contentBounds.height > layout.windowBounds.height + ) { + throw new Error('Packaged native content bounds exceed the native window bounds'); + } + const nativeChrome = { + width: layout.windowBounds.width - layout.contentBounds.width, + height: layout.windowBounds.height - layout.contentBounds.height, + }; + if ( + layout.viewport.width !== layout.windowBounds.width - nativeChrome.width + || layout.viewport.height !== layout.windowBounds.height - nativeChrome.height + ) { + throw new Error('Packaged renderer viewport does not match the actual native content bounds'); + } + + if (layout.logo.height < 18 || layout.logo.height > 22 || layout.logo.width < 40 || layout.logo.width > 100) { + throw new Error(`Packaged title-bar logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`); + } + if ( + layout.logo.top < layout.titlebar.top + || layout.logo.bottom > layout.titlebar.bottom + || layout.card.left < 0 + || layout.card.right > layout.viewport.width + || layout.card.top < layout.titlebar.bottom + || layout.card.bottom > layout.viewport.height + ) { + throw new Error('Packaged logo or connection card extends outside its layout container'); + } + for (const name of ['connectionName', 'apiUrl', 'submit']) { + const control = layout[name]; + if (control.height < 36 || control.left < layout.card.left || control.right > layout.card.right) { + throw new Error(`Packaged ${name} control has unreasonable bounds: ${JSON.stringify(control)}`); + } + } + assertGap(layout.connectionName, layout.apiUrl, 28, 'between connection inputs'); + assertGap(layout.apiUrl, layout.apiHelp, 6, 'between API input and help text'); + assertGap(layout.apiHelp, layout.submit, 16, 'between API help and submit button'); + assertGap(layout.submit, layout.footer, 20, 'between submit button and runtime footer'); +}; + +const ensurePrivateDirectory = async path => { + await mkdir(path, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await chmod(path, 0o700); + const stats = await lstat(path); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('Packaged smoke private profile layout is invalid'); + } +}; + +export const createPrivateSmokeProfile = async (temporaryDirectory = tmpdir()) => { + const root = await mkdtemp(join(resolve(temporaryDirectory), PRIVATE_SMOKE_PREFIX)); + try { + await ensurePrivateDirectory(root); + const profile = { + root, + userData: root, + home: join(root, 'home'), + userProfile: join(root, 'profile'), + appData: join(root, 'profile', 'AppData', 'Roaming'), + localAppData: join(root, 'profile', 'AppData', 'Local'), + temporary: join(root, 'temp'), + xdgConfig: join(root, 'xdg', 'config'), + xdgCache: join(root, 'xdg', 'cache'), + xdgData: join(root, 'xdg', 'data'), + xdgRuntime: join(root, 'xdg', 'runtime'), + }; + for (const path of [ + profile.home, + profile.userProfile, + dirname(profile.appData), + profile.appData, + profile.localAppData, + profile.temporary, + dirname(profile.xdgConfig), + profile.xdgConfig, + profile.xdgCache, + profile.xdgData, + profile.xdgRuntime, + ]) { + const pathFromRoot = relative(root, path); + if (!pathFromRoot || pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot)) { + throw new Error('Packaged smoke private profile path escaped its root'); + } + await ensurePrivateDirectory(path); + } + const result = Object.freeze(profile); + createdProfiles.add(result); + return result; + } catch (error) { + await rm(root, { recursive: true, force: true }); + throw error; + } +}; + +export const removePrivateSmokeProfile = async profile => { + if (!profile || !createdProfiles.has(profile)) { + throw new Error('Packaged smoke cleanup rejected an unknown profile'); + } + createdProfiles.delete(profile); + await rm(profile.root, { recursive: true, force: true }); +}; + +const validateProfileApiUrl = value => { + let url; + try { + url = new URL(value); + } catch { + throw new Error('Packaged smoke profile API trigger is invalid'); + } + if ( + url.protocol !== 'http:' + || url.hostname !== '127.0.0.1' + || !url.port + || url.username + || url.password + || url.pathname !== '/' + || url.search + || url.hash + || value !== url.origin + ) { + throw new Error('Packaged smoke profile API trigger is invalid'); + } + return value; +}; + +const validateDisplay = value => { + if ( + typeof value !== 'string' + || value.length > 128 + || !/^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,126})?)?:[0-9]{1,5}(?:\.[0-9]{1,5})?$/.test(value) + ) { + throw new Error('Packaged smoke X display input is invalid'); + } + return value; +}; + +const validateXAuthority = async (value, inspectPath) => { + if (typeof value !== 'string' || value.length > 4096 || !isAbsolute(value)) { + throw new Error('Packaged smoke X authority input is invalid'); + } + const stats = await inspectPath(value); + const expectedUid = typeof process.getuid === 'function' ? process.getuid() : undefined; + if ( + !stats.isFile() + || stats.isSymbolicLink() + || stats.size < 0 + || stats.size > MAX_XAUTHORITY_BYTES + || (expectedUid !== undefined && stats.uid !== expectedUid) + || (typeof stats.mode === 'number' && (stats.mode & 0o077) !== 0) + ) { + throw new Error('Packaged smoke X authority input is invalid'); + } + return value; +}; + +export const validateWindowsSystemRoot = async (value, inspectPath = lstat) => { + const driveCode = typeof value === 'string' ? value.charCodeAt(0) : -1; + if ( + typeof value !== 'string' + || value.length > 260 + || !( + (driveCode >= 65 && driveCode <= 90) + || (driveCode >= 97 && driveCode <= 122) + ) + || value[1] !== ':' + || value[2] !== '\\' + || value.includes('\0') + || value.includes('/') + || value.slice(3).split('\\').some(segment => segment.length === 0) + || !win32.isAbsolute(value) + || win32.normalize(value) !== value + ) { + throw new Error('Packaged smoke Windows system root is invalid'); + } + const stats = await inspectPath(value); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('Packaged smoke Windows system root is invalid'); + } + return value; +}; + +export const createSmokeChildEnvironment = async ({ + platform = process.platform, + profile, + profileApiUrl, + parentEnvironment = process.env, + inspectPath = lstat, +}) => { + if (!profile || !createdProfiles.has(profile)) { + throw new Error('Packaged smoke child environment rejected an unknown profile'); + } + + const triggers = { + PROPR_DESKTOP_SMOKE_PROFILE_API_URL: validateProfileApiUrl(profileApiUrl), + PROPR_DESKTOP_SMOKE_TEST: '1', + }; + if (platform === 'win32') { + return Object.freeze({ + APPDATA: profile.appData, + LOCALAPPDATA: profile.localAppData, + ...triggers, + SystemRoot: await validateWindowsSystemRoot(parentEnvironment.SystemRoot, inspectPath), + TEMP: profile.temporary, + TMP: profile.temporary, + USERPROFILE: profile.userProfile, + }); + } + if (platform === 'linux') { + return Object.freeze({ + DISPLAY: validateDisplay(parentEnvironment.DISPLAY), + HOME: profile.home, + ...triggers, + TEMP: profile.temporary, + TMP: profile.temporary, + TMPDIR: profile.temporary, + XAUTHORITY: await validateXAuthority(parentEnvironment.XAUTHORITY, inspectPath), + XDG_CACHE_HOME: profile.xdgCache, + XDG_CONFIG_HOME: profile.xdgConfig, + XDG_DATA_HOME: profile.xdgData, + XDG_RUNTIME_DIR: profile.xdgRuntime, + }); + } + if (platform === 'darwin') { + return Object.freeze({ + HOME: profile.home, + ...triggers, + TEMP: profile.temporary, + TMP: profile.temporary, + TMPDIR: profile.temporary, + }); + } + throw new Error('Packaged smoke child environment does not support this platform'); +}; diff --git a/apps/desktop/scripts/packaged-smoke-support.test.mjs b/apps/desktop/scripts/packaged-smoke-support.test.mjs new file mode 100644 index 000000000..5e7fba41d --- /dev/null +++ b/apps/desktop/scripts/packaged-smoke-support.test.mjs @@ -0,0 +1,396 @@ +import assert from 'node:assert/strict'; +import { chmod, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, relative } from 'node:path'; +import { describe, test } from 'node:test'; +import { + assertPackagedLayout, + assertPackagedNativeWindowSizing, + createPrivateSmokeProfile, + createSmokeChildEnvironment, + MINIMUM_WINDOW_SIZE, + removePrivateSmokeProfile, + validateWindowsSystemRoot, +} from './packaged-smoke-support.mjs'; +import { + CONNECT_DEEP_LINK, + createPackagedSmokeLaunch, + PACKAGED_SMOKE_LAUNCH_MODES, + TRANSPORT_SMOKE_ENVIRONMENT_NAMES, +} from './packaged-smoke-plan.mjs'; + +const assertPackagedSpawnOptions = (source) => { + const normalizedSource = source.replace(/\r\n?/g, '\n'); + assert.match(normalizedSource, /cwd: smokeProfile\.root,\n\s+env: childEnvironment,\n\s+shell: false,/); +}; + +const layoutFixture = ({ windowWidth, windowHeight, workWidth, workHeight }) => { + const viewport = { width: windowWidth - 16, height: windowHeight - 65 }; + const cardWidth = 560; + const cardLeft = (viewport.width - cardWidth) / 2; + const control = (top, bottom) => ({ + top, + bottom, + height: bottom - top, + left: cardLeft + 24, + right: cardLeft + cardWidth - 24, + }); + return { + windowBounds: { width: windowWidth, height: windowHeight }, + minimumSize: { + width: Math.min(MINIMUM_WINDOW_SIZE.width, workWidth), + height: Math.min(MINIMUM_WINDOW_SIZE.height, workHeight), + }, + contentBounds: viewport, + viewport, + screen: { width: Math.max(workWidth, windowWidth), height: Math.max(workHeight, windowHeight) }, + workArea: { width: workWidth, height: workHeight }, + titlebar: { top: 0, bottom: 60 }, + logo: { top: 20, bottom: 40, height: 20, width: 72 }, + card: { + top: 80, + bottom: viewport.height - 12, + left: cardLeft, + right: cardLeft + cardWidth, + }, + connectionName: control(110, 150), + apiUrl: control(180, 220), + apiHelp: control(226, 240), + submit: control(256, 296), + footer: control(316, 336), + }; +}; + +describe('packaged smoke native window layout', () => { + for (const scenario of [ + { name: 'preferred size', windowWidth: 1280, windowHeight: 820, workWidth: 1920, workHeight: 1040 }, + { name: '1024x720-clamped size', windowWidth: 1024, windowHeight: 720, workWidth: 1024, workHeight: 720 }, + { name: 'configured minimum size', windowWidth: 880, windowHeight: 620, workWidth: 880, workHeight: 620 }, + { name: 'undersized work area', windowWidth: 800, windowHeight: 560, workWidth: 800, workHeight: 560 }, + ]) { + test(`accepts the ${scenario.name} while retaining responsive containment`, () => { + assert.doesNotThrow(() => assertPackagedLayout(layoutFixture(scenario))); + }); + } + + test('rejects an unclamped window or a viewport inconsistent with native content chrome', () => { + const unclamped = layoutFixture({ + windowWidth: 1280, + windowHeight: 820, + workWidth: 1024, + workHeight: 720, + }); + assert.throws(() => assertPackagedLayout(unclamped), /preferred size clamped/); + + const inconsistentViewport = layoutFixture({ + windowWidth: 1024, + windowHeight: 720, + workWidth: 1024, + workHeight: 720, + }); + inconsistentViewport.viewport = { width: 1007, height: 655 }; + assert.throws(() => assertPackagedLayout(inconsistentViewport), /actual native content bounds/); + }); + + test('accepts actual reduced native sizing only when both minimum constraints are exercised', () => { + assert.doesNotThrow(() => assertPackagedNativeWindowSizing({ + displayWorkArea: { x: -1600, y: 0, width: 1600, height: 900 }, + workArea: { x: -1200, y: 170, width: 800, height: 560 }, + windowBounds: { x: -1200, y: 170, width: 800, height: 560 }, + minimumSize: { width: 800, height: 560 }, + }, { requireReducedWorkArea: true })); + assert.throws(() => assertPackagedNativeWindowSizing({ + displayWorkArea: { x: 0, y: 0, width: 1920, height: 1040 }, + workArea: { x: 520, y: 240, width: 880, height: 560 }, + windowBounds: { x: 520, y: 240, width: 880, height: 560 }, + minimumSize: { width: 880, height: 560 }, + }, { requireReducedWorkArea: true }), /both clamped minimum dimensions/); + }); +}); + +describe('packaged smoke child environment', () => { + test('defines four isolated launches with exact per-mode environment, argv, and marker contracts', () => { + const firstOrigin = 'http://127.0.0.1:41001'; + const secondOrigin = 'http://127.0.0.1:41002'; + const dbusSessionAddress = 'unix:path=/run/user/1000/bus'; + const connectDeepLink = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; + assert.equal(CONNECT_DEEP_LINK, connectDeepLink); + assert.deepEqual(TRANSPORT_SMOKE_ENVIRONMENT_NAMES, [ + 'PROPR_DESKTOP_SMOKE_FIRST_ORIGIN', + 'PROPR_DESKTOP_SMOKE_SECOND_ORIGIN', + 'PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE', + ]); + const launches = PACKAGED_SMOKE_LAUNCH_MODES.map((mode, index) => { + const userDataPath = `/private/propr-desktop-smoke-${mode}`; + const baseChildEnvironment = { + HOME: `${userDataPath}/home`, + PROPR_DESKTOP_SMOKE_PROFILE_API_URL: `http://127.0.0.1:${42000 + index}`, + PROPR_DESKTOP_SMOKE_TEST: '1', + }; + return createPackagedSmokeLaunch({ + mode, + platform: 'linux', + userDataPath, + baseChildEnvironment, + firstOrigin, + secondOrigin, + dbusSessionAddress, + }); + }); + + assert.deepEqual(launches.map(launch => launch.mode), [ + 'release-guard', 'success', 'retry', 'forced-timeout', + ]); + for (const [index, launch] of launches.entries()) { + const mode = PACKAGED_SMOKE_LAUNCH_MODES[index]; + const userDataPath = `/private/propr-desktop-smoke-${mode}`; + const baseEnvironment = { + HOME: `${userDataPath}/home`, + PROPR_DESKTOP_SMOKE_PROFILE_API_URL: `http://127.0.0.1:${42000 + index}`, + PROPR_DESKTOP_SMOKE_TEST: '1', + }; + const commonMarkers = [ + 'desktop.renderer.ready', + '"preloadBridgeExposed":true', + ]; + const layoutMarkers = [ + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + ]; + if (mode === 'release-guard') { + assert.equal(launch.transport, false); + assert.deepEqual(launch.launchArguments, [ + '--disable-gpu', + '--propr-smoke-test', + `--user-data-dir=${userDataPath}`, + '--password-store=gnome-libsecret', + connectDeepLink, + ]); + assert.deepEqual(launch.childEnvironment, { + ...baseEnvironment, + DBUS_SESSION_BUS_ADDRESS: dbusSessionAddress, + }); + assert.deepEqual(launch.requiredMarkers, [ + ...commonMarkers, + 'desktop.renderer.profile_api.ready', + ...layoutMarkers, + ]); + for (const name of TRANSPORT_SMOKE_ENVIRONMENT_NAMES) { + assert.equal(Object.hasOwn(launch.childEnvironment, name), false); + } + } else { + assert.equal(launch.transport, true); + assert.deepEqual(launch.launchArguments, [ + '--disable-gpu', + '--propr-smoke-test', + `--user-data-dir=${userDataPath}`, + '--password-store=gnome-libsecret', + ]); + assert.deepEqual(launch.childEnvironment, { + ...baseEnvironment, + DBUS_SESSION_BUS_ADDRESS: dbusSessionAddress, + PROPR_DESKTOP_SMOKE_FIRST_ORIGIN: firstOrigin, + PROPR_DESKTOP_SMOKE_SECOND_ORIGIN: secondOrigin, + PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE: mode, + }); + assert.deepEqual(launch.requiredMarkers, [ + ...commonMarkers, + 'desktop.renderer.transport_smoke.ready', + ...layoutMarkers, + ]); + assert.equal(launch.launchArguments.includes(connectDeepLink), false); + } + } + }); + + test('passes only platform launch inputs and private profile paths from a hostile parent', async () => { + const parent = await createPrivateSmokeProfile(tmpdir()); + const xAuthority = join(parent.root, 'Xauthority'); + await writeFile(xAuthority, 'xvfb-cookie'); + if (process.platform !== 'win32') await chmod(xAuthority, 0o600); + const hostileValues = new Set([ + 'hostile-certificate-file', + 'hostile-signing-password', + 'hostile-private-key', + 'hostile-github-token', + 'hostile-github-app-token', + 'hostile-azure-client', + 'hostile-azure-secret', + 'hostile-unrelated-propr-value', + 'hostile-path', + ]); + const hostileParent = { + WINDOWS_CERTIFICATE_FILE: 'hostile-certificate-file', + CSC_KEY_PASSWORD: 'hostile-signing-password', + PROPR_DESKTOP_UPDATE_PRIVATE_KEY: 'hostile-private-key', + GITHUB_TOKEN: 'hostile-github-token', + GH_TOKEN: 'hostile-github-app-token', + AZURE_CLIENT_ID: 'hostile-azure-client', + AZURE_CLIENT_SECRET: 'hostile-azure-secret', + PROPR_DESKTOP_UNRELATED: 'hostile-unrelated-propr-value', + PATH: 'hostile-path', + DISPLAY: ':77', + XAUTHORITY: xAuthority, + SystemRoot: process.env.SystemRoot, + }; + + try { + assert.equal(parent.userData, parent.root); + assert.match(basename(parent.userData), /^propr-desktop-smoke-[A-Za-z0-9]+$/); + const environment = await createSmokeChildEnvironment({ + profile: parent, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: hostileParent, + }); + const expectedKeys = process.platform === 'win32' + ? ['APPDATA', 'LOCALAPPDATA', 'PROPR_DESKTOP_SMOKE_PROFILE_API_URL', 'PROPR_DESKTOP_SMOKE_TEST', 'SystemRoot', 'TEMP', 'TMP', 'USERPROFILE'] + : process.platform === 'linux' + ? ['DISPLAY', 'HOME', 'PROPR_DESKTOP_SMOKE_PROFILE_API_URL', 'PROPR_DESKTOP_SMOKE_TEST', 'TEMP', 'TMP', 'TMPDIR', 'XAUTHORITY', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_RUNTIME_DIR'] + : ['HOME', 'PROPR_DESKTOP_SMOKE_PROFILE_API_URL', 'PROPR_DESKTOP_SMOKE_TEST', 'TEMP', 'TMP', 'TMPDIR']; + assert.deepEqual(Object.keys(environment), expectedKeys); + assert.equal(environment.PROPR_DESKTOP_SMOKE_TEST, '1'); + for (const [name, value] of Object.entries(environment)) { + assert.ok(!hostileValues.has(value), `${name} inherited a hostile parent value`); + if (!['DISPLAY', 'XAUTHORITY', 'SystemRoot', 'PROPR_DESKTOP_SMOKE_PROFILE_API_URL', 'PROPR_DESKTOP_SMOKE_TEST'].includes(name)) { + const pathFromRoot = relative(parent.root, value); + assert.ok(pathFromRoot && !pathFromRoot.startsWith('..'), `${name} escaped the private smoke root`); + } + } + for (const name of [ + 'WINDOWS_CERTIFICATE_FILE', + 'CSC_KEY_PASSWORD', + 'PROPR_DESKTOP_UPDATE_PRIVATE_KEY', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'AZURE_CLIENT_ID', + 'AZURE_CLIENT_SECRET', + 'PROPR_DESKTOP_UNRELATED', + 'PATH', + ]) { + assert.equal(Object.hasOwn(environment, name), false); + } + } finally { + await removePrivateSmokeProfile(parent); + } + }); + + test('keeps cleanup bounded to the generated profile root', async () => { + const outer = await createPrivateSmokeProfile(tmpdir()); + const sibling = join(outer.root, 'cleanup-must-not-touch.txt'); + await writeFile(sibling, 'retained'); + const nested = await createPrivateSmokeProfile(outer.root); + await removePrivateSmokeProfile(nested); + assert.equal(await readFile(sibling, 'utf8'), 'retained'); + await removePrivateSmokeProfile(outer); + }); + + test('accepts only a normalized absolute Windows SystemRoot directory', async () => { + const directoryStats = { isDirectory: () => true, isSymbolicLink: () => false }; + const inspectedPaths = []; + const inspectDirectory = async value => { + inspectedPaths.push(value); + return directoryStats; + }; + const validRoots = [ + String.raw`C:\Windows`, + String.raw`z:\Windows\System32`, + String.raw`D:\Program Files\Windows`, + `C:\\${'a'.repeat(257)}`, + ]; + for (const value of validRoots) { + assert.equal(await validateWindowsSystemRoot(value, inspectDirectory), value); + } + assert.deepEqual(inspectedPaths, validRoots); + + const repeatedDotPath = `C:\\${'.\\'.repeat(128)}.`; + assert.equal(repeatedDotPath.length, 260); + const invalidRoots = [ + repeatedDotPath, + String.raw`C:\Windows\\System32`, + `${String.raw`C:\Windows`}\\`, + String.raw`C:\Windows/System32`, + `C:\\Windows\0System32`, + 'Windows', + String.raw`C:\Windows\.\System32`, + String.raw`C:\Windows\..\secrets`, + String.raw`\\server\share`, + String.raw`1:\Windows`, + String.raw`é:\Windows`, + `C:\\${'a'.repeat(258)}`, + ]; + let invalidInspectionCount = 0; + for (const value of invalidRoots) { + await assert.rejects( + validateWindowsSystemRoot(value, async () => { + invalidInspectionCount += 1; + return directoryStats; + }), + { + name: 'Error', + message: 'Packaged smoke Windows system root is invalid', + }, + ); + } + assert.equal(invalidInspectionCount, 0); + + await assert.rejects( + validateWindowsSystemRoot(String.raw`C:\Windows`, async () => ({ + isDirectory: () => true, + isSymbolicLink: () => true, + })), + /system root is invalid/, + ); + }); + + test('contains no parent environment spread, enumeration, denylist, PATH, or shell launch', async () => { + const smokeSource = await readFile(new URL('./smoke-packaged.mjs', import.meta.url), 'utf8'); + const supportSource = await readFile(new URL('./packaged-smoke-support.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(smokeSource, /\.\.\.process\.env|Object\.(?:keys|values|entries)\(process\.env\)/); + assert.doesNotMatch(supportSource, /Object\.(?:keys|values|entries)\(parentEnvironment\)|\bPATH\b/); + assertPackagedSpawnOptions(smokeSource); + assert.doesNotMatch(smokeSource, /env:\s*\{[\s\S]*process\.env/); + }); + + test('serves each named fixture identity paired with its persisted credential', async () => { + const smokeSource = await readFile(new URL('./smoke-packaged.mjs', import.meta.url), 'utf8'); + const mainSource = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match( + smokeSource, + /name === 'first'\s*\? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'\s*: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'/u, + ); + assert.match(smokeSource, /first = await listenFixture\('first'\);/u); + assert.match(smokeSource, /second = await listenFixture\('second'\);/u); + assert.match( + mainSource, + /origin: smoke\.firstOrigin,\s*publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'/u, + ); + assert.match( + mainSource, + /origin: smoke\.secondOrigin,\s*publicInstanceIdentity: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'/u, + ); + }); + + test('requires the adjacent packaged spawn options with LF or CRLF source', () => { + const options = [ + ' cwd: smokeProfile.root,', + ' env: childEnvironment,', + ' shell: false,', + ]; + assert.doesNotThrow(() => assertPackagedSpawnOptions(options.join('\n'))); + assert.doesNotThrow(() => assertPackagedSpawnOptions(options.join('\r\n'))); + + for (const invalidOptions of [ + options.slice(1), + [options[0], options[2]], + options.slice(0, 2), + [options[1], options[0], options[2]], + [options[0], options[2], options[1]], + [' cwd: process.cwd(),', options[1], options[2]], + [options[0], ' env: process.env,', options[2]], + [options[0], options[1], ' shell: true,'], + ]) { + assert.throws(() => assertPackagedSpawnOptions(invalidOptions.join('\n'))); + } + }); +}); diff --git a/apps/desktop/scripts/probe-packaged-windows-authority.ts b/apps/desktop/scripts/probe-packaged-windows-authority.ts new file mode 100644 index 000000000..3019f62a8 --- /dev/null +++ b/apps/desktop/scripts/probe-packaged-windows-authority.ts @@ -0,0 +1,10 @@ +import { isAbsolute, resolve } from 'node:path'; +import { probePackagedWindowsAuthorityHelper } from '../src/windows-update-authority'; + +const [directory] = process.argv.slice(2); +if (!directory || process.argv.length !== 3 || !isAbsolute(directory)) { + throw new Error('Packaged Windows authority probe requires one absolute helper directory'); +} +const stage = await probePackagedWindowsAuthorityHelper(resolve(directory)); +if (stage !== 'READY') throw new Error(`Packaged Windows authority helper failed at ${stage}`); +process.stdout.write('Packaged Windows authority helper reached READY\n'); diff --git a/apps/desktop/scripts/release-architecture.mjs b/apps/desktop/scripts/release-architecture.mjs new file mode 100644 index 000000000..dc2fb2ca0 --- /dev/null +++ b/apps/desktop/scripts/release-architecture.mjs @@ -0,0 +1,1554 @@ +import { execFile as execFileCallback, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, mkdtemp, readdir, readlink, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { inflateRawSync } from 'node:zlib'; + +const execFile = promisify(execFileCallback); +const heldDmgArtifacts = new WeakMap(); +const HDIUTIL = '/usr/bin/hdiutil'; +const MSIEXTRACT = '/usr/bin/msiextract'; +const KERNEL_MSIEXEC = String.raw`\\?\GLOBALROOT\SystemRoot\System32\msiexec.exe`; +const KERNEL_TASKKILL = String.raw`\\?\GLOBALROOT\SystemRoot\System32\taskkill.exe`; +const EXECUTABLE_NAME = 'propr-desktop'; +const WINDOWS_AUTHORITY_EXECUTABLE = 'lib/net45/resources/windows-authority/propr-windows-authority.exe'; +const WINDOWS_AUTHORITY_MANIFEST = 'lib/net45/resources/windows-authority/propr-windows-authority.manifest.json'; +const WINDOWS_AUTHORITY_LAUNCHER = 'lib/net45/resources/windows-authority/propr-windows-launcher.node'; +const WINDOWS_AUTHORITY_BOOTSTRAP = 'lib/net45/resources/windows-authority/propr-windows-bootstrap.node'; +const DMG_INSTALL_LINK = 'Applications'; +const DMG_HELPER_BUNDLES = new Set([ + `${EXECUTABLE_NAME} Helper.app`, + `${EXECUTABLE_NAME} Helper (GPU).app`, + `${EXECUTABLE_NAME} Helper (Plugin).app`, + `${EXECUTABLE_NAME} Helper (Renderer).app`, +]); +const DMG_HELPER_EXECUTABLES = new Set([...DMG_HELPER_BUNDLES] + .map(name => name.slice(0, -'.app'.length).toLocaleLowerCase('en-US'))); +export const NATIVE_DMG_VALIDATOR = Object.freeze({ + schemaVersion: 1, + tool: 'propr-desktop-release-architecture', + toolVersion: '1.0.0', + nativePlatform: 'darwin', + mountMethod: 'hdiutil-attach-readonly', +}); + +export const createHeldDmgArtifact = (handle, description, privatePath) => { + if (!handle || !Number.isInteger(handle.fd) || handle.fd < 0) { + throw new Error('Held DMG artifact requires an open read-only file handle'); + } + if (privatePath !== undefined && (typeof privatePath !== 'string' || !isAbsolute(privatePath))) { + throw new Error('Held DMG private pathname must be absolute'); + } + const capability = Object.freeze({ description }); + heldDmgArtifacts.set(capability, { handle, description, privatePath }); + return capability; +}; + +const requireHeldDmgArtifact = capability => { + const held = heldDmgArtifacts.get(capability); + if (!held || held.handle.fd < 0) { + throw new Error('DMG inspection requires a live held exact-artifact capability'); + } + return held; +}; + +export const readHeldDmgArtifactBytes = async capability => { + const { handle } = requireHeldDmgArtifact(capability); + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.size < 0n || stats.size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('Held DMG artifact is not a safe regular file'); + } + const bytes = Buffer.alloc(Number(stats.size)); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) throw new Error('Held DMG artifact changed while it was read'); + offset += bytesRead; + } + return bytes; +}; +const LINUX_APP_DIRECTORY = join('usr', 'lib', EXECUTABLE_NAME); +const LINUX_PAYLOAD = join(LINUX_APP_DIRECTORY, EXECUTABLE_NAME); +const LINUX_LAUNCHER = join('usr', 'bin', EXECUTABLE_NAME); +const LINUX_DOC_DIRECTORY = join('usr', 'share', 'doc', EXECUTABLE_NAME); +const DEB_LINTIAN_OVERRIDE = join('usr', 'share', 'lintian', 'overrides', EXECUTABLE_NAME); +const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024; +const MAX_ZIP_DIRECTORY_BYTES = 64 * 1024 * 1024; +const MAX_ZIP_ENTRY_METADATA_BYTES = 1024 * 1024; +const MAX_ZIP_ENTRIES = 100_000; +const MAX_ZIP_SYMLINK_BYTES = 1024; +const MAX_ZIP_SYMLINKS = 32; +const MAX_MSI_FILES = 20_000; +const MAX_MSI_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; +const MAX_MSI_DEPTH = 32; +const MAX_MSI_PATH_BYTES = 32 * 1024; +const MSI_EXTRACT_TIMEOUT_MS = 10 * 60_000; +const MSI_EXTRACT_OUTPUT_BYTES = 8 * 1024 * 1024; +const MSI_CANONICAL_APPLICATION = `ProPR Desktop/${EXECUTABLE_NAME}.exe`; +const MSI_ADMIN_ROOT_PREFIX = 'Program Files 64'; +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); +const EXPECTED_PACKAGE_ARCHITECTURE = { + deb: { x64: 'amd64', arm64: 'arm64' }, + rpm: { x64: 'x86_64', arm64: 'aarch64' }, +}; + +const readPrefix = async (path, length = 4096) => { + const handle = await open(path, 'r'); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +}; + +const architectureForCpuType = cpuType => { + if (cpuType === 0x01000007) return 'x64'; + if (cpuType === 0x0100000c) return 'arm64'; + return `unknown-${cpuType.toString(16)}`; +}; + +export const inspectExecutableBytes = bytes => { + if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + const littleEndian = bytes[5] === 1; + if (!littleEndian && bytes[5] !== 2) throw new Error('ELF executable has an invalid byte order'); + const machine = littleEndian ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); + const architecture = machine === 62 ? 'x64' : machine === 183 ? 'arm64' : `unknown-${machine}`; + return { format: 'elf', architectures: [architecture] }; + } + + if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) { + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset + 6 > bytes.length || bytes.readUInt32LE(peOffset) !== 0x00004550) { + throw new Error('PE executable header is missing or truncated'); + } + const machine = bytes.readUInt16LE(peOffset + 4); + const architecture = machine === 0x014c + ? 'x86' + : machine === 0x8664 + ? 'x64' + : machine === 0xaa64 + ? 'arm64' + : `unknown-${machine.toString(16)}`; + return { format: 'pe', architectures: [architecture] }; + } + + if (bytes.length >= 8) { + const magic = bytes.readUInt32BE(0); + const thin = new Map([ + [0xfeedface, false], [0xfeedfacf, false], + [0xcefaedfe, true], [0xcffaedfe, true], + ]); + if (thin.has(magic)) { + const cpuType = thin.get(magic) ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4); + return { format: 'mach-o', architectures: [architectureForCpuType(cpuType)] }; + } + const fat = new Map([ + [0xcafebabe, { little: false, width: 20 }], + [0xcafebabf, { little: false, width: 24 }], + [0xbebafeca, { little: true, width: 20 }], + [0xbfbafeca, { little: true, width: 24 }], + ]); + const fatFormat = fat.get(magic); + if (fatFormat) { + const read32 = fatFormat.little ? Buffer.prototype.readUInt32LE : Buffer.prototype.readUInt32BE; + const count = read32.call(bytes, 4); + if (!Number.isSafeInteger(count) || count < 1 || count > 32 || 8 + count * fatFormat.width > bytes.length) { + throw new Error('Mach-O universal header is invalid or truncated'); + } + const architectures = []; + for (let index = 0; index < count; index += 1) { + architectures.push(architectureForCpuType(read32.call(bytes, 8 + index * fatFormat.width))); + } + return { format: 'mach-o', architectures: [...new Set(architectures)].sort() }; + } + } + throw new Error('Packaged executable is not a recognized ELF, PE, or Mach-O binary'); +}; + +const assertExecutableArchitecture = (inspection, platform, arch, artifact) => { + const expectedFormat = platform === 'linux' ? 'elf' : platform === 'win32' ? 'pe' : 'mach-o'; + if (inspection.format !== expectedFormat || inspection.architectures.length !== 1 || inspection.architectures[0] !== arch) { + throw new Error( + `${artifact} executable architecture mismatch: expected ${expectedFormat}/${arch}, found ${inspection.format}/${inspection.architectures.join(',')}`, + ); + } +}; + +const assertSupportedSquirrelBootstrap = (inspection, artifact) => { + const architecture = inspection.architectures[0]; + if (inspection.format !== 'pe' || inspection.architectures.length !== 1 + || !['x86', 'x64', 'arm64'].includes(architecture)) { + throw new Error(`${artifact} is not a supported x86, x64, or arm64 Squirrel PE bootstrapper`); + } +}; + +const msiInspectionFailure = (code, count) => new Error( + `MSI_INSPECTION_FAILED:${code}${count === undefined ? '' : ` count=${Math.min(count, MAX_MSI_FILES + 1)}`}`, +); + +const sameFileIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino + && left.size === right.size && left.mode === right.mode && left.nlink === right.nlink; + +const normalWindowsSystemTool = (path, name) => { + const candidate = /^\\\\\?\\[A-Za-z]:\\/.test(path) ? path.slice(4) : path; + if (!/^[A-Za-z]:\\[^\0]+$/.test(candidate) || candidate.startsWith('\\\\') + || !win32.isAbsolute(candidate) || candidate.indexOf(':', 2) >= 0 + || !candidate.toLocaleLowerCase('en-US').endsWith(`\\system32\\${name}`)) { + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } + return candidate; +}; + +const resolveWindowsSystemTool = async (kernelPath, name) => { + let held; + try { + const pathStats = await lstat(kernelPath, { bigint: true }); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink < 1n || pathStats.size <= 0n) { + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } + held = await open(kernelPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const before = await held.stat({ bigint: true }); + if (!sameFileIdentity(before, pathStats)) throw msiInspectionFailure('EXTRACTOR_TOOL'); + const canonical = normalWindowsSystemTool(await realpath(kernelPath), name); + const canonicalStats = await lstat(canonical, { bigint: true }); + const after = await held.stat({ bigint: true }); + if (!canonicalStats.isFile() || canonicalStats.isSymbolicLink() + || !sameFileIdentity(before, canonicalStats) || !sameFileIdentity(before, after)) { + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } + return canonical; + } catch (error) { + if (error instanceof Error && error.message === 'MSI_INSPECTION_FAILED:EXTRACTOR_TOOL') throw error; + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } finally { + await held?.close().catch(() => undefined); + } +}; + +const resolveLinuxMsiExtractor = async () => { + try { + const stats = await lstat(MSIEXTRACT, { bigint: true }); + if (!stats.isFile() || stats.isSymbolicLink() || stats.uid !== 0n || stats.nlink < 1n + || (stats.mode & 0o022n) !== 0n || (stats.mode & 0o111n) === 0n + || await realpath(MSIEXTRACT) !== MSIEXTRACT) { + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } + return MSIEXTRACT; + } catch (error) { + if (error instanceof Error && error.message === 'MSI_INSPECTION_FAILED:EXTRACTOR_TOOL') throw error; + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } +}; + +export const runBoundedMsiExtractorForTest = async ({ + file, + args, + cwd, + env, + hostPlatform, + treeKiller, + timeoutMs = MSI_EXTRACT_TIMEOUT_MS, + outputLimit = MSI_EXTRACT_OUTPUT_BYTES, + captureStdout = false, +}) => new Promise((resolveRun, rejectRun) => { + let child; + let outputBytes = 0; + const stdout = []; + let failed = false; + let terminating = false; + const fail = () => { + failed = true; + if (terminating || !child?.pid) return; + terminating = true; + if (hostPlatform === 'win32') { + const killer = spawn(treeKiller, ['/pid', String(child.pid), '/t', '/f'], { + cwd, + env, + shell: false, + windowsHide: true, + stdio: 'ignore', + }); + const killerTimer = setTimeout(() => { + killer.kill('SIGKILL'); + child.kill('SIGKILL'); + }, 30_000); + killer.once('error', () => { + clearTimeout(killerTimer); + child.kill('SIGKILL'); + }); + killer.once('close', () => { + clearTimeout(killerTimer); + child.kill('SIGKILL'); + }); + } else { + try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + } + }; + try { + child = spawn(file, args, { + cwd, + env, + detached: hostPlatform !== 'win32', + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + rejectRun(msiInspectionFailure('EXTRACTOR_TOOL')); + return; + } + const timer = setTimeout(fail, timeoutMs); + child.stdout.on('data', chunk => { + outputBytes += chunk.length; + if (outputBytes > outputLimit) fail(); + else if (captureStdout) stdout.push(chunk); + }); + child.stderr.on('data', () => fail()); + child.once('error', () => { + clearTimeout(timer); + rejectRun(msiInspectionFailure('EXTRACTOR_TOOL')); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (failed || code !== 0 || signal !== null) rejectRun(msiInspectionFailure('EXTRACTOR_TOOL')); + else resolveRun(captureStdout ? Buffer.concat(stdout, outputBytes) : undefined); + }); +}); + +export const msiExtractorInvocationForTest = (hostPlatform, path, extraction, tools) => { + if (hostPlatform === 'win32') { + return { + file: tools.msiexec, + args: ['/a', path, '/qn', '/norestart', 'REBOOT=ReallySuppress', `TARGETDIR=${extraction}`], + env: { SystemRoot: win32.dirname(win32.dirname(tools.msiexec)), TEMP: extraction, TMP: extraction }, + treeKiller: tools.taskkill, + }; + } + if (hostPlatform === 'linux') { + return { + file: tools.msiextract, + args: ['--directory', extraction, path], + env: { LANG: 'C', LC_ALL: 'C' }, + }; + } + throw msiInspectionFailure('UNSUPPORTED_HOST'); +}; + +export const validateMsiListingForTest = output => { + let text; + try { text = UTF8_DECODER.decode(output); } + catch { throw msiInspectionFailure('UNSAFE_TREE'); } + if (text.includes('\0')) throw msiInspectionFailure('UNSAFE_TREE'); + const paths = text.replace(/\r\n?/g, '\n').split('\n').filter(Boolean); + if (paths.length === 0 || paths.length > MAX_MSI_FILES) throw msiInspectionFailure('UNSAFE_TREE'); + const identities = new Set(); + for (const path of paths) { + const parts = path.split('/'); + if (path.startsWith('/') || path.includes('\\') || /^[A-Za-z]:/.test(path) + || parts.length > MAX_MSI_DEPTH || parts.some(part => !part || part === '.' || part === '..') + || Buffer.byteLength(path, 'utf8') > MAX_MSI_PATH_BYTES) { + throw msiInspectionFailure('UNSAFE_TREE'); + } + const identity = parts.map(part => part.toLocaleLowerCase('en-US')).join('/'); + if (identities.has(identity)) throw msiInspectionFailure('UNSAFE_TREE'); + identities.add(identity); + } +}; + +const extractAdministrativeMsi = async (path, extraction, hostPlatform = process.platform) => { + let tools; + if (hostPlatform === 'win32') { + const [msiexec, taskkill] = await Promise.all([ + resolveWindowsSystemTool(KERNEL_MSIEXEC, 'msiexec.exe'), + resolveWindowsSystemTool(KERNEL_TASKKILL, 'taskkill.exe'), + ]); + tools = { msiexec, taskkill }; + } else if (hostPlatform === 'linux') { + tools = { msiextract: await resolveLinuxMsiExtractor() }; + } else { + throw msiInspectionFailure('UNSUPPORTED_HOST'); + } + const invocation = msiExtractorInvocationForTest(hostPlatform, path, extraction, tools); + if (hostPlatform === 'linux') { + const listing = await runBoundedMsiExtractorForTest({ + ...invocation, + args: ['--list', path], + cwd: extraction, + hostPlatform, + captureStdout: true, + }); + validateMsiListingForTest(listing); + } + await runBoundedMsiExtractorForTest({ ...invocation, cwd: extraction, hostPlatform }); +}; + +export const inspectExtractedMsiLayout = async ({ root, platform, arch }) => { + const files = []; + const identities = new Set(); + let totalBytes = 0; + const visit = async (directory, depth) => { + if (depth > MAX_MSI_DEPTH) throw msiInspectionFailure('UNSAFE_TREE'); + let entries; + try { entries = await readdir(directory, { withFileTypes: true }); } + catch { throw msiInspectionFailure('UNSAFE_TREE'); } + for (const entry of entries) { + const entryPath = join(directory, entry.name); + const relativePath = relative(root, entryPath); + const parts = relativePath.split(sep); + if (!relativePath || isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${sep}`) + || parts.some(part => !part || part === '.' || part === '..' || part.includes('\0')) + || Buffer.byteLength(relativePath, 'utf8') > MAX_MSI_PATH_BYTES) { + throw msiInspectionFailure('UNSAFE_TREE'); + } + const identity = parts.map(part => part.toLocaleLowerCase('en-US')).join('/'); + if (identities.has(identity)) throw msiInspectionFailure('UNSAFE_TREE'); + identities.add(identity); + let stats; + try { stats = await lstat(entryPath, { bigint: true }); } + catch { throw msiInspectionFailure('UNSAFE_TREE'); } + if (stats.isSymbolicLink() || stats.isFile() && stats.nlink !== 1n + || (!stats.isDirectory() && !stats.isFile())) { + throw msiInspectionFailure('UNSAFE_TREE'); + } + if (stats.isDirectory()) { + await visit(entryPath, depth + 1); + } else { + if (stats.size < 0n || stats.size > BigInt(MAX_MSI_TOTAL_BYTES)) throw msiInspectionFailure('UNSAFE_TREE'); + let held; + try { + held = await open(entryPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + if (!sameFileIdentity(stats, await held.stat({ bigint: true }))) throw msiInspectionFailure('UNSAFE_TREE'); + } catch (error) { + if (error instanceof Error && error.message === 'MSI_INSPECTION_FAILED:UNSAFE_TREE') throw error; + throw msiInspectionFailure('UNSAFE_TREE'); + } finally { + await held?.close().catch(() => undefined); + } + totalBytes += Number(stats.size); + if (totalBytes > MAX_MSI_TOTAL_BYTES || files.push({ path: entryPath, relativePath: parts.join('/') }) > MAX_MSI_FILES) { + throw msiInspectionFailure('UNSAFE_TREE'); + } + } + } + }; + await visit(root, 0); + const authorityCount = files.filter(file => ( + /propr-windows-(?:authority|launcher|bootstrap)/i.test(basename(file.relativePath)) + || file.relativePath.split('/').some(part => /^(?:windows-update-authority|windows-authority)$/i.test(part)) + )).length; + if (authorityCount !== 0) throw msiInspectionFailure('AUTHORITY_RESOURCE', authorityCount); + const sameNameApplications = files.filter(file => ( + basename(file.relativePath).toLocaleLowerCase('en-US') === `${EXECUTABLE_NAME}.exe` + )); + const acceptedPaths = new Set([ + MSI_CANONICAL_APPLICATION, + `${MSI_ADMIN_ROOT_PREFIX}/${MSI_CANONICAL_APPLICATION}`, + ]); + const canonicalApplications = sameNameApplications.filter(file => acceptedPaths.has(file.relativePath)); + if (sameNameApplications.length !== 1 || canonicalApplications.length !== 1) { + throw msiInspectionFailure('CANONICAL_APP', canonicalApplications.length === 1 ? sameNameApplications.length : 0); + } + let executable; + try { + executable = inspectExecutableBytes(await readPrefix(canonicalApplications[0].path)); + assertExecutableArchitecture(executable, platform, arch, 'canonical MSI application'); + } catch { + throw msiInspectionFailure('ARCHITECTURE_MISMATCH'); + } + return executable; +}; + +const inspectMachineMsi = async (path, platform, arch, extract = extractAdministrativeMsi) => { + if (platform !== 'win32') throw msiInspectionFailure('TARGET_PLATFORM'); + let header; + try { header = await readPrefix(path); } + catch { throw msiInspectionFailure('MSI_HEADER'); } + if (header.length < 512 || header.subarray(0, 8).toString('hex') !== 'd0cf11e0a1b11ae1') { + throw msiInspectionFailure('MSI_HEADER'); + } + let extraction; + try { extraction = await mkdtemp(join(tmpdir(), 'propr-msi-inspect-')); } + catch { throw msiInspectionFailure('UNSAFE_TREE'); } + try { + const extractionStats = await lstat(extraction, { bigint: true }); + if (!extractionStats.isDirectory() || extractionStats.isSymbolicLink() + || process.platform !== 'win32' && (typeof process.getuid !== 'function' + || extractionStats.uid !== BigInt(process.getuid()) || (extractionStats.mode & 0o777n) !== 0o700n)) { + throw msiInspectionFailure('UNSAFE_TREE'); + } + try { await extract(resolve(path), extraction); } + catch (error) { + if (error instanceof Error && error.message.startsWith('MSI_INSPECTION_FAILED:')) throw error; + throw msiInspectionFailure('EXTRACTOR_TOOL'); + } + const executable = await inspectExtractedMsiLayout({ root: extraction, platform, arch }); + return { format: 'windows-machine-msi', scope: 'per-machine', executable }; + } finally { + try { await rm(extraction, { recursive: true, force: true }); } + catch { throw msiInspectionFailure('UNSAFE_TREE'); } + } +}; + +export const inspectMachineMsiForTest = inspectMachineMsi; + +const pathInside = (root, path) => { + const child = relative(root, path); + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)); +}; + +const displayPackagePath = (root, path) => relative(root, path).split(sep).join('/'); + +const describeFileType = stats => { + if (stats.isFile()) return 'regular file'; + if (stats.isDirectory()) return 'directory'; + if (stats.isSymbolicLink()) return 'symbolic link'; + return 'special file'; +}; + +const readPackageEntry = async (path, description) => { + try { + return await lstat(path); + } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`Linux package is missing ${description}`); + throw error; + } +}; + +const collectSameNameEntries = async root => { + const entries = []; + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const stats = await lstat(path); + if (entry.name.toLowerCase() === EXECUTABLE_NAME) entries.push({ path, stats }); + if (stats.isDirectory()) await visit(path); + } + }; + await visit(root); + return entries; +}; + +const resolvePackageSymlink = async (root, start) => { + const rootPath = resolve(root); + const startPath = resolve(start); + if (!pathInside(rootPath, startPath)) throw new Error('Linux package launcher escapes the extraction root'); + let components = relative(rootPath, startPath).split(sep).filter(Boolean); + const visited = new Set(); + + while (components.length > 0) { + let current = rootPath; + let followedLink = false; + for (let index = 0; index < components.length; index += 1) { + current = join(current, components[index]); + const stats = await readPackageEntry(current, `launcher target ${displayPackagePath(rootPath, current)}`); + if (stats.isSymbolicLink()) { + if (visited.has(current)) throw new Error('Linux package launcher contains a symbolic-link cycle'); + visited.add(current); + if (visited.size > 64) throw new Error('Linux package launcher has too many symbolic links'); + const target = await readlink(current); + if (isAbsolute(target)) throw new Error('Linux package launcher uses an absolute symbolic link'); + const resolvedTarget = resolve(dirname(current), target); + if (!pathInside(rootPath, resolvedTarget)) throw new Error('Linux package launcher escapes the extraction root'); + components = [ + ...relative(rootPath, resolvedTarget).split(sep).filter(Boolean), + ...components.slice(index + 1), + ]; + followedLink = true; + break; + } + if (index < components.length - 1 && !stats.isDirectory()) { + throw new Error(`Linux package launcher traverses non-directory ${displayPackagePath(rootPath, current)}`); + } + if (index === components.length - 1) return { path: current, stats }; + } + if (!followedLink) break; + } + throw new Error('Linux package launcher target is invalid'); +}; + +export const inspectLinuxPackageLayout = async ({ root, packageFormat, platform, arch, artifact }) => { + if (platform !== 'linux') throw new Error(`${artifact} Linux package is only valid for Linux targets`); + if (!['deb', 'rpm'].includes(packageFormat)) throw new Error(`${artifact} Linux package format is invalid`); + const rootPath = resolve(root); + const appDirectory = join(rootPath, LINUX_APP_DIRECTORY); + const payload = join(rootPath, LINUX_PAYLOAD); + const launcher = join(rootPath, LINUX_LAUNCHER); + + for (const [path, description] of [ + [join(rootPath, 'usr'), 'usr directory'], + [join(rootPath, 'usr', 'lib'), 'usr/lib directory'], + [appDirectory, `${LINUX_APP_DIRECTORY.split(sep).join('/')} directory`], + [join(rootPath, 'usr', 'bin'), 'usr/bin directory'], + ]) { + const stats = await readPackageEntry(path, description); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`Linux package ${description} must be a real directory, found ${describeFileType(stats)}`); + } + } + + const sameNameEntries = await collectSameNameEntries(rootPath); + const requiredEntries = new Map([ + [appDirectory, 'directory'], + [payload, 'regular file'], + [launcher, 'symbolic link'], + ]); + const allowedEntries = new Map([ + ...requiredEntries, + [join(rootPath, LINUX_DOC_DIRECTORY), 'directory'], + ...(packageFormat === 'deb' ? [[join(rootPath, DEB_LINTIAN_OVERRIDE), 'regular file']] : []), + ]); + const unexpected = sameNameEntries.filter(({ path, stats }) => { + const expectedType = allowedEntries.get(path); + return !expectedType || describeFileType(stats) !== expectedType; + }); + const missing = [...requiredEntries].filter(([path, expectedType]) => ( + !sameNameEntries.some(entry => entry.path === path && describeFileType(entry.stats) === expectedType) + )); + if (missing.length > 0 || unexpected.length > 0) { + const found = sameNameEntries + .map(({ path, stats }) => `${displayPackagePath(rootPath, path)} (${describeFileType(stats)})`) + .sort() + .join(', ') || 'none'; + throw new Error(`Linux package must contain only the canonical payload and launcher layout; found ${found}`); + } + + const payloadStats = await readPackageEntry(payload, `regular payload ${LINUX_PAYLOAD.split(sep).join('/')}`); + if (!payloadStats.isFile() || payloadStats.isSymbolicLink()) { + throw new Error(`Linux package payload must be a regular file, found ${describeFileType(payloadStats)}`); + } + const lintianOverride = sameNameEntries.find(entry => entry.path === join(rootPath, DEB_LINTIAN_OVERRIDE)); + if (lintianOverride) { + const prefix = await readPrefix(lintianOverride.path, 4); + if (lintianOverride.stats.size > 64 * 1024 + || prefix.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + throw new Error('DEB lintian override must not contain an extra ELF payload'); + } + } + const resolvedLauncher = await resolvePackageSymlink(rootPath, launcher); + if (resolvedLauncher.path !== payload || !resolvedLauncher.stats.isFile()) { + throw new Error(`Linux package launcher must resolve to ${LINUX_PAYLOAD.split(sep).join('/')}`); + } + const inspection = inspectExecutableBytes(await readPrefix(payload)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + return inspection; +}; + +const crcTable = Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + return crc >>> 0; +}); + +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +}; + +const readExact = async (handle, length, position, label) => { + const bytes = Buffer.alloc(length); + const { bytesRead } = await handle.read(bytes, 0, length, position); + if (bytesRead !== length) throw new Error(`${label} is truncated`); + return bytes; +}; + +const validateExtraFields = (bytes, label) => { + for (let offset = 0; offset < bytes.length;) { + if (offset + 4 > bytes.length) throw new Error(`${label} contains truncated ZIP extra metadata`); + const id = bytes.readUInt16LE(offset); + const length = bytes.readUInt16LE(offset + 2); + if (offset + 4 + length > bytes.length) throw new Error(`${label} contains truncated ZIP extra metadata`); + if (id === 0x0001 || id === 0x9901) throw new Error(`${label} uses unsupported ZIP64 or encrypted metadata`); + offset += 4 + length; + } +}; + +const decodeZipName = (bytes, flags) => { + let name; + try { + if ((flags & 0x0800) !== 0) name = UTF8_DECODER.decode(bytes); + else { + if (bytes.some(byte => byte > 0x7f)) throw new Error('legacy non-ASCII ZIP names are unsupported'); + name = bytes.toString('ascii'); + } + } catch (error) { + throw new Error(`ZIP entry name cannot be decoded strictly: ${error.message}`); + } + if (!name || name.includes('\0') || name.includes('\\') || name.normalize('NFC') !== name + || name.startsWith('/') || name.startsWith('//') || /^[A-Za-z]:/.test(name)) { + throw new Error(`ZIP entry has an unsafe name: ${JSON.stringify(name)}`); + } + const directory = name.endsWith('/'); + const path = directory ? name.slice(0, -1) : name; + if (!path || path.startsWith('/') || path.endsWith('/') || posix.normalize(path) !== path + || path.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`ZIP entry has a non-normalized relative POSIX path: ${JSON.stringify(name)}`); + } + return { name, path, directory }; +}; + +const archiveExecutablePath = (kind, platform, arch) => { + if (kind === 'nupkg' && platform === 'win32') return `lib/net45/${EXECUTABLE_NAME}.exe`; + if (kind === 'zip' && platform === 'linux') return `${EXECUTABLE_NAME}-linux-${arch}/${EXECUTABLE_NAME}`; + if (kind === 'zip' && platform === 'darwin') return `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`; + throw new Error(`${kind} does not have a canonical executable path for ${platform}-${arch}`); +}; + +const darwinFrameworkRoot = entryPath => { + const components = entryPath.split('/'); + if (components.length < 5 + || components[0] !== `${EXECUTABLE_NAME}.app` + || components[1] !== 'Contents' + || components[2] !== 'Frameworks' + || !components[3].endsWith('.framework') + || components[3] === '.framework' + || components.slice(4).some(component => component.toLocaleLowerCase('en-US').endsWith('.app')) + || DMG_HELPER_EXECUTABLES.has(components.at(-1).toLocaleLowerCase('en-US'))) return undefined; + return components.slice(0, 4).join('/'); +}; + +const decodeZipSymlinkTarget = entry => { + if (entry.bytes.length === 0 || entry.bytes.length > MAX_ZIP_SYMLINK_BYTES) { + throw new Error(`ZIP symbolic link ${entry.name} has an empty or oversized payload`); + } + let target; + try { + target = UTF8_DECODER.decode(entry.bytes); + } catch (error) { + throw new Error(`ZIP symbolic link ${entry.name} target cannot be decoded strictly: ${error.message}`); + } + if (target.includes('\0') || target.includes('\\') || target.normalize('NFC') !== target + || target.startsWith('/') || target.startsWith('//') || /^[A-Za-z]:/.test(target) + || posix.normalize(target) !== target + || target.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`ZIP symbolic link ${entry.name} has an unsafe relative target`); + } + return target; +}; + +const decodeDmgSymlinkTarget = (bytes, entryPath) => { + if (bytes.length === 0 || bytes.length > MAX_ZIP_SYMLINK_BYTES) { + throw new Error(`DMG framework symbolic link ${entryPath} has an empty or oversized target`); + } + let target; + try { + target = UTF8_DECODER.decode(bytes); + } catch (error) { + throw new Error(`DMG framework symbolic link ${entryPath} target cannot be decoded strictly: ${error.message}`); + } + if (target.includes('\0') || target.includes('\\') || target.normalize('NFC') !== target + || target.startsWith('/') || target.startsWith('//') || /^[A-Za-z]:/.test(target) + || posix.normalize(target) !== target + || target.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`DMG framework symbolic link ${entryPath} has an unsafe relative target`); + } + return target; +}; + +const validateDarwinFrameworkSymlinks = entries => { + const symlinks = entries.filter(entry => entry.symbolicLink); + if (symlinks.length > MAX_ZIP_SYMLINKS) throw new Error('ZIP contains too many symbolic links'); + const entriesByPath = new Map(entries.map(entry => [entry.path, entry])); + const entryPaths = [...entriesByPath.keys()]; + for (const entry of symlinks) entry.target = decodeZipSymlinkTarget(entry); + + const pathExistsAsDirectory = candidate => entryPaths.some(entryPath => entryPath.startsWith(`${candidate}/`)); + for (const link of symlinks) { + const frameworkRoot = link.frameworkRoot; + let components = link.path.split('/'); + const visited = new Set(); + let index = 0; + while (index < components.length) { + const candidate = components.slice(0, index + 1).join('/'); + const entry = entriesByPath.get(candidate); + if (entry?.symbolicLink) { + if (visited.has(candidate)) throw new Error(`ZIP symbolic link ${link.name} contains a cycle`); + visited.add(candidate); + if (visited.size > MAX_ZIP_SYMLINKS) throw new Error(`ZIP symbolic link ${link.name} chain is too long`); + const resolvedTarget = posix.normalize(posix.join(posix.dirname(candidate), entry.target)); + if (resolvedTarget !== frameworkRoot && !resolvedTarget.startsWith(`${frameworkRoot}/`)) { + throw new Error(`ZIP symbolic link ${link.name} escapes its canonical framework`); + } + components = [...resolvedTarget.split('/'), ...components.slice(index + 1)]; + index = 0; + continue; + } + const hasRemainingComponents = index < components.length - 1; + if (!entry && !pathExistsAsDirectory(candidate)) { + throw new Error(`ZIP symbolic link ${link.name} has a missing target ${candidate}`); + } + if (hasRemainingComponents && entry && !entry.directory) { + throw new Error(`ZIP symbolic link ${link.name} traverses non-directory target ${candidate}`); + } + index += 1; + } + const resolved = components.join('/'); + if (resolved !== frameworkRoot && !resolved.startsWith(`${frameworkRoot}/`)) { + throw new Error(`ZIP symbolic link ${link.name} escapes its canonical framework`); + } + } +}; + +const validateDmgFrameworkSymlinks = entries => { + const symlinks = entries.filter(entry => entry.symbolicLink); + if (symlinks.length > MAX_ZIP_SYMLINKS) throw new Error('DMG contains too many symbolic links'); + const entriesByPath = new Map(entries.map(entry => [entry.path, entry])); + + for (const link of symlinks) { + const frameworkRoot = link.frameworkRoot; + let components = link.path.split('/'); + const visited = new Set(); + let index = 0; + while (index < components.length) { + const candidate = components.slice(0, index + 1).join('/'); + const entry = entriesByPath.get(candidate); + if (entry?.symbolicLink) { + if (visited.has(candidate)) throw new Error(`DMG framework symbolic link ${link.path} contains a cycle`); + visited.add(candidate); + if (visited.size > MAX_ZIP_SYMLINKS) throw new Error(`DMG framework symbolic link ${link.path} chain is too long`); + const resolvedTarget = posix.normalize(posix.join(posix.dirname(candidate), entry.target)); + if (resolvedTarget !== frameworkRoot && !resolvedTarget.startsWith(`${frameworkRoot}/`)) { + throw new Error(`DMG framework symbolic link ${link.path} escapes its canonical framework`); + } + components = [...resolvedTarget.split('/'), ...components.slice(index + 1)]; + index = 0; + continue; + } + if (!entry) throw new Error(`DMG framework symbolic link ${link.path} has a missing target ${candidate}`); + if (index < components.length - 1 && !entry.directory) { + throw new Error(`DMG framework symbolic link ${link.path} traverses non-directory target ${candidate}`); + } + index += 1; + } + const resolved = components.join('/'); + if (resolved !== frameworkRoot && !resolved.startsWith(`${frameworkRoot}/`)) { + throw new Error(`DMG framework symbolic link ${link.path} escapes its canonical framework`); + } + } +}; + +const readValidatedZipExecutable = async (path, kind, platform, arch) => { + const handle = await open(path, 'r'); + try { + const { size } = await handle.stat(); + const tailLength = Math.min(size, 65_557); + const tail = await readExact(handle, tailLength, size - tailLength, 'ZIP tail'); + const eocdCandidates = []; + for (let offset = tail.length - 22; offset >= 0; offset -= 1) { + if (tail.readUInt32LE(offset) === 0x06054b50 + && offset + 22 + tail.readUInt16LE(offset + 20) === tail.length) eocdCandidates.push(offset); + } + if (eocdCandidates.length !== 1) throw new Error('ZIP end-of-central-directory record is missing or ambiguous'); + const eocd = eocdCandidates[0]; + if (tail.readUInt16LE(eocd + 20) !== 0) throw new Error('ZIP archive comments create trailing ambiguity'); + if (tail.readUInt16LE(eocd + 4) !== 0 || tail.readUInt16LE(eocd + 6) !== 0) { + throw new Error('Multi-disk ZIP archives are unsupported'); + } + const diskEntries = tail.readUInt16LE(eocd + 8); + const entryCount = tail.readUInt16LE(eocd + 10); + const centralSize = tail.readUInt32LE(eocd + 12); + const centralOffset = tail.readUInt32LE(eocd + 16); + const eocdOffset = size - tailLength + eocd; + if (diskEntries !== entryCount || entryCount > MAX_ZIP_ENTRIES + || entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff + || centralSize > MAX_ZIP_DIRECTORY_BYTES || centralOffset + centralSize !== eocdOffset) { + throw new Error('ZIP central directory is invalid or oversized'); + } + const central = await readExact(handle, centralSize, centralOffset, 'ZIP central directory'); + const entries = []; + for (let offset = 0; offset < central.length;) { + if (offset + 46 > central.length) throw new Error('ZIP central directory entry is truncated'); + if (central.readUInt32LE(offset) !== 0x02014b50) throw new Error('ZIP central directory entry is invalid'); + const flags = central.readUInt16LE(offset + 8); + const method = central.readUInt16LE(offset + 10); + const checksum = central.readUInt32LE(offset + 16); + const compressedSize = central.readUInt32LE(offset + 20); + const uncompressedSize = central.readUInt32LE(offset + 24); + const nameLength = central.readUInt16LE(offset + 28); + const extraLength = central.readUInt16LE(offset + 30); + const commentLength = central.readUInt16LE(offset + 32); + const disk = central.readUInt16LE(offset + 34); + const externalAttributes = central.readUInt32LE(offset + 38); + const localOffset = central.readUInt32LE(offset + 42); + const nextOffset = offset + 46 + nameLength + extraLength + commentLength; + if (nextOffset > central.length || nameLength + extraLength + commentLength > MAX_ZIP_ENTRY_METADATA_BYTES) { + throw new Error('ZIP central directory entry is truncated or has oversized metadata'); + } + if (disk !== 0 || (flags & ~(0x0800 | 0x0008 | 0x0006)) !== 0 || ![0, 8].includes(method) + || (method === 0 && (flags & 0x0006) !== 0) + || compressedSize > MAX_EXECUTABLE_BYTES || uncompressedSize > MAX_EXECUTABLE_BYTES) { + throw new Error('ZIP entry is encrypted, unsupported, or oversized'); + } + const nameBytes = central.subarray(offset + 46, offset + 46 + nameLength); + const decoded = decodeZipName(nameBytes, flags); + const extra = central.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength); + validateExtraFields(extra, `ZIP entry ${decoded.name}`); + const unixType = (externalAttributes >>> 16) & 0xf000; + const symbolicLink = unixType === 0xa000; + const frameworkRoot = symbolicLink && kind === 'zip' && platform === 'darwin' + ? darwinFrameworkRoot(decoded.path) + : undefined; + if (symbolicLink && (!frameworkRoot || decoded.directory)) { + throw new Error(`ZIP entry ${decoded.name} is a symbolic link outside canonical macOS framework internals`); + } + if (unixType && unixType !== 0x4000 && unixType !== 0x8000 && !symbolicLink) { + throw new Error(`ZIP entry ${decoded.name} is a symbolic link or special file`); + } + if ((decoded.directory && unixType === 0x8000) || (!decoded.directory && unixType === 0x4000)) { + throw new Error(`ZIP entry ${decoded.name} has conflicting file and directory metadata`); + } + if (symbolicLink && (compressedSize > MAX_ZIP_SYMLINK_BYTES || uncompressedSize > MAX_ZIP_SYMLINK_BYTES)) { + throw new Error(`ZIP symbolic link ${decoded.name} has an oversized payload`); + } + entries.push({ + ...decoded, + flags, + method, + checksum, + compressedSize, + uncompressedSize, + localOffset, + nameBytes, + symbolicLink, + frameworkRoot, + }); + offset = nextOffset; + } + if (entries.length !== entryCount) throw new Error('ZIP central directory entry count is inconsistent'); + const exactNames = new Set(); + const caseNames = new Set(); + const componentCase = new Map(); + for (const entry of entries) { + if (exactNames.has(entry.path) || caseNames.has(entry.path.toLocaleLowerCase('en-US'))) { + throw new Error(`ZIP contains duplicate or case-colliding entry ${entry.name}`); + } + exactNames.add(entry.path); + caseNames.add(entry.path.toLocaleLowerCase('en-US')); + const components = entry.path.split('/'); + for (let length = 1; length <= components.length; length += 1) { + const prefix = components.slice(0, length).join('/'); + const key = prefix.toLocaleLowerCase('en-US'); + if (componentCase.has(key) && componentCase.get(key) !== prefix) { + throw new Error(`ZIP contains case-colliding path components at ${entry.name}`); + } + componentCase.set(key, prefix); + } + } + for (const entry of entries.filter(candidate => !candidate.directory)) { + const prefix = `${entry.path.toLocaleLowerCase('en-US')}/`; + if (entries.some(candidate => candidate.path.toLocaleLowerCase('en-US').startsWith(prefix))) { + throw new Error(`ZIP contains conflicting file and directory prefix ${entry.path}`); + } + } + + const ranges = []; + let executableBytes; + let authorityExecutableBytes; + let authorityManifestBytes; + let authorityLauncherBytes; + let authorityBootstrapBytes; + const canonicalExecutable = archiveExecutablePath(kind, platform, arch); + const expectedExecutableName = platform === 'win32' ? `${EXECUTABLE_NAME}.exe` : EXECUTABLE_NAME; + const alternateExecutables = entries.filter(entry => !entry.directory + && basename(entry.path).toLocaleLowerCase('en-US') === expectedExecutableName.toLocaleLowerCase('en-US') + && entry.path !== canonicalExecutable); + if (alternateExecutables.length) throw new Error(`ZIP contains an executable outside ${canonicalExecutable}`); + if (kind === 'nupkg' && platform === 'win32') { + const alternateAuthority = entries.filter(entry => !entry.directory + && ['propr-windows-authority.exe', 'propr-windows-authority.manifest.json', 'propr-windows-launcher.node', + 'propr-windows-bootstrap.node'] + .includes(basename(entry.path).toLocaleLowerCase('en-US')) + && ![WINDOWS_AUTHORITY_EXECUTABLE, WINDOWS_AUTHORITY_MANIFEST, WINDOWS_AUTHORITY_LAUNCHER, + WINDOWS_AUTHORITY_BOOTSTRAP].includes(entry.path)); + if (alternateAuthority.length) throw new Error('NUPKG contains an ambiguous Windows authority helper layout'); + } + for (const entry of entries) { + if (entry.localOffset + 30 > centralOffset) throw new Error(`ZIP local header offset is invalid for ${entry.name}`); + const local = await readExact(handle, 30, entry.localOffset, `ZIP local header for ${entry.name}`); + if (local.readUInt32LE(0) !== 0x04034b50) throw new Error(`ZIP local entry header is invalid for ${entry.name}`); + const localFlags = local.readUInt16LE(6); + const localMethod = local.readUInt16LE(8); + const localChecksum = local.readUInt32LE(14); + const localCompressedSize = local.readUInt32LE(18); + const localUncompressedSize = local.readUInt32LE(22); + const localNameLength = local.readUInt16LE(26); + const localExtraLength = local.readUInt16LE(28); + if (localNameLength + localExtraLength > MAX_ZIP_ENTRY_METADATA_BYTES) { + throw new Error(`ZIP local entry metadata is oversized for ${entry.name}`); + } + const localMetadata = await readExact( + handle, + localNameLength + localExtraLength, + entry.localOffset + 30, + `ZIP local metadata for ${entry.name}`, + ); + const localNameBytes = localMetadata.subarray(0, localNameLength); + const localName = decodeZipName(localNameBytes, localFlags); + validateExtraFields(localMetadata.subarray(localNameLength), `ZIP local entry ${entry.name}`); + if (localFlags !== entry.flags || localMethod !== entry.method + || !localNameBytes.equals(entry.nameBytes) || localName.name !== entry.name) { + throw new Error(`ZIP central and local entry metadata disagree for ${entry.name}`); + } + const dataOffset = entry.localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataOffset + entry.compressedSize; + if (dataEnd > centralOffset) throw new Error(`ZIP entry exceeds archive bounds for ${entry.name}`); + const compressed = await readExact(handle, entry.compressedSize, dataOffset, `ZIP entry data for ${entry.name}`); + let bytes; + try { + bytes = entry.method === 0 + ? compressed + : inflateRawSync(compressed, { maxOutputLength: MAX_EXECUTABLE_BYTES }); + } catch { + throw new Error(`ZIP entry compression is invalid for ${entry.name}`); + } + if (bytes.length !== entry.uncompressedSize || crc32(bytes) !== entry.checksum) { + throw new Error(`ZIP entry size or CRC is invalid for ${entry.name}`); + } + let recordEnd = dataEnd; + if ((entry.flags & 0x0008) !== 0) { + const prefix = await readExact(handle, 4, recordEnd, `ZIP data descriptor for ${entry.name}`); + const hasSignature = prefix.readUInt32LE(0) === 0x08074b50; + const descriptor = await readExact(handle, hasSignature ? 16 : 12, recordEnd, `ZIP data descriptor for ${entry.name}`); + const base = hasSignature ? 4 : 0; + if (descriptor.readUInt32LE(base) !== entry.checksum + || descriptor.readUInt32LE(base + 4) !== entry.compressedSize + || descriptor.readUInt32LE(base + 8) !== entry.uncompressedSize + || ![0, entry.checksum].includes(localChecksum) + || ![0, entry.compressedSize].includes(localCompressedSize) + || ![0, entry.uncompressedSize].includes(localUncompressedSize)) { + throw new Error(`ZIP central, local, and descriptor sizes or CRC disagree for ${entry.name}`); + } + recordEnd += descriptor.length; + } else if (localChecksum !== entry.checksum || localCompressedSize !== entry.compressedSize + || localUncompressedSize !== entry.uncompressedSize) { + throw new Error(`ZIP central and local sizes or CRC disagree for ${entry.name}`); + } + ranges.push({ start: entry.localOffset, end: recordEnd, name: entry.name }); + if (entry.symbolicLink) entry.bytes = bytes; + if (entry.path === canonicalExecutable) executableBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_EXECUTABLE) authorityExecutableBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_MANIFEST) authorityManifestBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_LAUNCHER) authorityLauncherBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_BOOTSTRAP) authorityBootstrapBytes = bytes; + } + ranges.sort((left, right) => left.start - right.start); + let expectedOffset = 0; + for (const range of ranges) { + if (range.start !== expectedOffset || range.end <= range.start || range.end > centralOffset) { + throw new Error(`ZIP entries overlap or contain unclaimed data near ${range.name}`); + } + expectedOffset = range.end; + } + if (expectedOffset !== centralOffset) throw new Error('ZIP contains unclaimed data before its central directory'); + validateDarwinFrameworkSymlinks(entries); + if (!executableBytes) throw new Error(`ZIP is missing canonical executable ${canonicalExecutable}`); + if (kind === 'nupkg' && platform === 'win32') { + if (!authorityExecutableBytes || !authorityManifestBytes || !authorityLauncherBytes || !authorityBootstrapBytes + || authorityManifestBytes.length > 16 * 1024 + || authorityManifestBytes.at(-1) !== 0x0a) throw new Error('NUPKG is missing its exact Windows authority helper binding'); + let authorityManifest; + try { authorityManifest = JSON.parse(UTF8_DECODER.decode(authorityManifestBytes.subarray(0, -1))); } + catch { throw new Error('NUPKG Windows authority manifest is not strict UTF-8 JSON'); } + let launcherInspection, bootstrapInspection; + try { + launcherInspection = inspectExecutableBytes(authorityLauncherBytes); + bootstrapInspection = inspectExecutableBytes(authorityBootstrapBytes); + } + catch { throw new Error('NUPKG Windows native launcher is not a valid PE image'); } + const packagedApplicationInspection = inspectExecutableBytes(executableBytes); + const packagedArchitecture = packagedApplicationInspection.architectures.length === 1 + ? packagedApplicationInspection.architectures[0] : ''; + const expectedKeys = ['architecture', 'bootstrap', 'clr', 'compiler', 'format', 'launcher', 'machine', 'name', 'protocol', 'publisher', + 'schemaVersion', 'sha256', 'signerCertificateSha256', 'signerPins', 'signerSpkiSha256', 'size', + 'sourceSha256', 'trust']; + if (!authorityManifest || typeof authorityManifest !== 'object' || Array.isArray(authorityManifest) + || JSON.stringify(Object.keys(authorityManifest).sort()) !== JSON.stringify(expectedKeys) + || authorityManifest.schemaVersion !== 1 || authorityManifest.name !== 'propr-windows-authority.exe' + || authorityManifest.format !== 'PE32' || authorityManifest.architecture !== 'anycpu' + || authorityManifest.machine !== 'I386' || authorityManifest.clr !== true + || authorityManifest.protocol !== 'propr-windows-authority-v1' + || !authorityManifest.compiler || typeof authorityManifest.compiler !== 'object' + || Array.isArray(authorityManifest.compiler) + || JSON.stringify(Object.keys(authorityManifest.compiler).sort()) !== JSON.stringify([ + 'framework', 'kind', + ]) + || authorityManifest.compiler.kind !== 'windows-fixed-system-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(String(authorityManifest.compiler.framework)) + || !['unsigned-validation', 'production-signed'].includes(authorityManifest.trust) + || !Array.isArray(authorityManifest.signerPins) || authorityManifest.signerPins.length > 16 + || authorityManifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(authorityManifest.signerPins).size !== authorityManifest.signerPins.length + || authorityManifest.signerPins.join(',') !== [...authorityManifest.signerPins].sort().join(',') + || (authorityManifest.trust === 'unsigned-validation' + && (authorityManifest.publisher !== null || authorityManifest.signerPins.length !== 0 + || authorityManifest.signerCertificateSha256 !== null || authorityManifest.signerSpkiSha256 !== null)) + || (authorityManifest.trust === 'production-signed' + && (typeof authorityManifest.publisher !== 'string' || !authorityManifest.publisher + || authorityManifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.signerSpkiSha256)) + || !authorityManifest.signerPins.some(pin => + pin === `certificate-sha256:${authorityManifest.signerCertificateSha256}` + || pin === `spki-sha256:${authorityManifest.signerSpkiSha256}`))) + || authorityManifest.size !== authorityExecutableBytes.length + || authorityManifest.sha256 !== createHash('sha256').update(authorityExecutableBytes).digest('hex') + || !authorityManifest.launcher || typeof authorityManifest.launcher !== 'object' + || JSON.stringify(Object.keys(authorityManifest.launcher).sort()) !== JSON.stringify([ + 'architecture', 'format', 'machine', 'name', 'publisher', 'sha256', 'signerCertificateSha256', + 'signerPins', 'signerSpkiSha256', 'size', 'trust', + ]) + || authorityManifest.launcher.name !== 'propr-windows-launcher.node' + || authorityManifest.launcher.format !== 'PE' + || authorityManifest.launcher.architecture !== packagedArchitecture + || authorityManifest.launcher.machine !== (packagedArchitecture === 'arm64' ? 'ARM64' : 'AMD64') + || launcherInspection.format !== 'pe' || launcherInspection.architectures.length !== 1 + || launcherInspection.architectures[0] !== packagedArchitecture + || authorityManifest.launcher.size !== authorityLauncherBytes.length + || authorityManifest.launcher.sha256 !== createHash('sha256').update(authorityLauncherBytes).digest('hex') + || authorityManifest.launcher.trust !== authorityManifest.trust + || authorityManifest.launcher.publisher !== authorityManifest.publisher + || JSON.stringify(authorityManifest.launcher.signerPins) !== JSON.stringify(authorityManifest.signerPins) + || authorityManifest.launcher.signerCertificateSha256 !== authorityManifest.signerCertificateSha256 + || authorityManifest.launcher.signerSpkiSha256 !== authorityManifest.signerSpkiSha256 + || !authorityManifest.bootstrap || typeof authorityManifest.bootstrap !== 'object' + || Array.isArray(authorityManifest.bootstrap) + || JSON.stringify(Object.keys(authorityManifest.bootstrap).sort()) !== JSON.stringify([ + 'architecture', 'format', 'machine', 'name', 'publisher', 'sha256', 'signerCertificateSha256', + 'signerPins', 'signerSpkiSha256', 'size', 'trust', + ]) + || authorityManifest.bootstrap.name !== 'propr-windows-bootstrap.node' + || authorityManifest.bootstrap.format !== 'PE' + || authorityManifest.bootstrap.architecture !== packagedArchitecture + || authorityManifest.bootstrap.machine !== (packagedArchitecture === 'arm64' ? 'ARM64' : 'AMD64') + || bootstrapInspection.format !== 'pe' || bootstrapInspection.architectures.length !== 1 + || bootstrapInspection.architectures[0] !== packagedArchitecture + || authorityManifest.bootstrap.size !== authorityBootstrapBytes.length + || authorityManifest.bootstrap.sha256 !== createHash('sha256').update(authorityBootstrapBytes).digest('hex') + || authorityManifest.bootstrap.trust !== authorityManifest.trust + || authorityManifest.bootstrap.publisher !== authorityManifest.publisher + || JSON.stringify(authorityManifest.bootstrap.signerPins) !== JSON.stringify(authorityManifest.signerPins) + || authorityManifest.bootstrap.signerCertificateSha256 !== authorityManifest.signerCertificateSha256 + || authorityManifest.bootstrap.signerSpkiSha256 !== authorityManifest.signerSpkiSha256 + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.sourceSha256))) { + throw new Error('NUPKG Windows authority helper does not match its bound manifest'); + } + const peOffset = authorityExecutableBytes.length >= 512 ? authorityExecutableBytes.readUInt32LE(0x3c) : -1; + const optional = peOffset + 24; + const clrDirectory = optional + 96 + (14 * 8); + if (peOffset < 0x40 || clrDirectory + 8 > authorityExecutableBytes.length + || authorityExecutableBytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0' + || authorityExecutableBytes.readUInt16LE(peOffset + 4) !== 0x14c + || authorityExecutableBytes.readUInt16LE(optional) !== 0x10b + || authorityExecutableBytes.readUInt32LE(clrDirectory) === 0) { + throw new Error('NUPKG Windows authority helper is not the expected managed AnyCPU PE32 executable'); + } + const sectionCount = authorityExecutableBytes.readUInt16LE(peOffset + 6); + const optionalSize = authorityExecutableBytes.readUInt16LE(peOffset + 20); + const clrRva = authorityExecutableBytes.readUInt32LE(clrDirectory); + const sectionTable = optional + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > authorityExecutableBytes.length) break; + const virtualSize = authorityExecutableBytes.readUInt32LE(section + 8); + const virtualAddress = authorityExecutableBytes.readUInt32LE(section + 12); + const rawSize = authorityExecutableBytes.readUInt32LE(section + 16); + const rawAddress = authorityExecutableBytes.readUInt32LE(section + 20); + if (clrRva >= virtualAddress && clrRva < virtualAddress + Math.max(virtualSize, rawSize)) { + clrOffset = rawAddress + clrRva - virtualAddress; + } + } + const corFlags = clrOffset >= 0 && clrOffset + 20 <= authorityExecutableBytes.length + ? authorityExecutableBytes.readUInt32LE(clrOffset + 16) + : 0; + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 + || (corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) { + throw new Error('NUPKG Windows authority helper is not the expected managed AnyCPU PE32 executable'); + } + } + return executableBytes; + } finally { + await handle.close(); + } +}; + +const runPipeline = (firstCommand, firstArgs, secondCommand, secondArgs, cwd) => new Promise((resolve, reject) => { + const first = spawn(firstCommand, firstArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + const second = spawn(secondCommand, secondArgs, { cwd, stdio: ['pipe', 'ignore', 'pipe'] }); + let errors = ''; + first.stderr.on('data', chunk => { errors += chunk; }); + second.stderr.on('data', chunk => { errors += chunk; }); + first.stdout.pipe(second.stdin); + let firstCode; + let secondCode; + const complete = () => { + if (firstCode === undefined || secondCode === undefined) return; + if (firstCode === 0 && secondCode === 0) resolve(); + else reject(new Error(`${firstCommand}/${secondCommand} failed: ${errors.trim()}`)); + }; + first.on('error', reject); + second.on('error', reject); + first.on('close', code => { firstCode = code; complete(); }); + second.on('close', code => { secondCode = code; complete(); }); +}); + +const inspectDeb = async (path, platform, arch) => { + const { stdout } = await execFile('dpkg-deb', ['--field', path, 'Architecture']); + const packageArchitecture = stdout.trim(); + if (packageArchitecture !== EXPECTED_PACKAGE_ARCHITECTURE.deb[arch]) { + throw new Error(`DEB architecture mismatch: expected ${EXPECTED_PACKAGE_ARCHITECTURE.deb[arch]}, found ${packageArchitecture}`); + } + const directory = await mkdtemp(join(tmpdir(), 'propr-deb-')); + try { + await execFile('dpkg-deb', ['--extract', path, directory]); + const executable = await inspectLinuxPackageLayout({ root: directory, packageFormat: 'deb', platform, arch, artifact: path }); + return { format: 'deb', packageArchitecture, executable }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const inspectRpm = async (path, platform, arch) => { + const { stdout } = await execFile('rpm', ['-qp', '--qf', '%{ARCH}', path]); + const packageArchitecture = stdout.trim(); + if (packageArchitecture !== EXPECTED_PACKAGE_ARCHITECTURE.rpm[arch]) { + throw new Error(`RPM architecture mismatch: expected ${EXPECTED_PACKAGE_ARCHITECTURE.rpm[arch]}, found ${packageArchitecture}`); + } + const directory = await mkdtemp(join(tmpdir(), 'propr-rpm-')); + try { + await runPipeline('rpm2cpio', [path], 'cpio', ['-idm', '--quiet'], directory); + const executable = await inspectLinuxPackageLayout({ root: directory, packageFormat: 'rpm', platform, arch, artifact: path }); + return { format: 'rpm', packageArchitecture, executable }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const execFileWithHeldDescriptor = (file, arguments_, descriptor) => new Promise((resolvePromise, rejectPromise) => { + const child = spawn(file, arguments_, { + stdio: ['ignore', 'pipe', 'pipe', descriptor], + }); + const stdout = []; + const stderr = []; + let outputBytes = 0; + const collect = destination => chunk => { + outputBytes += chunk.length; + if (outputBytes > 16 * 1024 * 1024) { + child.kill(); + rejectPromise(new Error(`${file} produced excessive output`)); + return; + } + destination.push(chunk); + }; + child.stdout.on('data', collect(stdout)); + child.stderr.on('data', collect(stderr)); + child.once('error', rejectPromise); + child.once('close', (code, signal) => { + const standardOutput = Buffer.concat(stdout).toString('utf8'); + const standardError = Buffer.concat(stderr).toString('utf8'); + if (code === 0) { + resolvePromise({ stdout: standardOutput, stderr: standardError }); + return; + } + rejectPromise(new Error( + `${file} exited with ${signal ? `signal ${signal}` : `code ${code}`}${standardError ? `: ${standardError.trim()}` : ''}`, + )); + }); +}); + +const attachPrivateDmg = async (heldArtifact, directory) => { + const { handle, privatePath } = requireHeldDmgArtifact(heldArtifact); + if (!privatePath) { + throw new Error('Native DMG inspection requires an internal private-snapshot pathname capability'); + } + let heldStats; + let pathStats; + try { + [heldStats, pathStats] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(privatePath, { bigint: true }), + ]); + } catch { + throw new Error('Native DMG inspection could not prove the held private-snapshot pathname capability'); + } + if (!heldStats.isFile() + || !pathStats.isFile() + || pathStats.isSymbolicLink() + || heldStats.dev !== pathStats.dev + || heldStats.ino !== pathStats.ino + || heldStats.mode !== pathStats.mode + || heldStats.nlink !== 1n + || pathStats.nlink !== 1n + || heldStats.size !== pathStats.size + || (pathStats.mode & 0o777n) !== 0o600n + || typeof process.getuid !== 'function' + || pathStats.uid !== BigInt(process.getuid())) { + throw new Error('Native DMG inspection rejected an invalid private-snapshot pathname capability'); + } + try { + await execFile(HDIUTIL, ['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath]); + } catch { + // hdiutil includes its source argument in some failures. Keep the internal + // randomized pathname out of logs while still failing closed. + throw new Error('Native read-only DMG attach failed for the held private snapshot'); + } +}; + +const inspectDmg = async (heldArtifact, platform, arch, onDmgMounted) => { + const { handle, description } = requireHeldDmgArtifact(heldArtifact); + const directory = await mkdtemp(join(tmpdir(), 'propr-dmg-')); + let mounted = false; + try { + if (process.platform === 'darwin') { + await attachPrivateDmg(heldArtifact, directory); + mounted = true; + if (onDmgMounted) await onDmgMounted(); + const executable = await inspectDmgLayout({ root: directory, platform, arch, artifact: description }); + return { + format: 'dmg', + executable, + nativeValidation: nativeDmgLayoutEvidence(arch), + }; + } else { + await execFileWithHeldDescriptor( + '7z', + ['x', '-y', '-bso0', '-bsp0', `-o${directory}`, '/dev/fd/3'], + handle.fd, + ); + const executable = await inspectExtractedDmgArchitecture({ root: directory, platform, arch, artifact: description }); + return { format: 'dmg', executable }; + } + } finally { + try { + if (mounted) await execFile(HDIUTIL, ['detach', directory]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } +}; + +const dmgExecutableLayout = arch => ({ + topLevelApplication: `${EXECUTABLE_NAME}.app`, + installLink: { + path: DMG_INSTALL_LINK, + type: 'symbolic-link', + target: '/Applications', + }, + mainExecutable: { + path: `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`, + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: [...DMG_HELPER_BUNDLES].map(bundle => { + const executable = bundle.slice(0, -'.app'.length); + return { + bundle, + path: `${EXECUTABLE_NAME}.app/Contents/Frameworks/${bundle}/Contents/MacOS/${executable}`, + format: 'mach-o', + architectures: [arch], + }; + }), +}); + +const nativeDmgLayoutEvidence = arch => ({ + ...NATIVE_DMG_VALIDATOR, + layout: dmgExecutableLayout(arch), +}); + +export const inspectExtractedDmgArchitecture = async ({ root, platform, arch, artifact }) => { + if (platform !== 'darwin') throw new Error(`${artifact} DMG is only valid for macOS targets`); + const rootPath = resolve(root); + const layout = dmgExecutableLayout(arch); + const executablePaths = [layout.mainExecutable, ...layout.helperExecutables]; + let mainInspection; + for (const entry of executablePaths) { + const path = join(rootPath, ...entry.path.split('/')); + let stats; + try { stats = await lstat(path); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical executable ${entry.path}`); + throw error; + } + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`DMG canonical executable ${entry.path} must be a real regular file`); + } + const inspection = inspectExecutableBytes(await readPrefix(path)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + if (entry === layout.mainExecutable) mainInspection = inspection; + } + return mainInspection; +}; + +export const inspectDmgLayout = async ({ root, platform, arch, artifact }) => { + if (platform !== 'darwin') throw new Error(`${artifact} DMG is only valid for macOS targets`); + const rootPath = resolve(root); + const application = join(rootPath, `${EXECUTABLE_NAME}.app`); + const contents = join(application, 'Contents'); + const macos = join(contents, 'MacOS'); + const executable = join(macos, EXECUTABLE_NAME); + const helperDirectory = join(contents, 'Frameworks'); + const installLink = join(rootPath, DMG_INSTALL_LINK); + const canonicalPaths = [ + [application, `${EXECUTABLE_NAME}.app`, 'directory'], + [contents, `${EXECUTABLE_NAME}.app/Contents`, 'directory'], + [macos, `${EXECUTABLE_NAME}.app/Contents/MacOS`, 'directory'], + [executable, `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`, 'regular file'], + [helperDirectory, `${EXECUTABLE_NAME}.app/Contents/Frameworks`, 'directory'], + ]; + for (const helperBundle of DMG_HELPER_BUNDLES) { + const helperName = helperBundle.slice(0, -'.app'.length); + const helper = join(helperDirectory, helperBundle); + const helperContents = join(helper, 'Contents'); + const helperMacos = join(helperContents, 'MacOS'); + canonicalPaths.push( + [helper, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}`, 'directory'], + [helperContents, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents`, 'directory'], + [helperMacos, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents/MacOS`, 'directory'], + [join(helperMacos, helperName), `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents/MacOS/${helperName}`, 'regular file'], + ); + } + for (const [path, description, expectedType] of canonicalPaths) { + let stats; + try { stats = await lstat(path); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical ${description}`); + throw error; + } + if (describeFileType(stats) !== expectedType) { + throw new Error(`DMG canonical ${description} must be a real ${expectedType}, found ${describeFileType(stats)}`); + } + } + let installLinkStats; + try { installLinkStats = await lstat(installLink); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical ${DMG_INSTALL_LINK} install link`); + throw error; + } + if (!installLinkStats.isSymbolicLink() || await readlink(installLink) !== '/Applications') { + throw new Error(`DMG canonical ${DMG_INSTALL_LINK} install link must be the exact /Applications symbolic link`); + } + + const topLevel = await readdir(rootPath, { withFileTypes: true }); + const topLevelCaseNames = new Set(); + for (const entry of topLevel) { + const caseName = entry.name.toLocaleLowerCase('en-US'); + if (topLevelCaseNames.has(caseName)) throw new Error(`DMG has duplicate or case-colliding top-level entry ${entry.name}`); + topLevelCaseNames.add(caseName); + } + const allowedTopLevel = new Set([`${EXECUTABLE_NAME}.app`, DMG_INSTALL_LINK]); + if (topLevel.length !== allowedTopLevel.size || topLevel.some(entry => !allowedTopLevel.has(entry.name))) { + throw new Error(`DMG contains an unclaimed or alternate top-level payload; expected only ${[...allowedTopLevel].join(' and ')}`); + } + + const applications = []; + const sameNameExecutables = []; + const applicationEntries = [{ + path: `${EXECUTABLE_NAME}.app`, + symbolicLink: false, + directory: true, + }]; + const casePaths = new Map(); + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + const stats = await lstat(entryPath); + const relativePath = displayPackagePath(rootPath, entryPath); + const casePath = relativePath.toLocaleLowerCase('en-US'); + if (casePaths.has(casePath) && casePaths.get(casePath) !== relativePath) { + throw new Error(`DMG contains duplicate or case-colliding application path ${relativePath}`); + } + casePaths.set(casePath, relativePath); + if (entry.name.toLocaleLowerCase('en-US') === EXECUTABLE_NAME) sameNameExecutables.push(entryPath); + if (stats.isSymbolicLink()) { + const frameworkRoot = darwinFrameworkRoot(relativePath); + if (!frameworkRoot) { + throw new Error(`DMG symbolic link ${relativePath} is outside canonical macOS framework internals`); + } + const target = decodeDmgSymlinkTarget(await readlink(entryPath, { encoding: 'buffer' }), relativePath); + applicationEntries.push({ path: relativePath, symbolicLink: true, directory: false, frameworkRoot, target }); + } else if (stats.isDirectory()) { + applicationEntries.push({ path: relativePath, symbolicLink: false, directory: true }); + if (entry.name.toLocaleLowerCase('en-US').endsWith('.app')) applications.push(entryPath); + await visit(entryPath); + } else if (stats.isFile()) { + applicationEntries.push({ path: relativePath, symbolicLink: false, directory: false }); + } else if (!stats.isFile()) { + throw new Error(`DMG contains special file ${relativePath}`); + } + } + }; + await visit(application); + validateDmgFrameworkSymlinks(applicationEntries); + const unexpectedApplications = applications.filter(path => ( + dirname(path) !== helperDirectory || !DMG_HELPER_BUNDLES.has(basename(path)) + )); + if (unexpectedApplications.length > 0) { + throw new Error(`DMG contains an alternate application bundle outside the canonical Electron helper layout`); + } + const helperNames = new Set(applications.map(path => basename(path))); + if (helperNames.size !== DMG_HELPER_BUNDLES.size + || [...DMG_HELPER_BUNDLES].some(name => !helperNames.has(name))) { + throw new Error('DMG canonical application is missing a required Electron helper bundle'); + } + for (const helperBundle of DMG_HELPER_BUNDLES) { + const helperName = helperBundle.slice(0, -'.app'.length); + const helperExecutable = join(helperDirectory, helperBundle, 'Contents', 'MacOS', helperName); + const helperInspection = inspectExecutableBytes(await readPrefix(helperExecutable)); + assertExecutableArchitecture(helperInspection, platform, arch, artifact); + } + if (sameNameExecutables.length !== 1 || sameNameExecutables[0] !== executable) { + throw new Error(`DMG contains a missing or alternate same-name executable outside the canonical application bundle path`); + } + const inspection = inspectExecutableBytes(await readPrefix(executable)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + return inspection; +}; + +export const inspectArtifactArchitecture = async ({ path, heldArtifact, kind, platform, arch, onDmgMounted }) => { + if (kind === 'releases') return { format: 'squirrel-releases', target: `${platform}-${arch}` }; + if (kind === 'deb') return inspectDeb(path, platform, arch); + if (kind === 'rpm') return inspectRpm(path, platform, arch); + if (kind === 'dmg') { + if (path !== undefined) throw new Error('DMG inspection rejects mutable pathnames; pass a held exact-artifact capability'); + if (onDmgMounted !== undefined && typeof onDmgMounted !== 'function') { + throw new Error('DMG mounted callback must be a function'); + } + return inspectDmg(heldArtifact, platform, arch, onDmgMounted); + } + if (kind === 'setup') { + const executable = inspectExecutableBytes(await readPrefix(path)); + if (platform !== 'win32') throw new Error(`${path} Squirrel bootstrapper is only valid for Windows targets`); + assertSupportedSquirrelBootstrap(executable, path); + return { format: 'squirrel-setup', executable }; + } + if (kind === 'msi') return inspectMachineMsi(path, platform, arch); + if (kind === 'zip' || kind === 'nupkg') { + const executable = inspectExecutableBytes(await readValidatedZipExecutable(path, kind, platform, arch)); + assertExecutableArchitecture(executable, platform, arch, path); + return { format: kind, executable }; + } + throw new Error(`Unsupported release artifact format: ${kind}`); +}; + +const argument = name => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + if (process.argv[2] === 'inspect') { + const path = argument('--path'); + const kind = argument('--kind'); + const platform = argument('--platform'); + const arch = argument('--arch'); + if (!path || !kind || !platform || !arch) throw new Error('Archive inspection requires --path, --kind, --platform, and --arch'); + if (kind === 'dmg') throw new Error('Use release-artifacts staging for private-snapshot DMG inspection'); + console.log(JSON.stringify(await inspectArtifactArchitecture({ path: resolve(path), kind, platform, arch }))); + } else { + throw new Error('Expected release-architecture.mjs inspect command'); + } +} diff --git a/apps/desktop/scripts/release-architecture.test.mjs b/apps/desktop/scripts/release-architecture.test.mjs new file mode 100644 index 000000000..fcbb28854 --- /dev/null +++ b/apps/desktop/scripts/release-architecture.test.mjs @@ -0,0 +1,562 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { link, mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + inspectDmgLayout, + inspectExtractedDmgArchitecture, + inspectExtractedMsiLayout, + inspectArtifactArchitecture, + inspectLinuxPackageLayout, + inspectMachineMsiForTest, + msiExtractorInvocationForTest, + runBoundedMsiExtractorForTest, + validateMsiListingForTest, +} from './release-architecture.mjs'; + +test('machine-wide Windows artifacts require a real MSI compound file', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + const fake = join(root, 'ProPR-Desktop-Machine-Setup.msi'); + await writeFile(fake, Buffer.alloc(4096)); + await assert.rejects( + inspectArtifactArchitecture({ path: fake, kind: 'msi', platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:MSI_HEADER', + ); +}); + +const peFixture = machine => { + const bytes = Buffer.alloc(512); + bytes[0] = 0x4d; + bytes[1] = 0x5a; + bytes.writeUInt32LE(0x80, 0x3c); + bytes.writeUInt32LE(0x00004550, 0x80); + bytes.writeUInt16LE(machine, 0x84); + return bytes; +}; + +const msiTree = async (context, machine = 0x8664, prefixed = true) => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-admin-image-')); + context.after(() => rm(root, { recursive: true, force: true })); + const application = join(root, ...(prefixed ? ['Program Files 64'] : []), 'ProPR Desktop'); + await mkdir(application, { recursive: true }); + await writeFile(join(application, 'propr-desktop.exe'), peFixture(machine)); + return root; +}; + +describe('administrative MSI payload inspection', () => { + test('uses exact fixed native extractor argv and minimal environments', () => { + assert.deepEqual( + msiExtractorInvocationForTest('win32', String.raw`D:\input\app.msi`, String.raw`D:\private`, { + msiexec: String.raw`C:\Windows\System32\msiexec.exe`, + taskkill: String.raw`C:\Windows\System32\taskkill.exe`, + }), + { + file: String.raw`C:\Windows\System32\msiexec.exe`, + args: ['/a', String.raw`D:\input\app.msi`, '/qn', '/norestart', 'REBOOT=ReallySuppress', String.raw`TARGETDIR=D:\private`], + env: { SystemRoot: String.raw`C:\Windows`, TEMP: String.raw`D:\private`, TMP: String.raw`D:\private` }, + treeKiller: String.raw`C:\Windows\System32\taskkill.exe`, + }, + ); + assert.deepEqual( + msiExtractorInvocationForTest('linux', '/input/app.msi', '/private', { msiextract: '/usr/bin/msiextract' }), + { + file: '/usr/bin/msiextract', + args: ['--directory', '/private', '/input/app.msi'], + env: { LANG: 'C', LC_ALL: 'C' }, + }, + ); + assert.throws( + () => msiExtractorInvocationForTest('darwin', '/input/app.msi', '/private', {}), + error => error?.message === 'MSI_INSPECTION_FAILED:UNSUPPORTED_HOST', + ); + }); + + test('accepts only the canonical application with the one administrative root prefix', async context => { + for (const prefixed of [false, true]) { + const root = await msiTree(context, 0x8664, prefixed); + assert.deepEqual( + await inspectExtractedMsiLayout({ root, platform: 'win32', arch: 'x64' }), + { format: 'pe', architectures: ['x64'] }, + ); + } + }); + + test('rejects path escapes and case collisions from the Linux listing before extraction', () => { + assert.doesNotThrow(() => validateMsiListingForTest(Buffer.from( + 'Program Files 64/ProPR Desktop/propr-desktop.exe\n', + ))); + for (const listing of [ + '../escape.exe\n', + '/absolute.exe\n', + 'C:/absolute.exe\n', + 'safe\\alternate.exe\n', + 'Folder/file\nfolder/FILE\n', + Buffer.from([0xff]), + ]) { + assert.throws( + () => validateMsiListingForTest(Buffer.isBuffer(listing) ? listing : Buffer.from(listing)), + error => error?.message === 'MSI_INSPECTION_FAILED:UNSAFE_TREE', + ); + } + }); + + test('uses fixed missing and duplicate canonical-app codes with bounded counts', async context => { + const missing = await mkdtemp(join(tmpdir(), 'propr-msi-admin-missing-')); + context.after(() => rm(missing, { recursive: true, force: true })); + await assert.rejects( + inspectExtractedMsiLayout({ root: missing, platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:CANONICAL_APP count=0', + ); + + const duplicate = await msiTree(context); + const alternate = join(duplicate, 'Elsewhere'); + await mkdir(alternate); + await writeFile(join(alternate, 'propr-desktop.exe'), peFixture(0x8664)); + await assert.rejects( + inspectExtractedMsiLayout({ root: duplicate, platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:CANONICAL_APP count=2', + ); + }); + + test('distinguishes authority resources, unsafe trees, and architecture mismatch without path data', async context => { + const authority = await msiTree(context); + await writeFile(join(authority, 'Program Files 64', 'ProPR Desktop', 'propr-windows-launcher.node'), 'deferred'); + await assert.rejects( + inspectExtractedMsiLayout({ root: authority, platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:AUTHORITY_RESOURCE count=1', + ); + + const unsafe = await msiTree(context); + const canonical = join(unsafe, 'Program Files 64', 'ProPR Desktop', 'propr-desktop.exe'); + await link(canonical, join(unsafe, 'Program Files 64', 'ProPR Desktop', 'held-copy')); + await assert.rejects( + inspectExtractedMsiLayout({ root: unsafe, platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:UNSAFE_TREE', + ); + + const wrongArchitecture = await msiTree(context, 0xaa64); + await assert.rejects( + inspectExtractedMsiLayout({ root: wrongArchitecture, platform: 'win32', arch: 'x64' }), + error => error?.message === 'MSI_INSPECTION_FAILED:ARCHITECTURE_MISMATCH', + ); + }); + + test('maps extractor failures to one redacted tool code', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-extractor-failure-')); + context.after(() => rm(root, { recursive: true, force: true })); + const msi = join(root, 'fixture.msi'); + const bytes = Buffer.alloc(4096); + Buffer.from('d0cf11e0a1b11ae1', 'hex').copy(bytes); + await writeFile(msi, bytes); + await assert.rejects( + inspectMachineMsiForTest(msi, 'win32', 'x64', async () => { + throw new Error(`raw failure at ${root}`); + }), + error => error?.message === 'MSI_INSPECTION_FAILED:EXTRACTOR_TOOL', + ); + }); + + test('retains compound-file, per-machine scope, and canonical PE evidence across extraction', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-evidence-')); + context.after(() => rm(root, { recursive: true, force: true })); + const msi = join(root, 'fixture.msi'); + const bytes = Buffer.alloc(4096); + Buffer.from('d0cf11e0a1b11ae1', 'hex').copy(bytes); + await writeFile(msi, bytes); + const inspection = await inspectMachineMsiForTest(msi, 'win32', 'x64', async (msiPath, extraction) => { + assert.equal(msiPath, msi); + const application = join(extraction, 'Program Files 64', 'ProPR Desktop'); + await mkdir(application, { recursive: true }); + await writeFile(join(application, 'propr-desktop.exe'), peFixture(0x8664)); + }); + assert.deepEqual(inspection, { + format: 'windows-machine-msi', + scope: 'per-machine', + executable: { format: 'pe', architectures: ['x64'] }, + }); + }); + + test('fails closed on extractor nonzero, stderr, output overflow, and timeout', { + skip: process.platform === 'win32', + }, async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-process-boundary-')); + context.after(() => rm(root, { recursive: true, force: true })); + for (const source of [ + 'process.exit(7)', + 'process.stderr.write("diagnostic")', + 'process.stdout.write("x".repeat(65))', + 'setInterval(() => {}, 1000)', + ]) { + await assert.rejects( + runBoundedMsiExtractorForTest({ + file: process.execPath, + args: ['-e', source], + cwd: root, + env: {}, + hostPlatform: 'linux', + timeoutMs: 50, + outputLimit: 64, + }), + error => error?.message === 'MSI_INSPECTION_FAILED:EXTRACTOR_TOOL', + ); + } + }); +}); + +const elfFixture = machine => { + const bytes = Buffer.alloc(64); + Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(bytes); + bytes[5] = 1; + bytes.writeUInt16LE(machine, 18); + return bytes; +}; + +const createLayout = async (root, machine = 62, packageFormat = 'deb') => { + const appDirectory = join(root, 'usr', 'lib', 'propr-desktop'); + const binDirectory = join(root, 'usr', 'bin'); + await mkdir(appDirectory, { recursive: true }); + await mkdir(binDirectory, { recursive: true }); + await writeFile(join(appDirectory, 'propr-desktop'), elfFixture(machine), { mode: 0o755 }); + await symlink('../lib/propr-desktop/propr-desktop', join(binDirectory, 'propr-desktop')); + await mkdir(join(root, 'usr', 'share', 'doc', 'propr-desktop'), { recursive: true }); + if (packageFormat === 'deb') { + const lintianDirectory = join(root, 'usr', 'share', 'lintian', 'overrides'); + await mkdir(lintianDirectory, { recursive: true }); + await writeFile(join(lintianDirectory, 'propr-desktop'), 'propr-desktop: expected-package-override\n'); + } +}; + +const fixture = async (context, machine = 62, packageFormat = 'deb') => { + const root = await mkdtemp(join(tmpdir(), 'propr-linux-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createLayout(root, machine, packageFormat); + return root; +}; + +describe('DEB and RPM executable layouts', () => { + test('accept only the canonical regular ELF payload and documented launcher symlink', async context => { + for (const [format, arch, machine] of [['DEB', 'x64', 62], ['RPM', 'arm64', 183]]) { + const packageFormat = format.toLowerCase(); + const root = await fixture(context, machine, packageFormat); + assert.deepEqual( + await inspectLinuxPackageLayout({ root, packageFormat, platform: 'linux', arch, artifact: `${format} fixture` }), + { format: 'elf', architectures: [arch] }, + ); + } + }); + + test('reject missing and extra payload names for both package formats', async context => { + const missingRoot = await fixture(context); + await rm(join(missingRoot, 'usr', 'lib', 'propr-desktop', 'propr-desktop')); + await assert.rejects( + inspectLinuxPackageLayout({ root: missingRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /only the canonical payload and launcher layout/, + ); + + const extraRoot = await fixture(context, 62, 'rpm'); + await mkdir(join(extraRoot, 'opt'), { recursive: true }); + await writeFile(join(extraRoot, 'opt', 'propr-desktop'), elfFixture(62)); + await assert.rejects( + inspectLinuxPackageLayout({ root: extraRoot, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + /opt\/propr-desktop \(regular file\)/, + ); + + const disguisedPayloadRoot = await fixture(context); + await writeFile( + join(disguisedPayloadRoot, 'usr', 'share', 'lintian', 'overrides', 'propr-desktop'), + elfFixture(62), + ); + await assert.rejects( + inspectLinuxPackageLayout({ root: disguisedPayloadRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /lintian override must not contain an extra ELF payload/, + ); + }); + + test('reject unexpected same-name file types and non-ELF or cross-architecture payloads', async context => { + const regularLauncherRoot = await fixture(context); + const regularLauncher = join(regularLauncherRoot, 'usr', 'bin', 'propr-desktop'); + await rm(regularLauncher); + await writeFile(regularLauncher, '#!/bin/sh\n'); + await assert.rejects( + inspectLinuxPackageLayout({ root: regularLauncherRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /usr\/bin\/propr-desktop \(regular file\)/, + ); + + const wrongArchitectureRoot = await fixture(context, 183, 'rpm'); + await assert.rejects( + inspectLinuxPackageLayout({ root: wrongArchitectureRoot, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + /architecture mismatch.*elf\/x64.*elf\/arm64/, + ); + + const invalidPayloadRoot = await fixture(context); + await writeFile(join(invalidPayloadRoot, 'usr', 'lib', 'propr-desktop', 'propr-desktop'), 'launcher text'); + await assert.rejects( + inspectLinuxPackageLayout({ root: invalidPayloadRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /not a recognized.*binary/, + ); + }); + + test('reject launcher escapes, cycles, and targets other than the canonical payload', async context => { + for (const [name, target, pattern] of [ + ['escape', '../../../outside-propr-desktop', /escapes the extraction root/], + ['cycle', 'propr-desktop', /symbolic-link cycle/], + ['mismatch', '../lib/propr-desktop/helper', /must resolve to usr\/lib\/propr-desktop\/propr-desktop/], + ]) { + const root = await fixture(context, 62, 'rpm'); + const launcher = join(root, 'usr', 'bin', 'propr-desktop'); + await rm(launcher); + if (name === 'mismatch') { + await writeFile(join(root, 'usr', 'lib', 'propr-desktop', 'helper'), elfFixture(62)); + } + await symlink(target, launcher); + await assert.rejects( + inspectLinuxPackageLayout({ root, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + pattern, + ); + } + }); + + test('reject special files with the executable name', { skip: process.platform === 'win32' }, async context => { + const root = await fixture(context); + const specialDirectory = join(root, 'var'); + const special = join(specialDirectory, 'propr-desktop'); + await mkdir(specialDirectory, { recursive: true }); + execFileSync('mkfifo', [special]); + await assert.rejects( + inspectLinuxPackageLayout({ root, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /var\/propr-desktop \(special file\)/, + ); + }); +}); + +describe('DMG application layout', { skip: process.platform === 'win32' }, () => { + const createDmgLayout = async root => { + const macos = join(root, 'propr-desktop.app', 'Contents', 'MacOS'); + const frameworks = join(root, 'propr-desktop.app', 'Contents', 'Frameworks'); + await mkdir(macos, { recursive: true }); + const executable = Buffer.alloc(32); + executable.writeUInt32LE(0xfeedfacf, 0); + executable.writeUInt32LE(0x0100000c, 4); + await writeFile(join(macos, 'propr-desktop'), executable, { mode: 0o755 }); + for (const name of [ + 'propr-desktop Helper', + 'propr-desktop Helper (GPU)', + 'propr-desktop Helper (Plugin)', + 'propr-desktop Helper (Renderer)', + ]) { + const helperMacos = join(frameworks, `${name}.app`, 'Contents', 'MacOS'); + await mkdir(helperMacos, { recursive: true }); + await writeFile(join(helperMacos, name), executable, { mode: 0o755 }); + } + const framework = join(frameworks, 'Electron Framework.framework'); + const frameworkVersions = join(framework, 'Versions'); + await mkdir(join(frameworkVersions, 'A', 'Resources'), { recursive: true }); + await mkdir(join(frameworkVersions, 'A', 'Libraries'), { recursive: true }); + await mkdir(join(frameworkVersions, 'A', 'Helpers'), { recursive: true }); + await writeFile(join(frameworkVersions, 'A', 'Electron Framework'), executable, { mode: 0o755 }); + await symlink('A', join(frameworkVersions, 'Current')); + await symlink('Versions/Current/Electron Framework', join(framework, 'Electron Framework')); + await symlink('Versions/Current/Resources', join(framework, 'Resources')); + await symlink('Versions/Current/Libraries', join(framework, 'Libraries')); + await symlink('Versions/Current/Helpers', join(framework, 'Helpers')); + await symlink('/Applications', join(root, 'Applications')); + }; + + test('accepts the real Forge tree with its install link and nested Electron helper bundles', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + assert.deepEqual( + await inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + { format: 'mach-o', architectures: ['arm64'] }, + ); + }); + + test('rejects a symbolic-link canonical helper bundle', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-helper-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const frameworks = join(root, 'propr-desktop.app', 'Contents', 'Frameworks'); + const helper = join(frameworks, 'propr-desktop Helper.app'); + await rename(helper, `${helper}.real`); + await symlink('propr-desktop Helper.app.real', helper); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /canonical .*Helper\.app must be a real directory, found symbolic link/, + ); + }); + + test('rejects a symbolic-link canonical helper executable ancestor', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-helper-ancestor-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const helper = join(root, 'propr-desktop.app', 'Contents', 'Frameworks', 'propr-desktop Helper (GPU).app'); + const contents = join(helper, 'Contents'); + await rename(contents, join(helper, 'RealContents')); + await symlink('RealContents', contents); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /Helper \(GPU\)\.app\/Contents must be a real directory, found symbolic link/, + ); + }); + + test('rejects every symbolic link outside canonical framework internals', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-non-framework-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const resources = join(root, 'propr-desktop.app', 'Contents', 'Resources'); + await mkdir(resources); + await symlink('../MacOS', join(resources, 'MacOS')); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /outside canonical macOS framework internals/, + ); + }); + + test('rejects escaping, cyclic, missing, and case-mismatched framework symbolic links', async context => { + for (const [name, alter, pattern] of [ + ['escape', async framework => { + await rm(join(framework, 'Resources')); + await symlink('../../../../MacOS', join(framework, 'Resources')); + }, /unsafe relative target/], + ['cycle', async framework => { + const versions = join(framework, 'Versions'); + await rm(join(versions, 'Current')); + await symlink('B', join(versions, 'Current')); + await symlink('Current', join(versions, 'B')); + }, /contains a cycle/], + ['missing', async framework => { + await rm(join(framework, 'Resources')); + await symlink('Versions/B/Resources', join(framework, 'Resources')); + }, /missing target/], + ['case-mismatched', async framework => { + await rm(join(framework, 'Resources')); + await symlink('Versions/a/Resources', join(framework, 'Resources')); + }, /missing target/], + ]) { + const root = await mkdtemp(join(tmpdir(), `propr-dmg-framework-${name}-`)); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + await alter(join(root, 'propr-desktop.app', 'Contents', 'Frameworks', 'Electron Framework.framework')); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + pattern, + name, + ); + } + }); + + test('never treats Linux 7z sanitized install-link output as native layout evidence', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-sanitized-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + await rm(join(root, 'Applications')); + await writeFile(join(root, 'Applications'), '/Applications'); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: '7z DMG fixture' }), + /exact \/Applications symbolic link/, + ); + assert.deepEqual( + await inspectExtractedDmgArchitecture({ root, platform: 'darwin', arch: 'arm64', artifact: '7z DMG fixture' }), + { format: 'mach-o', architectures: ['arm64'] }, + ); + }); + + test('rejects wrong bundles, alternate same-name executables, and canonical symlink escapes', async context => { + const wrongBundle = await mkdtemp(join(tmpdir(), 'propr-dmg-wrong-bundle-')); + context.after(() => rm(wrongBundle, { recursive: true, force: true })); + await mkdir(join(wrongBundle, 'Wrong.app', 'Contents', 'MacOS'), { recursive: true }); + await assert.rejects( + inspectDmgLayout({ root: wrongBundle, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /missing canonical propr-desktop\.app/, + ); + + const alternate = await mkdtemp(join(tmpdir(), 'propr-dmg-alternate-')); + context.after(() => rm(alternate, { recursive: true, force: true })); + await createDmgLayout(alternate); + const resources = join(alternate, 'propr-desktop.app', 'Contents', 'Resources'); + await mkdir(resources, { recursive: true }); + await writeFile(join(resources, 'propr-desktop'), 'alternate'); + await assert.rejects( + inspectDmgLayout({ root: alternate, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /alternate same-name executable/, + ); + + const escaped = await mkdtemp(join(tmpdir(), 'propr-dmg-symlink-')); + context.after(() => rm(escaped, { recursive: true, force: true })); + await mkdir(join(escaped, 'propr-desktop.app', 'Contents', 'MacOS'), { recursive: true }); + await writeFile(join(escaped, 'outside'), 'outside'); + await symlink('/Applications', join(escaped, 'Applications')); + await symlink('../../../outside', join(escaped, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop')); + await assert.rejects( + inspectDmgLayout({ root: escaped, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /must be a real regular file.*symbolic link/, + ); + }); + + test('rejects alternate top-level application bundles', async context => { + const alternateRoot = await mkdtemp(join(tmpdir(), 'propr-dmg-extra-root-')); + context.after(() => rm(alternateRoot, { recursive: true, force: true })); + await createDmgLayout(alternateRoot); + await mkdir(join(alternateRoot, 'Other.app')); + await assert.rejects( + inspectDmgLayout({ root: alternateRoot, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /unclaimed or alternate top-level payload/, + ); + }); + + test('rejects unsafe links inside the canonical application bundle', async context => { + const unsafeLink = await mkdtemp(join(tmpdir(), 'propr-dmg-unsafe-link-')); + context.after(() => rm(unsafeLink, { recursive: true, force: true })); + await createDmgLayout(unsafeLink); + await symlink('/tmp/escape', join(unsafeLink, 'propr-desktop.app', 'Contents', 'escape')); + await assert.rejects( + inspectDmgLayout({ root: unsafeLink, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /outside canonical macOS framework internals/, + ); + }); + + test('rejects non-helper nested application bundles', async context => { + const nestedApp = await mkdtemp(join(tmpdir(), 'propr-dmg-nested-app-')); + context.after(() => rm(nestedApp, { recursive: true, force: true })); + await createDmgLayout(nestedApp); + await mkdir(join(nestedApp, 'propr-desktop.app', 'Contents', 'Resources', 'Alternate.app'), { recursive: true }); + await assert.rejects( + inspectDmgLayout({ root: nestedApp, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /alternate application bundle/, + ); + }); + + test('rejects case-colliding top-level entries when the filesystem permits them', async context => { + const caseCollision = await mkdtemp(join(tmpdir(), 'propr-dmg-case-collision-')); + context.after(() => rm(caseCollision, { recursive: true, force: true })); + await createDmgLayout(caseCollision); + try { + await symlink('/Applications', join(caseCollision, 'applications')); + } catch (error) { + if (error?.code === 'EEXIST') { + context.skip('filesystem does not permit distinct case-colliding entries'); + return; + } + throw error; + } + await assert.rejects( + inspectDmgLayout({ root: caseCollision, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /duplicate or case-colliding top-level entry/, + ); + }); + + test('rejects special files inside the canonical application bundle', async context => { + const special = await mkdtemp(join(tmpdir(), 'propr-dmg-special-')); + context.after(() => rm(special, { recursive: true, force: true })); + await createDmgLayout(special); + execFileSync('mkfifo', [join(special, 'propr-desktop.app', 'Contents', 'special')]); + await assert.rejects( + inspectDmgLayout({ root: special, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /special file/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-artifacts.mjs b/apps/desktop/scripts/release-artifacts.mjs new file mode 100644 index 000000000..f7bf7781e --- /dev/null +++ b/apps/desktop/scripts/release-artifacts.mjs @@ -0,0 +1,1065 @@ +import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + NATIVE_DMG_VALIDATOR, +} from './release-architecture.mjs'; + +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const WINDOWS_SIGNER_PIN_PATTERN = /^(?:certificate|spki)-sha256:[a-f0-9]{64}$/; +const TARGETS = new Map([ + ['linux-x64', ['deb', 'rpm', 'zip']], + ['linux-arm64', ['deb', 'rpm', 'zip']], + ['darwin-x64', ['dmg', 'zip']], + ['darwin-arm64', ['dmg', 'zip']], + ['win32-x64', ['msi']], + ['win32-arm64', ['msi']], +]); +const DMG_HELPERS = [ + 'propr-desktop Helper.app', + 'propr-desktop Helper (GPU).app', + 'propr-desktop Helper (Plugin).app', + 'propr-desktop Helper (Renderer).app', +]; + +const requireExactKeys = (value, keys, label) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value); + if (actual.length !== keys.length || actual.some(key => !keys.includes(key))) { + throw new Error(`${label} has missing or unknown keys`); + } +}; + +const expectedDmgLayout = arch => ({ + topLevelApplication: 'propr-desktop.app', + installLink: { path: 'Applications', type: 'symbolic-link', target: '/Applications' }, + mainExecutable: { + path: 'propr-desktop.app/Contents/MacOS/propr-desktop', + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: DMG_HELPERS.map(bundle => ({ + bundle, + path: `propr-desktop.app/Contents/Frameworks/${bundle}/Contents/MacOS/${bundle.slice(0, -'.app'.length)}`, + format: 'mach-o', + architectures: [arch], + })), +}); + +const validateExecutableLayoutEvidence = (value, expected, label, { helper = false } = {}) => { + requireExactKeys(value, helper + ? ['bundle', 'path', 'format', 'architectures'] + : ['path', 'format', 'architectures'], label); + if ((helper && value.bundle !== expected.bundle) + || value.path !== expected.path + || value.format !== 'mach-o' + || !Array.isArray(value.architectures) + || value.architectures.length !== 1 + || value.architectures[0] !== expected.architectures[0]) { + throw new Error(`${label} does not match the canonical native Mach-O layout`); + } +}; + +const validateDmgLayoutEvidence = (value, arch, label) => { + requireExactKeys(value, ['topLevelApplication', 'installLink', 'mainExecutable', 'helperExecutables'], label); + const expected = expectedDmgLayout(arch); + if (value.topLevelApplication !== expected.topLevelApplication) { + throw new Error(`${label} has a noncanonical top-level application`); + } + requireExactKeys(value.installLink, ['path', 'type', 'target'], `${label}.installLink`); + if (value.installLink.path !== expected.installLink.path + || value.installLink.type !== expected.installLink.type + || value.installLink.target !== expected.installLink.target) { + throw new Error(`${label} does not claim the exact native /Applications symbolic link`); + } + validateExecutableLayoutEvidence(value.mainExecutable, expected.mainExecutable, `${label}.mainExecutable`); + if (!Array.isArray(value.helperExecutables) || value.helperExecutables.length !== expected.helperExecutables.length) { + throw new Error(`${label}.helperExecutables must contain the exact canonical helper set`); + } + value.helperExecutables.forEach((helper, index) => { + validateExecutableLayoutEvidence(helper, expected.helperExecutables[index], `${label}.helperExecutables[${index}]`, { helper: true }); + }); +}; + +const createNativeDmgEvidence = ({ target, version, arch, artifact, nativeValidation }) => { + requireExactKeys( + nativeValidation, + ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod', 'layout'], + 'Native DMG validation marker', + ); + for (const key of ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod']) { + if (nativeValidation[key] !== NATIVE_DMG_VALIDATOR[key]) { + throw new Error(`Native DMG validation marker has an unsupported ${key}`); + } + } + validateDmgLayoutEvidence(nativeValidation.layout, arch, 'Native DMG validation marker layout'); + return { + schemaVersion: NATIVE_DMG_VALIDATOR.schemaVersion, + tool: NATIVE_DMG_VALIDATOR.tool, + toolVersion: NATIVE_DMG_VALIDATOR.toolVersion, + nativePlatform: NATIVE_DMG_VALIDATOR.nativePlatform, + mountMethod: NATIVE_DMG_VALIDATOR.mountMethod, + validatedNatively: true, + target, + version, + architecture: arch, + artifact: { + fileName: artifact.fileName, + size: artifact.size, + sha256: artifact.sha256, + }, + layout: nativeValidation.layout, + }; +}; + +const validateNativeDmgEvidence = (value, { target, version, arch, artifact }) => { + const label = `Native DMG evidence for ${target}`; + requireExactKeys(value, [ + 'schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod', 'validatedNatively', + 'target', 'version', 'architecture', 'artifact', 'layout', + ], label); + for (const key of ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod']) { + if (value[key] !== NATIVE_DMG_VALIDATOR[key]) throw new Error(`${label} has an unsupported ${key}`); + } + if (value.validatedNatively !== true) throw new Error(`${label} lacks the native-validation marker`); + if (typeof value.target !== 'string' || value.target.length > 32 || value.target !== target + || typeof value.version !== 'string' || value.version.length > 64 || value.version !== version + || typeof value.architecture !== 'string' || value.architecture.length > 16 || value.architecture !== arch) { + throw new Error(`${label} has mixed, stale, or cross-target metadata`); + } + requireExactKeys(value.artifact, ['fileName', 'size', 'sha256'], `${label}.artifact`); + if (typeof value.artifact.fileName !== 'string' || value.artifact.fileName.length > 255 + || value.artifact.fileName !== artifact.fileName + || !Number.isSafeInteger(value.artifact.size) || value.artifact.size <= 0 || value.artifact.size !== artifact.size + || typeof value.artifact.sha256 !== 'string' || !SHA256_PATTERN.test(value.artifact.sha256) + || value.artifact.sha256 !== artifact.sha256) { + throw new Error(`${label} does not bind the exact canonical DMG bytes`); + } + validateDmgLayoutEvidence(value.layout, arch, `${label}.layout`); +}; + +const recursiveFiles = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await recursiveFiles(path)); + else if (entry.isFile()) files.push(path); + } + return files; +}; + +const checksumBytes = value => createHash('sha256').update(value).digest('hex'); +const checksum = async path => checksumBytes(await readFile(path)); + +const dmgFileState = stats => ({ + device: stats.dev, + inode: stats.ino, + mode: stats.mode, + links: stats.nlink, + size: stats.size, +}); + +const sameDmgFileState = (left, right) => Object.keys(left).every(key => left[key] === right[key]); + +const checksumDmgHandle = async (handle, size) => { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < size) { + const length = Math.min(buffer.length, size - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('Staged DMG changed while its exact bytes were captured'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return hash.digest('hex'); +}; + +const captureHeldDmgBytes = async handle => { + const before = await handle.stat({ bigint: true }); + if (!before.isFile()) { + throw new Error('Staged DMG must be a real regular file, not a symbolic link or special file'); + } + if (before.size <= 0n || before.size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('Staged DMG size must be a positive safe integer'); + } + const size = Number(before.size); + const sha256 = await checksumDmgHandle(handle, size); + const after = await handle.stat({ bigint: true }); + if (!after.isFile() || !sameDmgFileState(dmgFileState(before), dmgFileState(after))) { + throw new Error('Staged DMG identity or content changed while its exact bytes were captured'); + } + return { state: dmgFileState(after), size, sha256 }; +}; + +const assertDmgPathNamesHeldFile = async (path, held) => { + const pathStats = await lstat(path, { bigint: true }); + if (!pathStats.isFile() || pathStats.isSymbolicLink() + || !sameDmgFileState(dmgFileState(pathStats), held.state)) { + throw new Error('Staged DMG pathname no longer names the held exact artifact'); + } +}; + +const isCurrentPosixOwner = stats => process.platform !== 'win32' + && typeof process.getuid === 'function' + && stats.uid === BigInt(process.getuid()); + +const isScopedWindowsDmgFixtureAuthority = authority => process.platform === 'win32' + && authority?.schemaVersion === 1 + && authority?.platform === 'win32' + && authority?.scope === 'release-test-private-dmg' + && Object.keys(authority).length === 3; + +const lstatPrivateDmgPath = async (path, label) => { + try { + return await lstat(path, { bigint: true }); + } catch { + throw new Error(`${label} could not be validated`); + } +}; + +const privateDmgAuthorityError = code => new Error(`Private DMG authority rejected [dmg-private:${code}]`); + +const assertPrivateDmgDirectory = async (path, publicOutputDirectory, fixtureAuthority) => { + const relationship = relative(resolve(publicOutputDirectory), resolve(path)); + if (relationship === '' || (!isAbsolute(relationship) && relationship !== '..' && !relationship.startsWith(`..${sep}`))) { + throw new Error('Private DMG snapshot directory must be outside the public output path'); + } + const stats = await lstatPrivateDmgPath(path, 'Private DMG snapshot directory'); + if (stats.isSymbolicLink()) throw privateDmgAuthorityError('directory-symlink'); + if (!stats.isDirectory()) throw privateDmgAuthorityError('directory-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(stats)) { + throw privateDmgAuthorityError('directory-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (stats.mode & 0o777n) !== 0o700n) { + throw privateDmgAuthorityError('directory-mode'); + } +}; + +const assertPrivateDmgHeldAuthority = async (handle, fixtureAuthority) => { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile()) throw privateDmgAuthorityError('file-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(stats)) { + throw privateDmgAuthorityError('file-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (stats.mode & 0o777n) !== 0o600n) { + throw privateDmgAuthorityError('file-mode'); + } + if (stats.nlink !== 1n) throw privateDmgAuthorityError('file-link'); + return stats; +}; + +const assertPrivateDmgPathNamesHeldFile = async (path, held, fixtureAuthority) => { + const pathStats = await lstatPrivateDmgPath(path, 'Private DMG snapshot pathname'); + if (pathStats.isSymbolicLink()) throw privateDmgAuthorityError('file-symlink'); + if (!pathStats.isFile()) throw privateDmgAuthorityError('file-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(pathStats)) { + throw privateDmgAuthorityError('file-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (pathStats.mode & 0o777n) !== 0o600n) { + throw privateDmgAuthorityError('file-mode'); + } + if (pathStats.nlink !== 1n) throw privateDmgAuthorityError('file-link'); + if (!sameDmgFileState(dmgFileState(pathStats), held.state)) throw privateDmgAuthorityError('file-identity'); +}; + +const assertStableDmgBytes = (before, after) => { + if (!sameDmgFileState(before.state, after.state) + || before.size !== after.size + || before.sha256 !== after.sha256) { + throw new Error('Staged DMG identity or content changed during native validation'); + } +}; + +const assertSameDmgContent = (expected, actual) => { + if (expected.size !== actual.size || expected.sha256 !== actual.sha256) { + throw new Error('Copied DMG bytes do not match the held validated artifact'); + } +}; + +const openHeldDmg = async (path, { privateSnapshot = false, fixtureAuthority } = {}) => { + let handle; + try { + handle = await open( + path, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | (privateSnapshot ? 0 : fsConstants.O_NONBLOCK), + ); + } catch (error) { + if (error?.code === 'ELOOP') { + throw new Error('Staged DMG must be a real regular file, not a symbolic link or special file'); + } + throw error; + } + try { + const captured = await captureHeldDmgBytes(handle); + if (privateSnapshot) { + await assertPrivateDmgHeldAuthority(handle, fixtureAuthority); + await assertPrivateDmgPathNamesHeldFile(path, captured, fixtureAuthority); + } + else await assertDmgPathNamesHeldFile(path, captured); + return { handle, captured }; + } catch (error) { + await handle.close(); + throw error; + } +}; + +const copyHeldDmgToExclusivePath = async (handle, size, path) => { + const output = await open( + path, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < size) { + const length = Math.min(buffer.length, size - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('Held DMG changed while it was copied for publication'); + let written = 0; + while (written < bytesRead) { + const result = await output.write(buffer, written, bytesRead - written, position + written); + if (result.bytesWritten === 0) throw new Error('Could not copy held DMG for publication'); + written += result.bytesWritten; + } + position += bytesRead; + } + await output.sync(); + } finally { + await output.close(); + } +}; + +const createPrivateDmgSnapshot = async ({ sourcePath, publicOutputDirectory, description, fixtureAuthority }) => { + const source = await openHeldDmg(sourcePath); + let privateDirectory; + let snapshot; + try { + privateDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-snapshot-')); + await assertPrivateDmgDirectory(privateDirectory, publicOutputDirectory, fixtureAuthority); + const privatePath = join(privateDirectory, `${randomUUID()}.dmg`); + await copyHeldDmgToExclusivePath(source.handle, source.captured.size, privatePath); + const sourceAfterCopy = await captureHeldDmgBytes(source.handle); + assertStableDmgBytes(source.captured, sourceAfterCopy); + snapshot = await openHeldDmg(privatePath, { privateSnapshot: true, fixtureAuthority }); + assertSameDmgContent(sourceAfterCopy, snapshot.captured); + return { + privateDirectory, + privatePath, + held: snapshot, + heldArtifact: createHeldDmgArtifact(snapshot.handle, description, privatePath), + }; + } catch (error) { + if (snapshot) await snapshot.handle.close(); + if (privateDirectory) { + try { + await rm(privateDirectory, { recursive: true, force: true }); + } catch { + throw new Error('Private DMG snapshot cleanup failed'); + } + } + if (privateDirectory && error?.message?.includes(privateDirectory)) { + throw new Error('Private DMG snapshot creation or validation failed'); + } + throw error; + } finally { + await source.handle.close(); + } +}; + +const closePrivateDmgSnapshot = async snapshot => { + if (!snapshot) return; + try { + await snapshot.held.handle.close(); + } finally { + try { + await rm(snapshot.privateDirectory, { recursive: true, force: true }); + } catch { + throw new Error('Private DMG snapshot cleanup failed'); + } + } +}; + +const publishHeldDmg = async ({ handle, captured, destination }) => { + const temporary = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`); + try { + await copyHeldDmgToExclusivePath(handle, captured.size, temporary); + const afterCopy = await captureHeldDmgBytes(handle); + assertStableDmgBytes(captured, afterCopy); + const copied = await openHeldDmg(temporary); + let copiedCapture; + try { + assertSameDmgContent(afterCopy, copied.captured); + copiedCapture = copied.captured; + } finally { + await copied.handle.close(); + } + await rename(temporary, destination); + const published = await openHeldDmg(destination); + try { + assertStableDmgBytes(copiedCapture, published.captured); + assertSameDmgContent(afterCopy, published.captured); + } finally { + await published.handle.close(); + } + return afterCopy; + } finally { + await rm(temporary, { force: true }); + } +}; + +const parseWindowsSignerPins = value => { + if (!value) throw new Error('PROPR_DESKTOP_WINDOWS_SIGNER_PINS is required'); + const pins = value.split(','); + if (pins.length > 16 || pins.some(pin => !WINDOWS_SIGNER_PIN_PATTERN.test(pin)) + || new Set(pins).size !== pins.length || pins.join(',') !== [...pins].sort().join(',')) { + throw new Error('PROPR_DESKTOP_WINDOWS_SIGNER_PINS must be a sorted, unique canonical SHA-256 fingerprint allowlist'); + } + return pins; +}; + +const windowsSignerMatchesPins = (signer, pins) => pins.some(pin => ( + pin === `certificate-sha256:${signer.certificateSha256}` + || pin === `spki-sha256:${signer.spkiSha256}` +)); + +const artifactKind = (path, platform) => { + const name = basename(path); + if (platform === 'win32') { + if (/-Machine-Setup\.msi$/i.test(name)) return 'msi'; + return undefined; + } + const extension = name.split('.').at(-1)?.toLowerCase(); + return ['deb', 'rpm', 'zip', 'dmg'].includes(extension) ? extension : undefined; +}; + +const releaseFileName = (version, platform, arch, kind) => { + const platformName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + return kind === 'msi' + ? `ProPR-Desktop-${version}-${platformName}-${arch}-Machine-Setup.msi` + : `ProPR-Desktop-${version}-${platformName}-${arch}.${kind}`; +}; + +const validateCanonicalArtifactMatrix = (artifacts, version, label) => { + const expected = new Map(); + for (const [target, targetKinds] of TARGETS) { + const [platform, arch] = target.split('-'); + for (const kind of targetKinds) { + expected.set(releaseFileName(version, platform, arch, kind), { platform, arch, kind }); + } + } + if (!Array.isArray(artifacts) || artifacts.length !== expected.size) { + throw new Error(`${label} must contain the exact ${expected.size}-artifact matrix`); + } + const seen = new Set(); + const seenCaseFolded = new Set(); + for (const artifact of artifacts) { + const canonical = artifact && typeof artifact === 'object' && artifact !== null + ? releaseFileName(version, artifact.platform, artifact.arch, artifact.kind) + : undefined; + const expectedArtifact = typeof artifact?.fileName === 'string' ? expected.get(artifact.fileName) : undefined; + const folded = typeof artifact?.fileName === 'string' ? artifact.fileName.toLowerCase() : undefined; + if (!expectedArtifact + || artifact.fileName !== canonical + || expectedArtifact.platform !== artifact.platform + || expectedArtifact.arch !== artifact.arch + || expectedArtifact.kind !== artifact.kind + || seen.has(artifact.fileName) + || seenCaseFolded.has(folded)) { + throw new Error(`${label} contains an invalid, duplicate, or noncanonical artifact name`); + } + seen.add(artifact.fileName); + seenCaseFolded.add(folded); + } + if (seen.size !== expected.size || [...expected.keys()].some(fileName => !seen.has(fileName))) { + throw new Error(`${label} must contain the exact ${expected.size}-artifact matrix`); + } +}; + +const readNativeSigner = (platform, env) => { + if (platform === 'linux') return undefined; + const type = env.PROPR_DESKTOP_ACTUAL_SIGNER_TYPE?.trim(); + const identity = env.PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY?.trim(); + const designatedRequirement = env.PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT?.trim(); + const certificateSha256 = env.PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256?.trim(); + const spkiSha256 = env.PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256?.trim(); + if (!type && !identity && !designatedRequirement && !certificateSha256 && !spkiSha256) return undefined; + const expectedType = platform === 'darwin' ? 'apple-team-id' : 'authenticode-subject'; + if (type !== expectedType || !identity + || (platform === 'darwin' && (!designatedRequirement || certificateSha256 || spkiSha256)) + || (platform === 'win32' && (designatedRequirement + || !SHA256_PATTERN.test(certificateSha256 ?? '') + || !SHA256_PATTERN.test(spkiSha256 ?? '')))) { + throw new Error(`Native signer evidence is incomplete or invalid for ${platform}`); + } + return { + type, + identity, + ...(platform === 'darwin' + ? { designatedRequirement } + : { certificateSha256, spkiSha256 }), + }; +}; + +export const stageArtifacts = async ({ + makeDirectory, + outputDirectory, + platform, + arch, + version, + env = process.env, + inspectArchitecture = inspectArtifactArchitecture, + privateDmgFixtureAuthority, +}) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const target = `${platform}-${arch}`; + const expectedKinds = TARGETS.get(target); + if (!expectedKinds) throw new Error(`Unsupported desktop release target: ${target}`); + if (platform === 'darwin' && process.platform === 'win32' + && (inspectArchitecture === inspectArtifactArchitecture + || !isScopedWindowsDmgFixtureAuthority(privateDmgFixtureAuthority))) { + throw new Error('Windows-hosted DMG fixtures require an explicit scoped fixture authority and injected inspector'); + } + + const candidates = await recursiveFiles(makeDirectory); + const byKind = new Map(); + for (const path of candidates) { + const kind = artifactKind(path, platform); + if (!kind || !expectedKinds.includes(kind)) continue; + if (byKind.has(kind)) throw new Error(`Found multiple ${kind} artifacts for ${target}`); + byKind.set(kind, path); + } + const missing = expectedKinds.filter(kind => !byKind.has(kind)); + if (missing.length) throw new Error(`Missing ${missing.join(', ')} artifact(s) for ${target}`); + + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + const artifacts = []; + for (const kind of expectedKinds) { + const fileName = releaseFileName(version, platform, arch, kind); + const destination = join(outputDirectory, fileName); + if (kind === 'dmg') { + let snapshot; + try { + snapshot = await createPrivateDmgSnapshot({ + sourcePath: byKind.get(kind), + publicOutputDirectory: outputDirectory, + description: fileName, + fixtureAuthority: privateDmgFixtureAuthority, + }); + const inspection = await inspectArchitecture({ heldArtifact: snapshot.heldArtifact, kind, platform, arch }); + // Authority reasons intentionally precede byte/identity stability after + // any native validation hook. The same descriptor remains authoritative. + await assertPrivateDmgHeldAuthority(snapshot.held.handle, privateDmgFixtureAuthority); + await assertPrivateDmgPathNamesHeldFile( + snapshot.privatePath, + snapshot.held.captured, + privateDmgFixtureAuthority, + ); + const afterInspection = await captureHeldDmgBytes(snapshot.held.handle); + assertStableDmgBytes(snapshot.held.captured, afterInspection); + await assertPrivateDmgPathNamesHeldFile(snapshot.privatePath, afterInspection, privateDmgFixtureAuthority); + const details = await publishHeldDmg({ + handle: snapshot.held.handle, + captured: afterInspection, + destination, + }); + const artifact = { + platform, + arch, + kind, + fileName, + size: details.size, + sha256: details.sha256, + architectureEvidence: { format: inspection.format, executable: inspection.executable }, + }; + artifact.nativeDmgValidationEvidence = createNativeDmgEvidence({ + target, + version, + arch, + artifact, + nativeValidation: inspection.nativeValidation, + }); + artifacts.push(artifact); + } finally { + await closePrivateDmgSnapshot(snapshot); + } + continue; + } + await copyFile(byKind.get(kind), destination); + const inspection = await inspectArchitecture({ + path: destination, + kind, + platform, + arch, + }); + const details = await stat(destination); + const artifact = { + platform, + arch, + kind, + fileName, + size: details.size, + sha256: await checksum(destination), + architectureEvidence: inspection, + }; + artifacts.push(artifact); + } + const nativeSigner = readNativeSigner(platform, env); + if (env.PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS === '1' && platform !== 'linux' && !nativeSigner) { + throw new Error(`Production ${platform} artifacts require verified native signer evidence`); + } + if (env.PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS === '1' && platform === 'win32') { + const pins = parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS); + if (!windowsSignerMatchesPins(nativeSigner, pins)) { + throw new Error('Production Windows signer fingerprint is not in the configured allowlist'); + } + } + const fragment = { + schemaVersion: 2, + version, + tag: `desktop-v${version}`, + target, + artifacts, + nativeSigner, + ...(platform === 'win32' ? { installedApplicationValidated: env.PROPR_DESKTOP_WINDOWS_INSTALLED_APP === '1' } : {}), + }; + await writeFile(join(outputDirectory, 'release-fragment.json'), `${JSON.stringify(fragment, null, 2)}\n`); + return fragment; +}; + +export const probePrivateDmgSnapshotIsolation = async ({ makeDirectory, arch, version, env = process.env }) => { + if (process.platform !== 'darwin') { + throw new Error('Private-snapshot DMG isolation probe is available only on native macOS'); + } + const dmgPaths = (await recursiveFiles(makeDirectory)).filter(path => artifactKind(path, 'darwin') === 'dmg'); + if (dmgPaths.length !== 1) throw new Error('Private-snapshot DMG isolation probe requires exactly one source DMG'); + const sourcePath = dmgPaths[0]; + const expected = await openHeldDmg(sourcePath); + const expectedSize = expected.captured.size; + const expectedSha256 = expected.captured.sha256; + await expected.handle.close(); + const outputDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-isolation-output-')); + const destination = join(outputDirectory, releaseFileName(version, 'darwin', arch, 'dmg')); + const displaced = `${sourcePath}.private-snapshot-isolation-held`; + let sourceDisplaced = false; + try { + const fragment = await stageArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch, + version, + env, + inspectArchitecture: arguments_ => inspectArtifactArchitecture({ + ...arguments_, + ...(arguments_.kind === 'dmg' ? { + onDmgMounted: async () => { + await rename(sourcePath, displaced); + sourceDisplaced = true; + await writeFile(sourcePath, 'hostile replacement of the original pathname'); + await writeFile(destination, 'hostile replacement of the public pathname'); + }, + } : {}), + }), + }); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + if (!artifact + || artifact.size !== expectedSize + || artifact.sha256 !== expectedSha256 + || artifact.nativeDmgValidationEvidence?.artifact?.sha256 !== expectedSha256 + || await checksum(destination) !== expectedSha256) { + throw new Error('Private-snapshot isolation probe did not keep mounted, evidenced, and published DMG bytes bound to held A'); + } + return { size: expectedSize, sha256: expectedSha256 }; + } finally { + if (sourceDisplaced) { + await rm(sourcePath, { force: true }); + await rename(displaced, sourcePath); + } + await rm(outputDirectory, { recursive: true, force: true }); + } +}; + +const readFragments = async inputDirectory => { + const paths = (await recursiveFiles(inputDirectory)).filter(path => basename(path) === 'release-fragment.json'); + return Promise.all(paths.map(async path => ({ path, value: JSON.parse(await readFile(path, 'utf8')) }))); +}; + +const parseHttpsUrl = (value, name, { allowQuery = true } = {}) => { + let url; + try { url = new URL(value); } catch { throw new Error(`${name} must be an absolute HTTPS URL`); } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || (!allowQuery && url.search)) { + throw new Error(`${name} must be HTTPS and contain no credentials, fragment${allowQuery ? '' : ', or query'}`); + } + return url.toString(); +}; + +export const finalizeArtifacts = async ({ + inputDirectory, + outputDirectory, + version, + inspectArchitecture = inspectArtifactArchitecture, +}) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const fragments = await readFragments(inputDirectory); + if (fragments.length !== TARGETS.size) { + throw new Error(`Expected ${TARGETS.size} release fragments, found ${fragments.length}`); + } + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + + const seenTargets = new Set(); + const seenNames = new Set(); + const artifacts = []; + const nativeSigners = {}; + for (const { path, value } of fragments) { + if (value.schemaVersion !== 2 || value.version !== version || value.tag !== `desktop-v${version}`) { + throw new Error(`Release fragment metadata does not match desktop-v${version}: ${path}`); + } + const expectedKinds = TARGETS.get(value.target); + if (!expectedKinds || seenTargets.has(value.target)) throw new Error(`Duplicate or invalid target ${value.target}`); + seenTargets.add(value.target); + if (!Array.isArray(value.artifacts) || value.artifacts.length !== expectedKinds.length) { + throw new Error(`Release fragment ${value.target} has an unexpected artifact count`); + } + const [targetPlatform, targetArch] = value.target.split('-'); + if (targetPlatform === 'win32' && value.installedApplicationValidated !== true) { + throw new Error(`Release fragment ${value.target} skipped the installed ordinary-user application gate`); + } + if (targetPlatform !== 'win32' && value.installedApplicationValidated !== undefined) { + throw new Error(`Release fragment ${value.target} has foreign installed application evidence`); + } + const expectedSigner = readNativeSigner(targetPlatform, { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: value.nativeSigner?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: value.nativeSigner?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: value.nativeSigner?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: value.nativeSigner?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: value.nativeSigner?.spkiSha256, + }); + if (expectedSigner) nativeSigners[value.target] = expectedSigner; + for (const artifact of value.artifacts) { + const expectedFileName = releaseFileName(version, targetPlatform, targetArch, artifact.kind); + if ( + !expectedKinds.includes(artifact.kind) + || artifact.platform !== targetPlatform + || artifact.arch !== targetArch + || artifact.fileName !== expectedFileName + || basename(artifact.fileName) !== artifact.fileName + || !Number.isSafeInteger(artifact.size) + || artifact.size <= 0 + || !SHA256_PATTERN.test(artifact.sha256) + || typeof artifact.architectureEvidence !== 'object' + || artifact.architectureEvidence === null + || seenNames.has(artifact.fileName) + ) { + throw new Error(`Release fragment ${value.target} has an invalid or duplicate artifact`); + } + if (artifact.kind === 'dmg') { + validateNativeDmgEvidence(artifact.nativeDmgValidationEvidence, { + target: value.target, + version, + arch: targetArch, + artifact, + }); + } else if (artifact.nativeDmgValidationEvidence !== undefined) { + throw new Error(`Release fragment ${value.target} attaches native DMG evidence to a non-DMG artifact`); + } + const source = join(dirname(path), artifact.fileName); + let inspection; + if (artifact.kind === 'dmg') { + const held = await openHeldDmg(source); + try { + if (held.captured.sha256 !== artifact.sha256 || held.captured.size !== artifact.size) { + throw new Error(`Release artifact integrity does not match its fragment: ${artifact.fileName}`); + } + inspection = await inspectArchitecture({ + heldArtifact: createHeldDmgArtifact(held.handle, artifact.fileName), + kind: artifact.kind, + platform: targetPlatform, + arch: targetArch, + }); + const afterInspection = await captureHeldDmgBytes(held.handle); + assertStableDmgBytes(held.captured, afterInspection); + await assertDmgPathNamesHeldFile(source, afterInspection); + await publishHeldDmg({ + handle: held.handle, + captured: afterInspection, + destination: join(outputDirectory, artifact.fileName), + }); + } finally { + await held.handle.close(); + } + } else { + if (await checksum(source) !== artifact.sha256 || (await stat(source)).size !== artifact.size) { + throw new Error(`Release artifact integrity does not match its fragment: ${artifact.fileName}`); + } + inspection = await inspectArchitecture({ + path: source, + kind: artifact.kind, + platform: targetPlatform, + arch: targetArch, + }); + } + const architectureEvidence = artifact.kind === 'dmg' + ? { format: inspection.format, executable: inspection.executable } + : inspection; + if (JSON.stringify(architectureEvidence) !== JSON.stringify(artifact.architectureEvidence)) { + throw new Error(`Release artifact architecture evidence does not match its fragment: ${artifact.fileName}`); + } + seenNames.add(artifact.fileName); + if (artifact.kind !== 'dmg') await copyFile(source, join(outputDirectory, artifact.fileName)); + artifacts.push(artifact); + } + } + for (const target of TARGETS.keys()) { + if (!seenTargets.has(target)) throw new Error(`Missing release target ${target}`); + } + const windowsSigners = ['win32-x64', 'win32-arm64'].map(target => nativeSigners[target]).filter(Boolean); + if (windowsSigners.length === 2 && JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + throw new Error('Windows release targets contain mixed native signer evidence'); + } + validateCanonicalArtifactMatrix(artifacts, version, 'Final desktop release'); + + artifacts.sort((left, right) => left.fileName.localeCompare(right.fileName)); + const publishedAt = process.env.SOURCE_DATE_EPOCH + ? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000).toISOString() + : new Date().toISOString(); + const manifest = { + schemaVersion: 2, + channel: 'stable', + version, + tag: `desktop-v${version}`, + publishedAt, + feeds: {}, + nativeSigners, + artifacts, + }; + await writeFile(join(outputDirectory, 'desktop-release.json'), `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile( + join(outputDirectory, 'SHA256SUMS'), + `${artifacts.map(artifact => `${artifact.sha256} ${artifact.fileName}`).join('\n')}\n`, + ); + return manifest; +}; + +const configuredFeedDefinitions = [ + ['darwin-x64', 'PROPR_DESKTOP_DARWIN_X64_FEED_URL'], + ['darwin-arm64', 'PROPR_DESKTOP_DARWIN_ARM64_FEED_URL'], +]; + +const exactFeedUrl = (target, configured, name) => { + const parsed = new URL(parseHttpsUrl(configured, name)); + const feedName = 'RELEASES.json'; + if (parsed.pathname.endsWith('/')) { + parsed.pathname += feedName; + } else if (!parsed.pathname.endsWith(`/${feedName}`)) { + parsed.pathname += `/${feedName}`; + } + return parsed.toString(); +}; + +const createSignedFeeds = async (manifest, outputDirectory, env) => { + const feeds = {}; + const feedFiles = []; + for (const [target, variable] of configuredFeedDefinitions) { + const feedUrl = exactFeedUrl(target, env[variable].trim(), variable); + const updateKind = 'zip'; + const artifact = manifest.artifacts.find(candidate => `${candidate.platform}-${candidate.arch}` === target && candidate.kind === updateKind); + const signer = manifest.nativeSigners[target]; + if (!artifact || !signer) throw new Error(`Signed update metadata lacks artifact or native signer evidence for ${target}`); + const artifactUrl = new URL(artifact.fileName, feedUrl).toString(); + const feedBytes = Buffer.from(`${JSON.stringify({ + url: artifactUrl, + name: manifest.version, + notes: `ProPR Desktop ${manifest.version}`, + pub_date: manifest.publishedAt, + }, null, 2)}\n`); + const platformName = 'macos'; + const feedSuffix = 'RELEASES.json'; + const feedFileName = `ProPR-Desktop-${manifest.version}-${platformName}-${target.split('-')[1]}-${feedSuffix}`; + await writeFile(join(outputDirectory, feedFileName), feedBytes); + feedFiles.push({ fileName: feedFileName, size: feedBytes.length, sha256: checksumBytes(feedBytes) }); + feeds[target] = { + target, + version: manifest.version, + feed: { url: feedUrl, size: feedBytes.length, sha256: checksumBytes(feedBytes) }, + artifact: { + url: artifactUrl, + fileName: artifact.fileName, + kind: updateKind, + size: artifact.size, + sha256: artifact.sha256, + }, + signer, + }; + } + return { feeds, feedFiles }; +}; + +export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, version, env = process.env }) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const unsignedManifest = JSON.parse(await readFile(join(inputDirectory, 'desktop-release.json'), 'utf8')); + if ( + unsignedManifest.schemaVersion !== 2 + || unsignedManifest.version !== version + || unsignedManifest.tag !== `desktop-v${version}` + || Object.keys(unsignedManifest.feeds ?? {}).length !== 0 + || !Array.isArray(unsignedManifest.artifacts) + ) { + throw new Error('Unsigned release metadata is invalid'); + } + validateCanonicalArtifactMatrix(unsignedManifest.artifacts, version, 'Unsigned release metadata'); + for (const artifact of unsignedManifest.artifacts) { + const path = join(inputDirectory, artifact.fileName); + if (basename(artifact.fileName) !== artifact.fileName + || await checksum(path) !== artifact.sha256 + || (await stat(path)).size !== artifact.size) { + throw new Error(`Unsigned release artifact integrity is invalid: ${artifact.fileName}`); + } + } + + const configurationNames = [ + 'PROPR_DESKTOP_UPDATE_PRIVATE_KEY', + 'PROPR_DESKTOP_UPDATE_PUBLIC_KEY', + 'PROPR_DESKTOP_UPDATE_MANIFEST_URL', + 'PROPR_DESKTOP_MAC_TEAM_ID', + 'PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY', + 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', + ...configuredFeedDefinitions.map(([, name]) => name), + ]; + const present = configurationNames.filter(name => env[name]?.trim()); + if (present.length !== configurationNames.length) { + throw new Error(`Trusted update signing configuration is incomplete; missing ${configurationNames.filter(name => !env[name]?.trim()).join(', ')}`); + } + const windowsSignerPins = parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS); + + for (const target of ['darwin-x64', 'darwin-arm64']) { + const signer = readNativeSigner('darwin', { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: unsignedManifest.nativeSigners?.[target]?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: unsignedManifest.nativeSigners?.[target]?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: unsignedManifest.nativeSigners?.[target]?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: unsignedManifest.nativeSigners?.[target]?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: unsignedManifest.nativeSigners?.[target]?.spkiSha256, + }); + if (!signer || signer.identity !== env.PROPR_DESKTOP_MAC_TEAM_ID.trim()) { + throw new Error(`Actual native signer mismatch for ${target}`); + } + } + const windowsSigners = []; + for (const target of ['win32-x64', 'win32-arm64']) { + const signer = readNativeSigner('win32', { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: unsignedManifest.nativeSigners?.[target]?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: unsignedManifest.nativeSigners?.[target]?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: unsignedManifest.nativeSigners?.[target]?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: unsignedManifest.nativeSigners?.[target]?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: unsignedManifest.nativeSigners?.[target]?.spkiSha256, + }); + if (!signer || signer.identity !== env.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY.trim() + || !windowsSignerMatchesPins(signer, windowsSignerPins)) { + throw new Error(`Actual native signer mismatch for ${target}`); + } + windowsSigners.push(signer); + } + if (JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + throw new Error('Windows release targets contain mixed native signer evidence'); + } + + const manifestUrl = parseHttpsUrl( + env.PROPR_DESKTOP_UPDATE_MANIFEST_URL.trim(), + 'PROPR_DESKTOP_UPDATE_MANIFEST_URL', + { allowQuery: false }, + ); + const privateKey = createPrivateKey({ + key: Buffer.from(env.PROPR_DESKTOP_UPDATE_PRIVATE_KEY.trim(), 'base64'), + format: 'der', + type: 'pkcs8', + }); + if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('Update signing private key must be Ed25519'); + const actualPublicKey = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).toString('base64'); + if (actualPublicKey !== env.PROPR_DESKTOP_UPDATE_PUBLIC_KEY.trim()) { + throw new Error('Update signing private and public keys do not match'); + } + + await rm(outputDirectory, { recursive: true, force: true }); + await cp(inputDirectory, outputDirectory, { recursive: true }); + const { feeds, feedFiles } = await createSignedFeeds(unsignedManifest, outputDirectory, env); + const signedManifest = { ...unsignedManifest, manifestUrl, windowsSignerPins, feeds }; + const manifestPayload = Buffer.from(`${JSON.stringify(signedManifest, null, 2)}\n`); + const signaturePayload = Buffer.from(`${sign(null, manifestPayload, privateKey).toString('base64')}\n`); + await writeFile(join(outputDirectory, 'desktop-release.json'), manifestPayload); + await writeFile(join(outputDirectory, 'desktop-release.json.sig'), signaturePayload); + await writeFile( + join(outputDirectory, 'SHA256SUMS'), + `${[ + ...unsignedManifest.artifacts, + ...feedFiles, + { fileName: 'desktop-release.json', size: manifestPayload.length, sha256: checksumBytes(manifestPayload) }, + { fileName: 'desktop-release.json.sig', size: signaturePayload.length, sha256: checksumBytes(signaturePayload) }, + ].sort((left, right) => left.fileName.localeCompare(right.fileName)) + .map(file => `${file.sha256} ${file.fileName}`) + .join('\n')}\n`, + ); + return signedManifest; +}; + +const argument = name => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const command = process.argv[2]; + if (command === 'probe-dmg-private-snapshot-isolation') { + const makeDirectory = argument('--make-directory'); + const arch = argument('--arch'); + const version = argument('--version'); + if (!makeDirectory || !arch || !version) { + throw new Error('Private-snapshot DMG isolation probe requires --make-directory, --arch, and --version'); + } + const result = await probePrivateDmgSnapshotIsolation({ + makeDirectory: resolve(makeDirectory), + arch, + version, + }); + console.log(JSON.stringify({ privateSnapshotDmgIsolation: true, architecture: arch, ...result })); + } else if (command === 'stage') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await stageArtifacts({ + makeDirectory: resolve(argument('--make-directory') || 'out/make'), + outputDirectory: resolve(argument('--output') || 'release-staging'), + platform: argument('--platform') || process.platform, + arch: argument('--arch') || process.arch, + version, + }); + } else if (command === 'finalize') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await finalizeArtifacts({ + inputDirectory: resolve(argument('--input') || 'release-artifacts'), + outputDirectory: resolve(argument('--output') || 'release-final'), + version, + }); + } else if (command === 'sign') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await signReleaseMetadata({ + inputDirectory: resolve(argument('--input') || 'release-final'), + outputDirectory: resolve(argument('--output') || 'release-signed'), + version, + }); + } else { + throw new Error('Expected release-artifacts.mjs private-snapshot probe, stage, finalize, or sign command'); + } +} diff --git a/apps/desktop/scripts/release-artifacts.test.mjs b/apps/desktop/scripts/release-artifacts.test.mjs new file mode 100644 index 000000000..42d913283 --- /dev/null +++ b/apps/desktop/scripts/release-artifacts.test.mjs @@ -0,0 +1,1239 @@ +import assert from 'node:assert/strict'; +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash, generateKeyPairSync, verify } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { access, chmod, link, lstat, mkdtemp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, test } from 'node:test'; +import { promisify } from 'node:util'; +import { + finalizeArtifacts, + signReleaseMetadata, + stageArtifacts, +} from './release-artifacts.mjs'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + inspectExecutableBytes, + readHeldDmgArtifactBytes, +} from './release-architecture.mjs'; + +const kinds = { + 'linux-x64': ['deb', 'rpm', 'zip'], + 'linux-arm64': ['deb', 'rpm', 'zip'], + 'darwin-x64': ['dmg', 'zip'], + 'darwin-arm64': ['dmg', 'zip'], + 'win32-x64': ['msi'], + 'win32-arm64': ['msi'], +}; + +const expectedDistributableNames = [ + 'ProPR-Desktop-1.2.3-linux-x64.deb', + 'ProPR-Desktop-1.2.3-linux-x64.rpm', + 'ProPR-Desktop-1.2.3-linux-x64.zip', + 'ProPR-Desktop-1.2.3-linux-arm64.deb', + 'ProPR-Desktop-1.2.3-linux-arm64.rpm', + 'ProPR-Desktop-1.2.3-linux-arm64.zip', + 'ProPR-Desktop-1.2.3-macos-x64.dmg', + 'ProPR-Desktop-1.2.3-macos-x64.zip', + 'ProPR-Desktop-1.2.3-macos-arm64.dmg', + 'ProPR-Desktop-1.2.3-macos-arm64.zip', + 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi', + 'ProPR-Desktop-1.2.3-windows-arm64-Machine-Setup.msi', +]; + +const sourceName = kind => kind === 'msi' ? 'Desktop-Machine-Setup.msi' : `desktop.${kind}`; +const certificateSha256 = '1'.repeat(64); +const spkiSha256 = '2'.repeat(64); +const windowsSignerPins = `certificate-sha256:${certificateSha256},spki-sha256:${spkiSha256}`; +const execFile = promisify(execFileCallback); +const nativeDarwinArch = process.arch === 'arm64' ? 'arm64' : 'x64'; +const privateDmgSnapshotPaths = async () => { + const entries = await readdir(tmpdir(), { withFileTypes: true }); + const paths = []; + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('propr-dmg-snapshot-')) continue; + const directory = join(tmpdir(), entry.name); + for (const name of await readdir(directory)) { + if (name.endsWith('.dmg')) paths.push(join(directory, name)); + } + } + return paths; +}; + +const findNewPrivateDmgSnapshot = async previous => { + const paths = (await privateDmgSnapshotPaths()).filter(path => !previous.has(path)); + assert.equal(paths.length, 1, 'inspection must create exactly one private DMG snapshot'); + return paths[0]; +}; + +const nativeDmgValidation = arch => ({ + schemaVersion: 1, + tool: 'propr-desktop-release-architecture', + toolVersion: '1.0.0', + nativePlatform: 'darwin', + mountMethod: 'hdiutil-attach-readonly', + layout: { + topLevelApplication: 'propr-desktop.app', + installLink: { path: 'Applications', type: 'symbolic-link', target: '/Applications' }, + mainExecutable: { + path: 'propr-desktop.app/Contents/MacOS/propr-desktop', + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: [ + 'propr-desktop Helper.app', + 'propr-desktop Helper (GPU).app', + 'propr-desktop Helper (Plugin).app', + 'propr-desktop Helper (Renderer).app', + ].map(bundle => ({ + bundle, + path: `propr-desktop.app/Contents/Frameworks/${bundle}/Contents/MacOS/${bundle.slice(0, -'.app'.length)}`, + format: 'mach-o', + architectures: [arch], + })), + }, +}); + +const architectureInspector = async ({ path, heldArtifact, kind, platform, arch }) => { + if (kind === 'releases') return { format: 'squirrel-releases', target: `${platform}-${arch}` }; + const contents = kind === 'dmg' + ? (await readHeldDmgArtifactBytes(heldArtifact)).toString('utf8') + : await readFile(path, 'utf8'); + if (!contents.includes(`${platform}-${arch}-${kind}`)) { + throw new Error(`${kind} packaged executable architecture mismatch for ${platform}-${arch}`); + } + return { + format: kind, + executable: { platform, architectures: [arch] }, + ...(kind === 'dmg' ? { nativeValidation: nativeDmgValidation(arch) } : {}), + }; +}; + +const windowsDmgFixtureAuthority = Object.freeze({ + schemaVersion: 1, + platform: 'win32', + scope: 'release-test-private-dmg', +}); + +const stageFixtureArtifacts = arguments_ => stageArtifacts({ + ...arguments_, + privateDmgFixtureAuthority: windowsDmgFixtureAuthority, +}); + +const signerEnvironment = platform => platform === 'darwin' + ? { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: 'apple-team-id', + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: 'TEAM123456', + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + } + : platform === 'win32' + ? { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: 'authenticode-subject', + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: 'CN=Example Publisher', + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: spkiSha256, + } + : {}; + +const createFragments = async (root, { signed = false } = {}) => { + const fragments = join(root, 'fragments'); + for (const [target, targetKinds] of Object.entries(kinds)) { + const [platform, arch] = target.split('-'); + const makeDirectory = join(root, 'make', target); + await mkdir(makeDirectory, { recursive: true }); + for (const kind of targetKinds) { + await writeFile(join(makeDirectory, sourceName(kind)), `${target}-${kind}`); + } + await stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(fragments, target), + platform, + arch, + version: '1.2.3', + env: { + ...(signed ? signerEnvironment(platform) : {}), + ...(platform === 'win32' ? { PROPR_DESKTOP_WINDOWS_INSTALLED_APP: '1' } : {}), + }, + inspectArchitecture: architectureInspector, + }); + } + return fragments; +}; + +const signingEnvironment = keys => ({ + PROPR_DESKTOP_UPDATE_PRIVATE_KEY: keys.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64'), + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'), + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_MAC_TEAM_ID: 'TEAM123456', + PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: 'CN=Example Publisher', + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: windowsSignerPins, + PROPR_DESKTOP_DARWIN_X64_FEED_URL: 'https://updates.example.test/darwin/x64/RELEASES.json', + PROPR_DESKTOP_DARWIN_ARM64_FEED_URL: 'https://updates.example.test/darwin/arm64/RELEASES.json', +}); + +const peFixture = machine => { + const bytes = Buffer.alloc(128); + bytes.write('MZ'); + bytes.writeUInt32LE(64, 0x3c); + bytes.writeUInt32LE(0x00004550, 64); + bytes.writeUInt16LE(machine, 68); + return bytes; +}; + +const windowsAuthorityFixtureEntries = (executablePath, executable) => { + const helper = Buffer.alloc(1024); + helper.writeUInt16LE(0x5a4d, 0); + helper.writeUInt32LE(0x80, 0x3c); + helper.write('PE\0\0', 0x80, 'ascii'); + helper.writeUInt16LE(0x14c, 0x84); + helper.writeUInt16LE(1, 0x86); + helper.writeUInt16LE(224, 0x94); + helper.writeUInt16LE(0x10b, 0x98); + helper.writeUInt32LE(0x2000, 0x98 + 96 + (14 * 8)); + helper.writeUInt32LE(72, 0x98 + 96 + (14 * 8) + 4); + helper.writeUInt32LE(0x200, 0x178 + 8); + helper.writeUInt32LE(0x2000, 0x178 + 12); + helper.writeUInt32LE(0x200, 0x178 + 16); + helper.writeUInt32LE(0x200, 0x178 + 20); + helper.writeUInt32LE(0x1, 0x210); + const executablePe = executable.length >= 64 && executable.readUInt16LE(0) === 0x5a4d + ? executable.readUInt32LE(0x3c) : -1; + const launcherMachine = executablePe >= 0 && executablePe + 6 <= executable.length + ? executable.readUInt16LE(executablePe + 4) : 0x8664; + const launcherArchitecture = launcherMachine === 0xaa64 ? 'arm64' : 'x64'; + const launcher = Buffer.from(helper); + launcher.writeUInt16LE(launcherMachine, 0x84); + const manifest = Buffer.from(`${JSON.stringify({ + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: 'PE32', + architecture: 'anycpu', + machine: 'I386', + clr: true, + size: helper.length, + sha256: createHash('sha256').update(helper).digest('hex'), + sourceSha256: 'a'.repeat(64), + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: 'propr-windows-launcher.node', + format: 'PE', + architecture: launcherArchitecture, + machine: launcherArchitecture === 'arm64' ? 'ARM64' : 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: 'propr-windows-bootstrap.node', + format: 'PE', + architecture: launcherArchitecture, + machine: launcherArchitecture === 'arm64' ? 'ARM64' : 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-fixed-system-dotnet-framework-csc-v1', + framework: 'Framework64-v4.0.30319', + }, + })}\n`); + return [ + [executablePath, executable], + ['lib/net45/resources/windows-authority/propr-windows-authority.exe', helper], + ['lib/net45/resources/windows-authority/propr-windows-authority.manifest.json', manifest], + ['lib/net45/resources/windows-authority/propr-windows-launcher.node', launcher], + ['lib/net45/resources/windows-authority/propr-windows-bootstrap.node', launcher], + ]; +}; + +const machOFixture = cpuType => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(cpuType, 4); + return bytes; +}; + +const crcTable = Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + return crc >>> 0; +}); +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +}; + +const storedZip = entries => { + const localParts = []; + const centralParts = []; + let offset = 0; + for (const [name, contents, unixMode = 0] of entries) { + const nameBytes = Buffer.from(name); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(contents), 14); + local.writeUInt32LE(contents.length, 18); + local.writeUInt32LE(contents.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + localParts.push(local, nameBytes, contents); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(contents), 16); + central.writeUInt32LE(contents.length, 20); + central.writeUInt32LE(contents.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(((unixMode & 0xffff) << 16) >>> 0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBytes); + offset += local.length + nameBytes.length + contents.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); +}; + +describe('desktop release artifacts', () => { + test('stages named artifacts and finalizes unsigned validation metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-test-')); + const fragments = await createFragments(root); + const fragmentNames = []; + for (const target of Object.keys(kinds)) { + const fragment = JSON.parse(await readFile(join(fragments, target, 'release-fragment.json'), 'utf8')); + fragmentNames.push(...fragment.artifacts.map(artifact => artifact.fileName)); + } + assert.deepEqual([...fragmentNames].sort(), [...expectedDistributableNames].sort()); + const output = join(root, 'final'); + const manifest = await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: output, version: '1.2.3', inspectArchitecture: architectureInspector }); + assert.equal(manifest.schemaVersion, 2); + assert.equal(manifest.artifacts.length, 12); + assert.equal(manifest.tag, 'desktop-v1.2.3'); + assert.equal(Object.keys(manifest.feeds).length, 0); + assert.equal(Object.keys(manifest.nativeSigners).length, 0); + await assert.rejects(access(join(output, 'desktop-release.json.sig'))); + const names = manifest.artifacts.map(artifact => artifact.fileName); + assert.equal(new Set(names).size, 12); + assert.deepEqual([...names].sort(), [...expectedDistributableNames].sort()); + const checksumLines = (await readFile(join(output, 'SHA256SUMS'), 'utf8')).trim().split('\n'); + assert.equal(checksumLines.length, 12); + assert.deepEqual( + checksumLines.map(line => line.slice(line.indexOf(' ') + 2)).sort(), + [...expectedDistributableNames].sort(), + ); + for (const line of checksumLines) { + const match = /^([a-f0-9]{64}) ([^/\\]+)$/.exec(line); + assert.ok(match, `invalid SHA256SUMS line: ${line}`); + assert.equal(createHash('sha256').update(await readFile(join(output, match[2]))).digest('hex'), match[1]); + } + const dmg = manifest.artifacts.find(artifact => artifact.kind === 'dmg' && artifact.arch === 'arm64'); + assert.deepEqual(dmg.nativeDmgValidationEvidence.artifact, { + fileName: dmg.fileName, + size: dmg.size, + sha256: dmg.sha256, + }); + assert.equal(dmg.nativeDmgValidationEvidence.validatedNatively, true); + assert.deepEqual(dmg.nativeDmgValidationEvidence.layout.installLink, { + path: 'Applications', + type: 'symbolic-link', + target: '/Applications', + }); + }); + + test('rejects extensionless, doubled-extension, case-conflicting, duplicate, wrong-kind, stale, and mixed-target names', async () => { + const cases = [ + ['extensionless', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.3-linux-x64-zip'; }], + ['doubled-extension', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.3-linux-x64.zip.zip'; }], + ['case-conflicting', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.3-linux-x64.ZIP'; }], + ['duplicate', artifact => { + artifact.kind = 'rpm'; + artifact.fileName = 'ProPR-Desktop-1.2.3-linux-x64.rpm'; + }], + ['wrong-kind', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.3-linux-x64.rpm'; }], + ['stale', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.2-linux-x64.zip'; }], + ['mixed-target', artifact => { artifact.fileName = 'ProPR-Desktop-1.2.3-linux-arm64.zip'; }], + ]; + for (const [name, mutate] of cases) { + const root = await mkdtemp(join(tmpdir(), `propr-release-name-${name}-`)); + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'linux-x64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + mutate(fragment.artifacts.find(artifact => artifact.kind === 'zip')); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /invalid or duplicate artifact|invalid, duplicate, or noncanonical artifact name/, + name, + ); + } + }); + + test('rejects altered DMG bytes even when fragment artifact metadata is rewritten', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-altered-')); + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + const dmg = fragment.artifacts.find(artifact => artifact.kind === 'dmg'); + const dmgPath = join(fragments, 'darwin-arm64', dmg.fileName); + const altered = Buffer.from('darwin-arm64-dmg-altered-after-native-validation'); + await writeFile(dmgPath, altered); + dmg.size = altered.length; + dmg.sha256 = createHash('sha256').update(altered).digest('hex'); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /does not bind the exact canonical DMG bytes/, + ); + }); + + test('rejects permanent DMG replacement or in-place mutation during held inspection without emitting evidence', async () => { + for (const operation of ['in-place-mutation', 'permanent-replace']) { + const root = await mkdtemp(join(tmpdir(), `propr-release-dmg-inspection-${operation}-`)); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), 'darwin-arm64-dmg'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + assert.equal(arguments_.path, undefined, 'DMG inspectors must not receive a mutable pathname'); + assert.deepEqual(Object.keys(arguments_.heldArtifact), ['description']); + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + assert.ok(!privatePath.startsWith(`${outputDirectory}/`), 'private snapshot must stay outside public output'); + if (operation === 'in-place-mutation') { + await writeFile(privatePath, 'darwin-arm64-dmg-mutated-during-native-validation'); + } else { + const displaced = `${privatePath}.displaced`; + await rename(privatePath, displaced); + await writeFile(privatePath, 'darwin-arm64-dmg-permanent-replacement'); + } + } + return inspection; + }, + }), + /Staged DMG identity or content changed during native validation|pathname no longer names the held exact artifact|\[dmg-private:file-(?:identity|mode)\]/, + operation, + ); + await assert.rejects(access(join(outputDirectory, 'release-fragment.json')), undefined, operation); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps held A bytes, evidence, and publication stable when original and public pathnames change during inspection', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-swap-restore-')); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + const originalPath = join(makeDirectory, 'desktop.dmg'); + const destination = join(outputDirectory, 'ProPR-Desktop-1.2.3-macos-arm64.dmg'); + await writeFile(originalPath, 'darwin-arm64-dmg-A'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + const expectedBytes = Buffer.from('darwin-arm64-dmg-A'); + const fragment = await stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + if (arguments_.kind !== 'dmg') return architectureInspector(arguments_); + assert.equal(arguments_.path, undefined, 'the mutable private pathname must not enter the callback API'); + assert.deepEqual(Object.keys(arguments_.heldArtifact), ['description']); + assert.deepEqual(await readHeldDmgArtifactBytes(arguments_.heldArtifact), expectedBytes); + const displaced = `${originalPath}.held-A`; + await rename(originalPath, displaced); + await writeFile(originalPath, 'darwin-arm64-dmg-B'); + await writeFile(destination, 'attacker-controlled-public-B'); + assert.equal(await readFile(destination, 'utf8'), 'attacker-controlled-public-B'); + await rm(destination); + return architectureInspector(arguments_); + }, + }); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + assert.equal(artifact.sha256, createHash('sha256').update(expectedBytes).digest('hex')); + assert.equal(artifact.nativeDmgValidationEvidence.artifact.sha256, artifact.sha256); + assert.ok(!JSON.stringify(fragment).includes('propr-dmg-snapshot-'), 'private snapshot path must not enter evidence'); + assert.deepEqual(await readFile(destination), expectedBytes); + await rm(root, { recursive: true, force: true }); + }); + + test('continues to reject a mutable pathname passed directly to DMG inspection', async () => { + await assert.rejects( + inspectArtifactArchitecture({ path: '/tmp/public.dmg', kind: 'dmg', platform: 'darwin', arch: 'arm64' }), + /DMG inspection rejects mutable pathnames/, + ); + }); + + test('requires explicit fixture authority for Windows-hosted DMG evidence tests', { + skip: process.platform !== 'win32', + }, async () => { + await assert.rejects( + stageArtifacts({ + makeDirectory: 'unused', + outputDirectory: 'unused', + platform: 'darwin', + arch: 'x64', + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /explicit scoped fixture authority/, + ); + }); + + test('accepts real Darwin mode-0700 directory and mode-0600 single-link file authority', { + skip: process.platform !== 'darwin', + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-private-accept-')); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + try { + await stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + const directoryStats = await lstat(dirname(privatePath), { bigint: true }); + const fileStats = await lstat(privatePath, { bigint: true }); + assert.equal(directoryStats.mode & 0o777n, 0o700n); + assert.equal(fileStats.mode & 0o777n, 0o600n); + assert.equal(fileStats.nlink, 1n); + } + return inspection; + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects native Darwin broad mode, foreign owner, extra link, replacement type, and symlink with fixed authority codes', { + skip: process.platform !== 'darwin', + }, async t => { + const cases = [ + ['broad-mode', 'file-mode'], + ['foreign-owner', 'file-owner'], + ['hardlink', 'file-link'], + ['directory', 'file-type'], + ['symlink', 'file-symlink'], + ]; + for (const [scenario, code] of cases) await t.test(scenario, async () => { + const root = await mkdtemp(join(tmpdir(), `propr-release-dmg-private-${scenario}-`)); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + if (scenario === 'broad-mode') await chmod(privatePath, 0o644); + else if (scenario === 'foreign-owner') await execFile('/usr/bin/sudo', ['-n', 'chown', '0', privatePath]); + else if (scenario === 'hardlink') await link(privatePath, `${privatePath}.link`); + else { + const displaced = `${privatePath}.displaced`; + await rename(privatePath, displaced); + if (scenario === 'directory') await mkdir(privatePath, { mode: 0o700 }); + else await symlink(displaced, privatePath); + } + } + return inspection; + }, + }), + error => error instanceof Error + && error.message === `Private DMG authority rejected [dmg-private:${code}]`, + ); + await rm(root, { recursive: true, force: true }); + }); + }); + + test('accepts native xattr/ctime-only change when held bytes and identity are unchanged', { + skip: process.platform !== 'darwin', + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-xattr-')); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + const fragment = await stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + const before = await lstat(privatePath, { bigint: true }); + await execFile('xattr', ['-w', 'com.propr.descriptor-validation', 'verified', privatePath]); + const after = await lstat(privatePath, { bigint: true }); + assert.notEqual(after.ctimeNs, before.ctimeNs, 'fixture must exercise an xattr-only ctime change'); + } + return inspection; + }, + }); + assert.equal(fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence.validatedNatively, true); + await rm(root, { recursive: true, force: true }); + }); + + test('does not emit claimed DMG layout evidence without the native-validation marker', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-no-native-marker-')); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), 'darwin-arm64-dmg'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + delete inspection.nativeValidation; + return inspection; + }, + }), + /Native DMG validation marker must be an object/, + ); + }); + + test('strictly rejects missing, mixed, stale, malformed, or fabricated native DMG evidence', async () => { + const cases = [ + ['missing evidence', artifact => { delete artifact.nativeDmgValidationEvidence; }, /must be an object/], + ['wrong filename', artifact => { artifact.nativeDmgValidationEvidence.artifact.fileName = 'foreign.dmg'; }, /exact canonical DMG bytes/], + ['wrong version', artifact => { artifact.nativeDmgValidationEvidence.version = '1.2.4'; }, /mixed, stale, or cross-target/], + ['wrong target', artifact => { artifact.nativeDmgValidationEvidence.target = 'darwin-x64'; }, /mixed, stale, or cross-target/], + ['wrong architecture', artifact => { artifact.nativeDmgValidationEvidence.architecture = 'x64'; }, /mixed, stale, or cross-target/], + ['wrong hash', artifact => { artifact.nativeDmgValidationEvidence.artifact.sha256 = '0'.repeat(64); }, /exact canonical DMG bytes/], + ['wrong size', artifact => { artifact.nativeDmgValidationEvidence.artifact.size += 1; }, /exact canonical DMG bytes/], + ['wrong size type', artifact => { artifact.nativeDmgValidationEvidence.artifact.size = `${artifact.size}`; }, /exact canonical DMG bytes/], + ['missing layout field', artifact => { delete artifact.nativeDmgValidationEvidence.layout.mainExecutable; }, /missing or unknown keys/], + ['unknown layout key', artifact => { artifact.nativeDmgValidationEvidence.layout.untrusted = true; }, /missing or unknown keys/], + ['unknown record key', artifact => { artifact.nativeDmgValidationEvidence.untrusted = true; }, /missing or unknown keys/], + ['unknown schema', artifact => { artifact.nativeDmgValidationEvidence.schemaVersion = 2; }, /unsupported schemaVersion/], + ['symlink claim without native marker', artifact => { artifact.nativeDmgValidationEvidence.validatedNatively = false; }, /lacks the native-validation marker/], + ]; + for (const [name, mutate, expected] of cases) { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-evidence-')); + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + mutate(artifact); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + expected, + name, + ); + } + }); + + test('rejects native DMG evidence copied between x64 and arm64 fragments', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-cross-label-')); + const fragments = await createFragments(root); + const x64Fragment = JSON.parse(await readFile(join(fragments, 'darwin-x64', 'release-fragment.json'), 'utf8')); + const arm64Path = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const arm64Fragment = JSON.parse(await readFile(arm64Path, 'utf8')); + arm64Fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence = + x64Fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence; + await writeFile(arm64Path, `${JSON.stringify(arm64Fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /mixed, stale, or cross-target|exact canonical DMG bytes/, + ); + }); + + test('rejects duplicate target fragments before aggregation', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-duplicate-fragment-')); + const fragments = await createFragments(root); + const duplicate = join(fragments, 'duplicate'); + await mkdir(duplicate); + await writeFile( + join(duplicate, 'release-fragment.json'), + await readFile(join(fragments, 'darwin-x64', 'release-fragment.json')), + ); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /Expected 6 release fragments, found 7/, + ); + }); + + test('rejects either Windows fragment when the installed ordinary-user application gate was skipped', async () => { + for (const target of ['win32-x64', 'win32-arm64']) { + const root = await mkdtemp(join(tmpdir(), 'propr-release-missing-installed-authority-')); + const fragments = await createFragments(root); + const path = join(fragments, target, 'release-fragment.json'); + const fragment = JSON.parse(await readFile(path, 'utf8')); + fragment.installedApplicationValidated = false; + await writeFile(path, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + new RegExp(`${target} skipped the installed ordinary-user application gate`), + ); + } + }); + + test('fails closed when trusted update signing configuration is incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-missing-key-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + const publicKey = generateKeyPairSync('ed25519').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: { PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey }, + }), + /configuration is incomplete.*PROPR_DESKTOP_UPDATE_PRIVATE_KEY/, + ); + const complete = signingEnvironment(generateKeyPairSync('ed25519')); + for (const name of Object.keys(complete)) { + const incomplete = { ...complete }; + delete incomplete[name]; + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, `missing-${name}`), + version: '1.2.3', + env: incomplete, + }), + new RegExp(`configuration is incomplete.*${name}`), + ); + } + }); + + test('signs cryptographically bound feeds only in the trusted release phase', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-sign-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + const output = join(root, 'signed'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + const keys = generateKeyPairSync('ed25519'); + const manifest = await signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: output, + version: '1.2.3', + env: signingEnvironment(keys), + }); + + assert.equal(manifest.manifestUrl, 'https://updates.example.test/stable/desktop-release.json'); + assert.deepEqual(manifest.windowsSignerPins, windowsSignerPins.split(',')); + assert.deepEqual(Object.keys(manifest.feeds).sort(), ['darwin-arm64', 'darwin-x64']); + assert.equal(manifest.feeds['darwin-arm64'].signer.identity, 'TEAM123456'); + assert.equal(manifest.feeds['win32-x64'], undefined); + for (const arch of ['x64', 'arm64']) { + const feed = manifest.feeds[`darwin-${arch}`]; + const fileName = `ProPR-Desktop-1.2.3-macos-${arch}.zip`; + assert.equal(feed.artifact.fileName, fileName); + assert.equal(feed.artifact.url, `https://updates.example.test/darwin/${arch}/${fileName}`); + const feedBytes = JSON.parse(await readFile( + join(output, `ProPR-Desktop-1.2.3-macos-${arch}-RELEASES.json`), + 'utf8', + )); + assert.equal(feedBytes.url, feed.artifact.url); + } + const checksumNames = (await readFile(join(output, 'SHA256SUMS'), 'utf8')) + .trim() + .split('\n') + .map(line => line.slice(line.indexOf(' ') + 2)); + assert.deepEqual( + checksumNames.filter(name => expectedDistributableNames.includes(name)).sort(), + [...expectedDistributableNames].sort(), + ); + const payload = await readFile(join(output, 'desktop-release.json')); + const signature = Buffer.from((await readFile(join(output, 'desktop-release.json.sig'), 'utf8')).trim(), 'base64'); + assert.equal(verify(null, payload, keys.publicKey, signature), true); + }); + + test('refuses to sign a renamed extensionless distributable', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-sign-name-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + const manifestPath = join(unsigned, 'desktop-release.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.artifacts.find(artifact => artifact.fileName === 'ProPR-Desktop-1.2.3-macos-x64.zip').fileName = + 'ProPR-Desktop-1.2.3-macos-x64-zip'; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: signingEnvironment(generateKeyPairSync('ed25519')), + }), + /invalid, duplicate, or noncanonical artifact name/, + ); + }); + + test('refuses to sign when artifact bytes changed after unsigned finalization', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-tamper-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await writeFile(join(unsigned, 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi'), 'tampered'); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: signingEnvironment(generateKeyPairSync('ed25519')), + }), + /artifact integrity is invalid/, + ); + }); + + test('rejects unsigned production metadata and actual signer mismatches', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-unsigned-production-')); + const fragments = await createFragments(root); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: signingEnvironment(generateKeyPairSync('ed25519')), + }), + /Actual native signer mismatch/, + ); + + const signedFragments = await createFragments(await mkdtemp(join(tmpdir(), 'propr-release-signer-mismatch-')), { signed: true }); + const signedUnsigned = join(root, 'signed-unsigned'); + await finalizeArtifacts({ inputDirectory: signedFragments, outputDirectory: signedUnsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'mismatch'), + version: '1.2.3', + env: { ...signingEnvironment(generateKeyPairSync('ed25519')), PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: 'CN=Wrong Publisher' }, + }), + /Actual native signer mismatch for win32-x64/, + ); + + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'same-subject-different-key'), + version: '1.2.3', + env: { + ...signingEnvironment(generateKeyPairSync('ed25519')), + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: `certificate-sha256:${'3'.repeat(64)}`, + }, + }), + /Actual native signer mismatch for win32-x64/, + ); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'malformed-pin'), + version: '1.2.3', + env: { + ...signingEnvironment(generateKeyPairSync('ed25519')), + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: `certificate-sha256:${'A'.repeat(64)}`, + }, + }), + /canonical SHA-256 fingerprint allowlist/, + ); + }); + + test('rejects mixed Windows signers and tampered fingerprint evidence', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-mixed-signers-')); + const fragments = await createFragments(root, { signed: true }); + const fragmentPath = join(fragments, 'win32-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + fragment.nativeSigner.certificateSha256 = '3'.repeat(64); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /mixed native signer evidence/, + ); + + fragment.nativeSigner.certificateSha256 = 'not-a-sha256'; + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'tampered'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /Native signer evidence is incomplete or invalid/, + ); + }); + + test('parses x64 and arm64 ELF, PE, and Mach-O executable fixtures', () => { + const elf = machine => { + const bytes = Buffer.alloc(64); + Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(bytes); + bytes[5] = 1; + bytes.writeUInt16LE(machine, 18); + return bytes; + }; + const pe = machine => { + const bytes = Buffer.alloc(128); + bytes.write('MZ'); + bytes.writeUInt32LE(64, 0x3c); + bytes.writeUInt32LE(0x00004550, 64); + bytes.writeUInt16LE(machine, 68); + return bytes; + }; + const machO = cpuType => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(cpuType, 4); + return bytes; + }; + assert.deepEqual(inspectExecutableBytes(elf(62)), { format: 'elf', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(elf(183)), { format: 'elf', architectures: ['arm64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0x8664)), { format: 'pe', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0xaa64)), { format: 'pe', architectures: ['arm64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0x014c)), { format: 'pe', architectures: ['x86'] }); + assert.deepEqual(inspectExecutableBytes(machO(0x01000007)), { format: 'mach-o', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(machO(0x0100000c)), { format: 'mach-o', architectures: ['arm64'] }); + }); + + test('derives Windows target architecture from the full NUPKG independently of its supported bootstrapper', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-squirrel-arch-')); + const setup = join(root, 'Setup.exe'); + const arm64Package = join(root, 'desktop-arm64-full.nupkg'); + await writeFile(setup, peFixture(0x014c)); + await writeFile(arm64Package, storedZip(windowsAuthorityFixtureEntries( + 'lib/net45/propr-desktop.exe', peFixture(0xaa64), + ))); + + assert.deepEqual( + await inspectArtifactArchitecture({ path: setup, kind: 'setup', platform: 'win32', arch: 'arm64' }), + { format: 'squirrel-setup', executable: { format: 'pe', architectures: ['x86'] } }, + ); + assert.deepEqual( + await inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'arm64' }), + { format: 'nupkg', executable: { format: 'pe', architectures: ['arm64'] } }, + ); + + await assert.rejects( + inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + /executable architecture mismatch.*pe\/x64.*pe\/arm64/, + ); + await writeFile(arm64Package, storedZip(windowsAuthorityFixtureEntries( + 'lib/net45/propr-desktop.exe', Buffer.from('tampered payload'), + ))); + await assert.rejects( + inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'arm64' }), + /not a recognized.*binary/, + ); + await writeFile(setup, peFixture(0x01c0)); + await assert.rejects( + inspectArtifactArchitecture({ path: setup, kind: 'setup', platform: 'win32', arch: 'arm64' }), + /not a supported x86, x64, or arm64 Squirrel PE bootstrapper/, + ); + }); + + test('binds ZIP and NUPKG executables to exact maker-specific canonical paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-canonical-archives-')); + const fixtures = [ + ['linux.zip', 'zip', 'linux', 'x64', 'propr-desktop-linux-x64/propr-desktop', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, ...Array(12).fill(0), 62, 0])], + ['darwin.zip', 'zip', 'darwin', 'arm64', 'propr-desktop.app/Contents/MacOS/propr-desktop', machOFixture(0x0100000c)], + ['windows.nupkg', 'nupkg', 'win32', 'x64', 'lib/net45/propr-desktop.exe', peFixture(0x8664)], + ]; + for (const [name, kind, platform, arch, executablePath, bytes] of fixtures) { + const path = join(root, name); + const entries = kind === 'nupkg' + ? windowsAuthorityFixtureEntries(executablePath, bytes) + : [[executablePath, bytes]]; + await writeFile(path, storedZip(entries)); + const result = await inspectArtifactArchitecture({ path, kind, platform, arch }); + assert.equal(result.executable.architectures[0], arch); + } + }); + + test('rejects missing, corrupt, mismatched, and ambiguous packaged Windows authority helpers', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-windows-authority-')); + const executablePath = 'lib/net45/propr-desktop.exe'; + const executable = peFixture(0x8664); + const exact = windowsAuthorityFixtureEntries(executablePath, executable); + const corruptManifest = exact.map(entry => [...entry]); + const parsed = JSON.parse(corruptManifest[2][1].toString('utf8')); + parsed.sha256 = '0'.repeat(64); + corruptManifest[2][1] = Buffer.from(`${JSON.stringify(parsed)}\n`); + const corruptHelper = exact.map(entry => [...entry]); + corruptHelper[1][1] = Buffer.from(corruptHelper[1][1]); + corruptHelper[1][1][0] = 0; + const cases = [ + ['missing', [exact[0]], /missing its exact Windows authority helper binding/], + ['manifest', corruptManifest, /does not match its bound manifest/], + ['output', corruptHelper, /does not match its bound manifest|not the expected managed/], + ['alternate', [...exact, ['tools/propr-windows-authority.exe', exact[1][1]]], /ambiguous Windows authority helper layout/], + ]; + for (const [name, entries, pattern] of cases) { + const path = join(root, `${name}.nupkg`); + await writeFile(path, storedZip(entries)); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + pattern, + ); + } + }); + + test('accepts only the real Forge macOS framework-internal symbolic-link layout', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-darwin-framework-')); + context.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, 'darwin.zip'); + const framework = 'propr-desktop.app/Contents/Frameworks/Electron Framework.framework'; + const symlink = (name, target) => [`${framework}/${name}`, Buffer.from(target), 0xa1ff]; + await writeFile(path, storedZip([ + ['propr-desktop.app/Contents/MacOS/propr-desktop', machOFixture(0x0100000c)], + [`${framework}/Versions/A/Electron Framework`, machOFixture(0x0100000c)], + [`${framework}/Versions/A/Resources/Info.plist`, Buffer.from('resources')], + [`${framework}/Versions/A/Libraries/libEGL.dylib`, Buffer.from('library')], + [`${framework}/Versions/A/Helpers/chrome_crashpad_handler`, Buffer.from('helper')], + symlink('Versions/Current', 'A'), + symlink('Electron Framework', 'Versions/Current/Electron Framework'), + symlink('Resources', 'Versions/Current/Resources'), + symlink('Libraries', 'Versions/Current/Libraries'), + symlink('Helpers', 'Versions/Current/Helpers'), + ])); + + assert.deepEqual( + await inspectArtifactArchitecture({ path, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + { format: 'zip', executable: { format: 'mach-o', architectures: ['arm64'] } }, + ); + }); + + test('rejects hostile macOS ZIP symbolic links before trusting their payloads', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-hostile-darwin-links-')); + context.after(() => rm(root, { recursive: true, force: true })); + const executablePath = 'propr-desktop.app/Contents/MacOS/propr-desktop'; + const framework = 'propr-desktop.app/Contents/Frameworks/Electron Framework.framework'; + const executable = [executablePath, machOFixture(0x0100000c)]; + const target = [`${framework}/Versions/A/Resources/Info.plist`, Buffer.from('resource')]; + const link = (name, contents) => [`${framework}/${name}`, Buffer.isBuffer(contents) ? contents : Buffer.from(contents), 0xa1ff]; + const cases = [ + ['absolute', [executable, target, link('Resources', '/Applications')], /unsafe relative target/], + ['escaping', [executable, target, link('Resources', '../../../../MacOS')], /unsafe relative target/], + ['chained-escape', [ + executable, + target, + link('Resources', 'Versions/Current/Resources'), + link('Versions/Current', '../../../../../outside'), + ], /unsafe relative target/], + ['cycle', [executable, target, link('Resources', 'Libraries'), link('Libraries', 'Resources')], /contains a cycle/], + ['oversized', [executable, target, link('Resources', Buffer.alloc(1025, 0x61))], /oversized payload/], + ['malformed-utf8', [executable, target, link('Resources', Buffer.from([0xc3, 0x28]))], /cannot be decoded strictly/], + ['duplicate', [executable, target, link('Resources', 'Versions/A/Resources'), link('Resources', 'Versions/A/Resources')], /duplicate or case-colliding/], + ['missing', [executable, target, link('Resources', 'Versions/B/Resources')], /missing target/], + ['case-mismatched-target', [executable, target, link('Resources', 'Versions/a/Resources')], /missing target/], + ['canonical-executable', [ + [executablePath, Buffer.from('../Frameworks/Electron Framework.framework/Electron Framework'), 0xa1ff], + target, + ], /symbolic link outside canonical macOS framework internals/], + ['helper-executable', [ + executable, + target, + ['propr-desktop.app/Contents/Frameworks/propr-desktop Helper.app/Contents/MacOS/propr-desktop Helper', Buffer.from('target'), 0xa1ff], + ], /symbolic link outside canonical macOS framework internals/], + ['nested-helper-executable', [ + executable, + target, + [`${framework}/Helpers/propr-desktop Helper`, Buffer.from('Versions/A/Resources'), 0xa1ff], + ], /symbolic link outside canonical macOS framework internals/], + ['alternate-root', [executable, target, ['Other.app/Contents/Frameworks/Other.framework/Current', Buffer.from('A'), 0xa1ff]], /symbolic link outside canonical macOS framework internals/], + ['special-file', [executable, target, [`${framework}/special`, Buffer.from('special'), 0x11ff]], /symbolic link or special file/], + ]; + for (const [name, entries, pattern] of cases) { + const path = join(root, `${name}.zip`); + await writeFile(path, storedZip(entries)); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + pattern, + name, + ); + } + + const crcPath = join(root, 'link-crc.zip'); + const linkName = `${framework}/Resources`; + const crcBytes = storedZip([executable, target, link('Resources', 'Versions/A/Resources')]); + const localLinkRecord = crcBytes.indexOf(Buffer.from(`${linkName}Versions/A/Resources`)); + assert.notEqual(localLinkRecord, -1); + const payloadOffset = localLinkRecord + Buffer.byteLength(linkName); + crcBytes[payloadOffset] ^= 1; + await writeFile(crcPath, crcBytes); + await assert.rejects( + inspectArtifactArchitecture({ path: crcPath, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + /size or CRC is invalid/, + ); + }); + + test('rejects unsafe, duplicate, shadowed, forged, alternate, and noncanonical archive layouts', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-malicious-archives-')); + const executable = peFixture(0x8664); + const cases = [ + ['traversal', storedZip([['../lib/net45/propr-desktop.exe', executable]]), /non-normalized|unsafe name/], + ['duplicate', storedZip([['lib/net45/propr-desktop.exe', executable], ['lib/net45/propr-desktop.exe', executable]]), /duplicate or case-colliding/], + ['case', storedZip([['lib/net45/propr-desktop.exe', executable], ['LIB/NET45/PROPR-DESKTOP.EXE', executable]]), /case-colliding/], + ['shadow', storedZip([['lib', Buffer.from('file')], ['lib/net45/propr-desktop.exe', executable]]), /conflicting file and directory prefix/], + ['alternate', storedZip([['lib/net45/propr-desktop.exe', executable], ['tools/propr-desktop.exe', executable]]), /executable outside/], + ['wrong-path', storedZip([['lib/net46/propr-desktop.exe', executable]]), /executable outside|missing canonical/], + ]; + const valid = storedZip(windowsAuthorityFixtureEntries('lib/net45/propr-desktop.exe', executable)); + const forged = Buffer.from(valid); + const localNameOffset = 30; + Buffer.from('lib/net46/propr-desktop.exe').copy(forged, localNameOffset); + cases.push(['forged-local-header', forged, /central and local entry metadata disagree/]); + cases.push(['trailing-ambiguity', Buffer.concat([valid, Buffer.from('trailing')]), /end-of-central-directory.*ambiguous/]); + for (const [name, bytes, pattern] of cases) { + const path = join(root, `${name}.nupkg`); + await writeFile(path, bytes); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + pattern, + ); + } + }); + + test('rejects cross-labeled package architectures at staging and finalization', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-wrong-arch-')); + for (const [target, targetKinds] of Object.entries(kinds)) { + const [platform, arch] = target.split('-'); + const oppositeArch = arch === 'x64' ? 'arm64' : 'x64'; + for (const kind of targetKinds.filter(candidate => candidate !== 'releases')) { + const path = join(root, `${target}-${kind}`); + await writeFile(path, `${platform}-${oppositeArch}-${kind}`); + if (kind === 'dmg') { + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + await assert.rejects( + architectureInspector({ heldArtifact: createHeldDmgArtifact(handle, path), kind, platform, arch }), + new RegExp(`${kind} packaged executable architecture mismatch`), + ); + } finally { + await handle.close(); + } + } else { + await assert.rejects( + architectureInspector({ path, kind, platform, arch }), + new RegExp(`${kind} packaged executable architecture mismatch`), + ); + } + } + } + + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory, { recursive: true }); + for (const kind of kinds['linux-x64']) { + const contents = kind === 'releases' ? '' : `linux-arm64-${kind}`; + await writeFile(join(makeDirectory, sourceName(kind)), contents); + } + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'linux', + arch: 'x64', + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /architecture mismatch/, + ); + + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + fragment.artifacts[0].architectureEvidence.executable.architectures = ['x64']; + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ inputDirectory: fragments, outputDirectory: join(root, 'final'), version: '1.2.3', inspectArchitecture: architectureInspector }), + /architecture evidence does not match/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-preflight.mjs b/apps/desktop/scripts/release-preflight.mjs new file mode 100644 index 000000000..615be2c89 --- /dev/null +++ b/apps/desktop/scripts/release-preflight.mjs @@ -0,0 +1,204 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const execFile = promisify(execFileCallback); +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA_PATTERN = /^[a-f0-9]{40}$/; +const ZERO_SHA = '0'.repeat(40); +const RELEASE_ENVIRONMENT = 'desktop-release'; +const PREFLIGHT_ENVIRONMENT = 'desktop-release-preflight'; +const RELEASE_TAG_POLICY = 'desktop-v*'; +const RELEASE_TAG_RULESET_INCLUDE = `refs/tags/${RELEASE_TAG_POLICY}`; +const API_PAGE_SIZE = 100; + +const defaultGit = async args => (await execFile('git', args)).stdout.trim(); + +const apiRequest = async ({ fetchImpl, apiUrl, repository, token, path, allowNotFound = false }) => { + const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (allowNotFound && response.status === 404) return undefined; + if (!response.ok) throw new Error(`GitHub API ${path} failed with HTTP ${response.status}`); + return response.json(); +}; + +const paginatedArray = async (request, path) => { + const values = []; + for (let page = 1; ; page += 1) { + const separator = path.includes('?') ? '&' : '?'; + const result = await request(`${path}${separator}per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Array.isArray(result)) throw new Error(`GitHub API ${path} returned an ambiguous paginated response`); + values.push(...result); + if (result.length < API_PAGE_SIZE) return values; + } +}; + +const paginatedDeploymentPolicies = async (request, environmentName) => { + const path = `/environments/${environmentName}/deployment-branch-policies`; + const policies = []; + let totalCount; + for (let page = 1; ; page += 1) { + const result = await request(`${path}?per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Number.isSafeInteger(result?.total_count) || result.total_count < 0 || !Array.isArray(result.branch_policies)) { + throw new Error(`GitHub API ${path} returned an ambiguous paginated response`); + } + if (totalCount === undefined) totalCount = result.total_count; + if (result.total_count !== totalCount || policies.length + result.branch_policies.length > totalCount) { + throw new Error(`GitHub API ${path} changed or returned inconsistent pagination`); + } + policies.push(...result.branch_policies); + if (policies.length === totalCount) return policies; + if (result.branch_policies.length !== API_PAGE_SIZE) { + throw new Error(`GitHub API ${path} omitted deployment policies during pagination`); + } + } +}; + +const assertNewTagPush = ({ event, tag }) => { + if (event.ref !== `refs/tags/${tag}` || event.created !== true || event.deleted === true || event.forced === true + || event.before !== ZERO_SHA || !SHA_PATTERN.test(event.after)) { + throw new Error('Production release must be a new, non-forced desktop tag push at the exact event SHA'); + } +}; + +const assertEnvironmentProtection = (environment, policies, environmentName) => { + if (environment?.name !== environmentName) { + throw new Error(`GitHub environment ${environmentName} does not exist`); + } + const reviewerRule = environment.protection_rules?.find(rule => rule.type === 'required_reviewers'); + if (!reviewerRule || !Array.isArray(reviewerRule.reviewers) || reviewerRule.reviewers.length === 0) { + throw new Error(`GitHub environment ${environmentName} must require reviewers`); + } + if (environment.deployment_branch_policy?.custom_branch_policies !== true + || environment.deployment_branch_policy?.protected_branches !== false) { + throw new Error(`GitHub environment ${environmentName} must use custom deployment tag restrictions`); + } + if (!Array.isArray(policies) || policies.length !== 1 + || policies[0]?.type !== 'tag' || policies[0]?.name !== RELEASE_TAG_POLICY) { + throw new Error(`GitHub environment ${environmentName} must have exactly the tag policy ${RELEASE_TAG_POLICY}`); + } +}; + +const rulesetSecurityState = ruleset => JSON.stringify({ + id: ruleset.id, + target: ruleset.target, + enforcement: ruleset.enforcement, + bypassActors: ruleset.bypass_actors, + refName: ruleset.conditions?.ref_name, + ruleTypes: Array.isArray(ruleset.rules) ? ruleset.rules.map(rule => rule?.type).sort() : ruleset.rules, +}); + +const isExactImmutableTagRuleset = ruleset => { + const refName = ruleset?.conditions?.ref_name; + const ruleTypes = Array.isArray(ruleset?.rules) ? ruleset.rules.map(rule => rule?.type) : []; + return Number.isSafeInteger(ruleset?.id) + && ruleset.target === 'tag' + && ruleset.enforcement === 'active' + && Array.isArray(ruleset.bypass_actors) + && ruleset.bypass_actors.length === 0 + && Array.isArray(refName?.include) + && refName.include.length === 1 + && refName.include[0] === RELEASE_TAG_RULESET_INCLUDE + && Array.isArray(refName.exclude) + && refName.exclude.length === 0 + && ruleTypes.includes('update') + && ruleTypes.includes('deletion'); +}; + +const readImmutableTagRuleset = async request => { + const summaries = await paginatedArray(request, '/rulesets?includes_parents=true&targets=tag'); + const ids = summaries.map(summary => summary?.id); + if (ids.some(id => !Number.isSafeInteger(id)) || new Set(ids).size !== ids.length) { + throw new Error('GitHub repository rulesets response is ambiguous'); + } + const rulesets = []; + for (const id of ids) { + rulesets.push(await request(`/rulesets/${id}?includes_parents=true`)); + } + const matching = rulesets.filter(isExactImmutableTagRuleset); + if (matching.length === 0) { + throw new Error(`Repository must have an active, bypass-free ${RELEASE_TAG_RULESET_INCLUDE} tag ruleset blocking update and deletion`); + } + return matching[0]; +}; + +export const verifyDesktopReleasePreflight = async ({ + repository, + tag, + releaseSha, + token, + event, + apiUrl = 'https://api.github.com', + fetchImpl = fetch, + git = defaultGit, +}) => { + const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : ''; + if (!VERSION_PATTERN.test(version) || !SHA_PATTERN.test(releaseSha) || !repository.includes('/') || !token) { + throw new Error('Desktop release preflight inputs are invalid'); + } + assertNewTagPush({ event, tag }); + + const request = (path, options) => apiRequest({ fetchImpl, apiUrl, repository, token, path, ...options }); + const repositoryDetails = await request(''); + if (repositoryDetails.default_branch !== 'main') throw new Error('The protected release branch must be main'); + const mainBranch = await request('/branches/main'); + if (mainBranch.protected !== true) throw new Error('Repository main branch is not protected'); + + const immutableTagRuleset = await readImmutableTagRuleset(request); + const immutableTagRulesetState = rulesetSecurityState(immutableTagRuleset); + + const encodedTag = encodeURIComponent(tag); + const currentRef = await request(`/git/ref/tags/${encodedTag}`); + if (currentRef.object?.sha !== event.after) throw new Error('Desktop release tag ref moved from the new-tag push'); + const currentCommit = await request(`/commits/${encodedTag}`); + if (currentCommit.sha !== releaseSha) throw new Error('Desktop release tag moved or does not resolve to the event SHA'); + const existingRelease = await request(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (existingRelease) throw new Error(`GitHub release ${tag} already exists`); + + for (const environmentName of [PREFLIGHT_ENVIRONMENT, RELEASE_ENVIRONMENT]) { + const environment = await request(`/environments/${environmentName}`); + const policies = await paginatedDeploymentPolicies(request, environmentName); + assertEnvironmentProtection(environment, policies, environmentName); + } + + await git(['fetch', '--no-tags', 'origin', 'refs/heads/main:refs/remotes/origin/main']); + await git(['fetch', '--no-tags', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]); + const localTagSha = await git(['rev-parse', `${tag}^{commit}`]); + if (localTagSha !== releaseSha) throw new Error('Fetched desktop release tag does not match the event SHA'); + await git(['merge-base', '--is-ancestor', releaseSha, 'refs/remotes/origin/main']); + + const stableCommit = await request(`/commits/${encodedTag}`); + if (stableCommit.sha !== releaseSha) throw new Error('Desktop release tag moved during preflight'); + const stableRef = await request(`/git/ref/tags/${encodedTag}`); + if (stableRef.object?.sha !== event.after) throw new Error('Desktop release tag ref moved during preflight'); + const racedRelease = await request(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (racedRelease) throw new Error(`GitHub release ${tag} appeared during preflight`); + const stableRuleset = await request(`/rulesets/${immutableTagRuleset.id}?includes_parents=true`); + if (!isExactImmutableTagRuleset(stableRuleset) + || rulesetSecurityState(stableRuleset) !== immutableTagRulesetState) { + throw new Error('Desktop tag immutability ruleset changed during preflight'); + } + return { version, releaseSha, tag, tagObjectSha: event.after }; +}; + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); + const result = await verifyDesktopReleasePreflight({ + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.GITHUB_REF_NAME, + releaseSha: process.env.GITHUB_SHA, + token: process.env.GITHUB_TOKEN, + event, + apiUrl: process.env.GITHUB_API_URL, + }); + if (process.env.GITHUB_OUTPUT) { + const { appendFile } = await import('node:fs/promises'); + await appendFile(process.env.GITHUB_OUTPUT, `version=${result.version}\nrelease_sha=${result.releaseSha}\ntag=${result.tag}\ntag_object_sha=${result.tagObjectSha}\n`); + } +} diff --git a/apps/desktop/scripts/release-preflight.test.mjs b/apps/desktop/scripts/release-preflight.test.mjs new file mode 100644 index 000000000..a87b89e65 --- /dev/null +++ b/apps/desktop/scripts/release-preflight.test.mjs @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { verifyDesktopReleasePreflight } from './release-preflight.mjs'; + +const sha = '1'.repeat(40); +const event = { + ref: 'refs/tags/desktop-v1.2.3', + created: true, + deleted: false, + forced: false, + before: '0'.repeat(40), + after: sha, +}; + +const immutableRuleset = (overrides = {}) => ({ + id: 9, + name: 'immutable desktop release tags', + target: 'tag', + enforcement: 'active', + bypass_actors: [], + conditions: { ref_name: { include: ['refs/tags/desktop-v*'], exclude: [] } }, + rules: [{ type: 'update' }, { type: 'deletion' }], + ...overrides, +}); + +const protectedEnvironment = name => ({ + name, + protection_rules: [{ type: 'required_reviewers', reviewers: [{ type: 'Team' }] }, { type: 'branch_policy' }], + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }, +}); + +const responses = ({ protectedMain = true, environment = true, release = false, tagSha = sha } = {}) => ({ + '': { default_branch: 'main' }, + '/branches/main': { protected: protectedMain }, + '/rulesets': [{ id: 9 }], + '/rulesets/9': immutableRuleset(), + '/git/ref/tags/desktop-v1.2.3': { object: { sha } }, + '/commits/desktop-v1.2.3': { sha: tagSha }, + '/releases/tags/desktop-v1.2.3': release ? { id: 7 } : undefined, + '/environments/desktop-release-preflight': environment ? protectedEnvironment('desktop-release-preflight') : undefined, + '/environments/desktop-release-preflight/deployment-branch-policies': environment ? { + total_count: 1, + branch_policies: [{ name: 'desktop-v*', type: 'tag' }], + } : undefined, + '/environments/desktop-release': environment ? protectedEnvironment('desktop-release') : undefined, + '/environments/desktop-release/deployment-branch-policies': environment ? { + total_count: 1, + branch_policies: [{ name: 'desktop-v*', type: 'tag' }], + } : undefined, +}); + +const harness = (values, { + secondTagSha, + secondRefSha, + secondRuleset, + failures = {}, +} = {}) => { + const calls = new Map(); + const requested = []; + return { + requested, + fetchImpl: async (url, request) => { + const parsed = new URL(url); + const path = parsed.pathname.replace('/repos/integry/propr', ''); + const count = (calls.get(path) ?? 0) + 1; + calls.set(path, count); + requested.push(`${path}${parsed.search}`); + assert.equal(request.headers.Authorization, 'Bearer token'); + if (failures[path]) return { status: failures[path], ok: false, json: async () => undefined }; + let value = values[path]; + if (typeof value === 'function') value = value({ count, page: Number(parsed.searchParams.get('page') ?? 1), url: parsed }); + if (path === '/commits/desktop-v1.2.3' && count === 2 && secondTagSha) value = { sha: secondTagSha }; + if (path === '/git/ref/tags/desktop-v1.2.3' && count === 2 && secondRefSha) value = { object: { sha: secondRefSha } }; + if (path === '/rulesets/9' && count === 2 && secondRuleset !== undefined) value = secondRuleset; + return { status: value === undefined ? 404 : 200, ok: value !== undefined, json: async () => value }; + }, + git: async args => args[0] === 'rev-parse' ? sha : '', + }; +}; + +const verify = (values = responses(), options = {}) => verifyDesktopReleasePreflight({ + repository: 'integry/propr', + tag: 'desktop-v1.2.3', + releaseSha: sha, + token: 'token', + event, + ...harness(values, options), +}); + +describe('desktop release preflight', () => { + test('accepts only a new immutable tag reachable from protected main and a protected environment', async () => { + assert.deepEqual(await verify(), { version: '1.2.3', releaseSha: sha, tag: 'desktop-v1.2.3', tagObjectSha: sha }); + }); + + test('accepts an authorization-visible bypass list and fails closed for hidden or denied ruleset details', async () => { + const authorized = responses(); + authorized['/rulesets/9'] = immutableRuleset({ bypass_actors: [] }); + await verify(authorized); + + const hidden = responses(); + hidden['/rulesets/9'] = immutableRuleset({ bypass_actors: undefined }); + await assert.rejects(verify(hidden), /active, bypass-free/); + + await assert.rejects( + verify(responses(), { failures: { '/rulesets/9': 403 } }), + /rulesets\/9.*403/, + ); + }); + + test('paginates repository rulesets and reads every full rule definition', async () => { + const values = responses(); + const summaries = Array.from({ length: 101 }, (_, index) => ({ id: index + 1 })); + values['/rulesets'] = ({ page }) => page === 1 ? summaries.slice(0, 100) : summaries.slice(100); + for (let id = 1; id <= 101; id += 1) { + values[`/rulesets/${id}`] = id === 101 + ? immutableRuleset({ id }) + : immutableRuleset({ id, enforcement: 'disabled' }); + } + const configured = harness(values); + await verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...configured, + }); + assert(configured.requested.includes('/rulesets?includes_parents=true&targets=tag&per_page=100&page=2')); + assert(configured.requested.includes('/rulesets/101?includes_parents=true')); + }); + + test('requires an exact active bypass-free update and deletion tag ruleset', async () => { + const invalidRulesets = [ + immutableRuleset({ enforcement: 'disabled' }), + immutableRuleset({ enforcement: 'evaluate' }), + immutableRuleset({ target: 'branch' }), + immutableRuleset({ bypass_actors: [{ actor_type: 'Integration', actor_id: 15368, bypass_mode: 'always' }] }), + immutableRuleset({ bypass_actors: undefined }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v**'], exclude: [] } } }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v*', '~ALL'], exclude: [] } } }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v*'], exclude: ['refs/tags/desktop-v1.*'] } } }), + immutableRuleset({ rules: [{ type: 'update' }] }), + immutableRuleset({ rules: [{ type: 'deletion' }] }), + ]; + for (const ruleset of invalidRulesets) { + const values = responses(); + values['/rulesets/9'] = ruleset; + await assert.rejects(verify(values), /active, bypass-free.*blocking update and deletion/); + } + }); + + test('rejects ruleset mutation or deletion during preflight', async () => { + await assert.rejects( + verify(responses(), { secondRuleset: immutableRuleset({ rules: [{ type: 'update' }] }) }), + /ruleset changed during preflight/, + ); + await assert.rejects( + verify(responses(), { failures: { '/rulesets/9': 404 } }), + /rulesets\/9.*404/, + ); + const values = responses(); + values['/rulesets/9'] = ({ count }) => count === 1 ? immutableRuleset() : undefined; + await assert.rejects(verify(values), /rulesets\/9.*404/); + }); + + test('requires the complete effective environment policy set to be exactly desktop-v* tags', async () => { + const invalidPolicies = [ + [], + [{ name: '*', type: 'tag' }], + [{ name: 'desktop-v**', type: 'tag' }], + [{ name: 'desktop-v*', type: 'branch' }], + [{ name: 'desktop-v*', type: 'tag' }, { name: '*', type: 'tag' }], + [{ name: 'desktop-v*', type: 'tag' }, { name: 'main', type: 'branch' }], + ]; + for (const policies of invalidPolicies) { + const values = responses(); + values['/environments/desktop-release/deployment-branch-policies'] = { + total_count: policies.length, + branch_policies: policies, + }; + await assert.rejects(verify(values), /exactly the tag policy desktop-v\*/); + } + const fallback = responses(); + fallback['/environments/desktop-release'].deployment_branch_policy = { + protected_branches: true, + custom_branch_policies: false, + }; + await assert.rejects(verify(fallback), /custom deployment tag restrictions/); + }); + + test('requires the separately protected preflight credential environment', async () => { + const missing = responses(); + missing['/environments/desktop-release-preflight'] = undefined; + await assert.rejects(verify(missing), /environments\/desktop-release-preflight.*404/); + const permissive = responses(); + permissive['/environments/desktop-release-preflight/deployment-branch-policies'] = { + total_count: 1, + branch_policies: [{ name: '*', type: 'tag' }], + }; + await assert.rejects(verify(permissive), /desktop-release-preflight must have exactly the tag policy desktop-v\*/); + }); + + test('paginates all environment policies and rejects a permissive policy on a later page', async () => { + const values = responses(); + const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `desktop-v${index}.*`, type: 'tag' })); + values['/environments/desktop-release/deployment-branch-policies'] = ({ page }) => ({ + total_count: 101, + branch_policies: page === 1 ? firstPage : [{ name: '*', type: 'tag' }], + }); + const configured = harness(values); + await assert.rejects( + verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...configured, + }), + /exactly the tag policy desktop-v\*/, + ); + assert(configured.requested.includes('/environments/desktop-release/deployment-branch-policies?per_page=100&page=2')); + }); + + test('rejects missing or ambiguous environment protection and explicit API denial', async () => { + await assert.rejects(verify(responses({ protectedMain: false })), /main branch is not protected/); + await assert.rejects(verify(responses({ environment: false })), /environments\/desktop-release.*404/); + await assert.rejects(verify(responses(), { failures: { '/environments/desktop-release': 403 } }), /environments\/desktop-release.*403/); + const missingReviewers = responses(); + missingReviewers['/environments/desktop-release'].protection_rules = [{ type: 'branch_policy' }]; + await assert.rejects(verify(missingReviewers), /require reviewers/); + const ambiguous = responses(); + ambiguous['/environments/desktop-release/deployment-branch-policies'] = { branch_policies: [] }; + await assert.rejects(verify(ambiguous), /ambiguous paginated response/); + }); + + test('rejects tags not created by this push, tags off main, and moved or existing releases', async () => { + await assert.rejects( + verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', + event: { ...event, created: false, before: '2'.repeat(40) }, ...harness(responses()), + }), + /new, non-forced desktop tag push/, + ); + await assert.rejects(verify(responses({ tagSha: '2'.repeat(40) })), /tag moved/); + await assert.rejects(verify(responses({ release: true })), /already exists/); + await assert.rejects(verify(responses(), { secondTagSha: '2'.repeat(40) }), /moved during preflight/); + await assert.rejects(verify(responses(), { secondRefSha: '2'.repeat(40) }), /tag ref moved during preflight/); + const failingGit = harness(responses()); + failingGit.git = async args => { + if (args[0] === 'merge-base') throw new Error('not an ancestor'); + return args[0] === 'rev-parse' ? sha : ''; + }; + await assert.rejects( + verifyDesktopReleasePreflight({ repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...failingGit }), + /not an ancestor/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-publish.mjs b/apps/desktop/scripts/release-publish.mjs new file mode 100644 index 000000000..6a8b183a4 --- /dev/null +++ b/apps/desktop/scripts/release-publish.mjs @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const VERSIONED_TAG_PATTERN = /^desktop-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA_PATTERN = /^[a-f0-9]{40}$/; +const API_PAGE_SIZE = 100; +const CHECKSUM_FILE = 'SHA256SUMS'; +const REQUIRED_METADATA = ['desktop-release.json', 'desktop-release.json.sig']; + +const sha256File = async path => { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +}; + +const readFinalAssetSet = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + if (entries.some(entry => !entry.isFile())) throw new Error('Final release directory may contain only regular files'); + const names = entries.map(entry => entry.name).sort(); + if (new Set(names).size !== names.length || names.some(name => basename(name) !== name)) { + throw new Error('Final release directory contains duplicate or invalid asset names'); + } + const checksumLines = (await readFile(join(directory, CHECKSUM_FILE), 'utf8')).split(/\r?\n/).filter(Boolean); + const checksums = new Map(); + for (const line of checksumLines) { + const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)$/.exec(line); + if (!match || checksums.has(match[2]) || match[2] === CHECKSUM_FILE) { + throw new Error('Finalized SHA256SUMS contains an invalid or duplicate asset'); + } + checksums.set(match[2], match[1]); + } + if (checksums.size === 0 || REQUIRED_METADATA.some(name => !checksums.has(name))) { + throw new Error('Finalized SHA256SUMS does not cover the signed release metadata'); + } + const expectedNames = [...checksums.keys(), CHECKSUM_FILE].sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { + throw new Error('Final release directory does not exactly match the finalized checksum allowlist'); + } + const assets = new Map(); + for (const name of names) { + const path = join(directory, name); + const details = await stat(path); + if (!details.isFile() || details.size <= 0) throw new Error(`Final release asset ${name} must be a nonempty regular file`); + const digest = await sha256File(path); + if (name !== CHECKSUM_FILE && digest !== checksums.get(name)) { + throw new Error(`Final release asset ${name} does not match finalized checksums`); + } + assets.set(name, { name, path, size: details.size, sha256: digest }); + } + return assets; +}; + +const githubRequest = async ({ + fetchImpl, + apiUrl, + repository, + token, + path, + method = 'GET', + json, + body, + headers = {}, + allowNotFound = false, + expectedStatus, +}) => { + const url = path.startsWith('https://') ? path : `${apiUrl}/repos/${repository}${path}`; + const response = await fetchImpl(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(json === undefined ? {} : { 'Content-Type': 'application/json' }), + ...headers, + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + ...(body === undefined ? {} : { body, duplex: 'half' }), + }); + if (allowNotFound && response.status === 404) return undefined; + if (!response.ok || (expectedStatus !== undefined && response.status !== expectedStatus)) { + throw new Error(`GitHub API ${method} ${path} failed with HTTP ${response.status}`); + } + return response; +}; + +const assertApprovedTag = async ({ requestJson, tag, releaseSha, tagObjectSha }) => { + const encodedTag = encodeURIComponent(tag); + const ref = await requestJson(`/git/ref/tags/${encodedTag}`); + if (ref?.object?.sha !== tagObjectSha) throw new Error('Desktop release tag object drifted from preflight approval'); + const commit = await requestJson(`/commits/${encodedTag}`); + if (commit?.sha !== releaseSha) throw new Error('Desktop release tag commit drifted from preflight approval'); +}; + +const assertDraftRelease = (release, tag) => { + if (!Number.isSafeInteger(release?.id) + || release.tag_name !== tag + || release.draft !== true + || release.prerelease !== false + || release.published_at != null + || typeof release.upload_url !== 'string') { + throw new Error('Existing GitHub release is not the exact recoverable draft for the approved tag'); + } +}; + +const listReleaseAssets = async (requestJson, releaseId) => { + const assets = []; + for (let page = 1; ; page += 1) { + const result = await requestJson(`/releases/${releaseId}/assets?per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Array.isArray(result)) throw new Error('GitHub release assets response is ambiguous'); + assets.push(...result); + if (result.length < API_PAGE_SIZE) return assets; + } +}; + +const digestResponse = async (response, expectedSize, name) => { + const declaredLength = response.headers?.get?.('content-length'); + if (declaredLength !== null && declaredLength !== undefined && Number(declaredLength) !== expectedSize) { + throw new Error(`GitHub release asset ${name} has an unexpected content length`); + } + if (!response.body) throw new Error(`GitHub release asset ${name} has no downloadable body`); + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > expectedSize) throw new Error(`GitHub release asset ${name} exceeds its expected size`); + hash.update(chunk); + } + if (size !== expectedSize) throw new Error(`GitHub release asset ${name} has an unexpected size`); + return hash.digest('hex'); +}; + +const verifyRemoteAssets = async ({ request, requestJson, releaseId, expected, allowSubset, apiOrigin }) => { + const remote = await listReleaseAssets(requestJson, releaseId); + const seen = new Set(); + for (const asset of remote) { + if (!Number.isSafeInteger(asset?.id) || typeof asset.name !== 'string' || seen.has(asset.name)) { + throw new Error('GitHub release contains duplicate or ambiguous assets'); + } + seen.add(asset.name); + const local = expected.get(asset.name); + if (!local) throw new Error(`GitHub release contains unexpected asset ${asset.name}`); + if (asset.state !== 'uploaded' || asset.size !== local.size || typeof asset.url !== 'string') { + throw new Error(`GitHub release asset ${asset.name} metadata does not match the finalized asset`); + } + let assetUrl; + try { assetUrl = new URL(asset.url); } catch { throw new Error(`GitHub release asset ${asset.name} has an invalid API URL`); } + if (assetUrl.origin !== apiOrigin) throw new Error(`GitHub release asset ${asset.name} has an untrusted API URL`); + if (asset.digest != null && asset.digest !== `sha256:${local.sha256}`) { + throw new Error(`GitHub release asset ${asset.name} digest metadata does not match finalized checksums`); + } + const download = await request(asset.url, { headers: { Accept: 'application/octet-stream' } }); + if (await digestResponse(download, local.size, asset.name) !== local.sha256) { + throw new Error(`GitHub release asset ${asset.name} content digest does not match finalized checksums`); + } + } + if (!allowSubset && (seen.size !== expected.size || [...expected.keys()].some(name => !seen.has(name)))) { + throw new Error(`GitHub release asset set is incomplete: expected ${expected.size}, found ${seen.size}`); + } + return seen; +}; + +export const publishDesktopRelease = async ({ + repository, + tag, + releaseSha, + tagObjectSha, + directory, + token, + apiUrl = 'https://api.github.com', + fetchImpl = fetch, +}) => { + if (!repository?.includes('/') || !VERSIONED_TAG_PATTERN.test(tag) || !SHA_PATTERN.test(releaseSha) + || !SHA_PATTERN.test(tagObjectSha) || !token) { + throw new Error('Desktop release publication inputs are invalid'); + } + const finalDirectory = resolve(directory); + const expected = await readFinalAssetSet(finalDirectory); + const apiOrigin = new URL(apiUrl).origin; + const baseOptions = { fetchImpl, apiUrl, repository, token }; + const request = (path, options = {}) => githubRequest({ ...baseOptions, path, ...options }); + const requestJson = async (path, options = {}) => { + const response = await request(path, options); + return response === undefined ? undefined : response.json(); + }; + + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + const encodedTag = encodeURIComponent(tag); + let release = await requestJson(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (release === undefined) { + release = await requestJson('/releases', { + method: 'POST', + expectedStatus: 201, + json: { + tag_name: tag, + target_commitish: releaseSha, + name: `ProPR Desktop ${tag}`, + draft: true, + prerelease: false, + generate_release_notes: true, + }, + }); + } + assertDraftRelease(release, tag); + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + + const uploaded = await verifyRemoteAssets({ + request, requestJson, releaseId: release.id, expected, allowSubset: true, apiOrigin, + }); + const uploadBase = release.upload_url.replace(/\{.*$/, ''); + let uploadOrigin; + try { uploadOrigin = new URL(uploadBase).origin; } catch { throw new Error('GitHub release returned an invalid asset upload URL'); } + const allowedUploadOrigins = new Set([apiOrigin]); + if (apiOrigin === 'https://api.github.com') allowedUploadOrigins.add('https://uploads.github.com'); + if (!allowedUploadOrigins.has(uploadOrigin)) throw new Error('GitHub release returned an untrusted asset upload URL'); + for (const asset of expected.values()) { + if (uploaded.has(asset.name)) continue; + const uploadUrl = new URL(uploadBase); + uploadUrl.searchParams.set('name', asset.name); + await request(uploadUrl.toString(), { + method: 'POST', + expectedStatus: 201, + body: createReadStream(asset.path), + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(asset.size), + }, + }); + } + + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + await verifyRemoteAssets({ + request, requestJson, releaseId: release.id, expected, allowSubset: false, apiOrigin, + }); + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + const published = await requestJson(`/releases/${release.id}`, { + method: 'PATCH', + json: { draft: false }, + }); + if (published?.id !== release.id || published.tag_name !== tag || published.draft !== false || !published.published_at) { + throw new Error('GitHub did not confirm publication of the exact verified draft release'); + } + return published; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + await publishDesktopRelease({ + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.RELEASE_TAG, + releaseSha: process.env.RELEASE_SHA, + tagObjectSha: process.env.TAG_OBJECT_SHA, + directory: process.env.RELEASE_DIRECTORY || 'desktop-release-final', + token: process.env.GITHUB_TOKEN, + apiUrl: process.env.GITHUB_API_URL, + }); +} diff --git a/apps/desktop/scripts/release-publish.test.mjs b/apps/desktop/scripts/release-publish.test.mjs new file mode 100644 index 000000000..f08fa2bd0 --- /dev/null +++ b/apps/desktop/scripts/release-publish.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { describe, test } from 'node:test'; +import { publishDesktopRelease } from './release-publish.mjs'; + +const releaseSha = '1'.repeat(40); +const tagObjectSha = '2'.repeat(40); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +const createFinalAssets = async (extraCount = 1) => { + const directory = await mkdtemp(join(tmpdir(), 'propr-publish-')); + const files = new Map([ + ['desktop-release.json', Buffer.from('{}\n')], + ['desktop-release.json.sig', Buffer.from('signed\n')], + ]); + for (let index = 0; index < extraCount; index += 1) { + files.set(`ProPR-Desktop-asset-${String(index).padStart(3, '0')}.bin`, Buffer.from(`asset-${index}\n`)); + } + const checksums = [...files] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, bytes]) => `${sha256(bytes)} ${name}`) + .join('\n'); + files.set('SHA256SUMS', Buffer.from(`${checksums}\n`)); + for (const [name, bytes] of files) await writeFile(join(directory, name), bytes); + return { directory, files }; +}; + +const response = ({ status = 200, value, bytes }) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => value, + body: bytes === undefined ? undefined : Readable.from([bytes]), + headers: new Headers(bytes === undefined ? {} : { 'content-length': String(bytes.length) }), +}); + +const createGitHub = ({ seedAssets = [], failUploadAt, driftAfterTagChecks } = {}) => { + const state = { + release: undefined, + assets: seedAssets.map(asset => ({ ...asset })), + calls: [], + patchCalls: 0, + uploadCalls: 0, + failUploadAt, + tagChecks: 0, + }; + const draft = () => ({ + id: 7, + tag_name: 'desktop-v1.2.3', + draft: true, + prerelease: false, + published_at: null, + upload_url: 'https://uploads.github.com/releases/7/assets{?name,label}', + }); + if (seedAssets.length) state.release = draft(); + state.fetchImpl = async (url, options = {}) => { + const parsed = new URL(url); + const path = parsed.pathname.replace('/repos/integry/propr', ''); + const method = options.method ?? 'GET'; + state.calls.push(`${method} ${path}${parsed.search}`); + if (path === '/git/ref/tags/desktop-v1.2.3') { + state.tagChecks += 1; + const drifted = driftAfterTagChecks && state.tagChecks >= driftAfterTagChecks; + return response({ value: { object: { sha: drifted ? '3'.repeat(40) : tagObjectSha } } }); + } + if (path === '/commits/desktop-v1.2.3') return response({ value: { sha: releaseSha } }); + if (path === '/releases/tags/desktop-v1.2.3') { + return state.release ? response({ value: state.release }) : response({ status: 404 }); + } + if (path === '/releases' && method === 'POST') { + const input = JSON.parse(options.body); + assert.equal(input.draft, true); + assert.equal(input.tag_name, 'desktop-v1.2.3'); + assert.equal(input.target_commitish, releaseSha); + state.release = draft(); + return response({ status: 201, value: state.release }); + } + if (path === '/releases/7/assets' && method === 'GET') { + const page = Number(parsed.searchParams.get('page')); + return response({ value: state.assets.slice((page - 1) * 100, page * 100) }); + } + if (parsed.host === 'uploads.github.com' && method === 'POST') { + state.uploadCalls += 1; + if (state.failUploadAt === state.uploadCalls) return response({ status: 500 }); + const chunks = []; + for await (const chunk of options.body) chunks.push(chunk); + const bytes = Buffer.concat(chunks); + const name = parsed.searchParams.get('name'); + const asset = { + id: state.assets.length + 1, + name, + state: 'uploaded', + size: bytes.length, + digest: `sha256:${sha256(bytes)}`, + url: `https://api.github.com/assets/${state.assets.length + 1}`, + bytes, + }; + state.assets.push(asset); + return response({ status: 201, value: asset }); + } + if (parsed.host === 'api.github.com' && path.startsWith('/assets/')) { + const asset = state.assets.find(candidate => candidate.url === url); + return asset ? response({ bytes: asset.bytes }) : response({ status: 404 }); + } + if (path === '/releases/7' && method === 'PATCH') { + state.patchCalls += 1; + state.release = { ...state.release, draft: false, published_at: '2026-08-29T00:00:00Z' }; + return response({ value: state.release }); + } + throw new Error(`Unexpected request: ${method} ${url}`); + }; + return state; +}; + +const publish = ({ directory, fetchImpl }) => publishDesktopRelease({ + repository: 'integry/propr', + tag: 'desktop-v1.2.3', + releaseSha, + tagObjectSha, + directory, + token: 'token', + apiUrl: 'https://api.github.com', + fetchImpl, +}); + +describe('atomic desktop release publication', () => { + test('creates a draft, paginates and verifies the exact final assets, then publishes', async () => { + const { directory } = await createFinalAssets(101); + const github = createGitHub(); + const result = await publish({ directory, fetchImpl: github.fetchImpl }); + assert.equal(result.draft, false); + assert.equal(github.patchCalls, 1); + assert.equal(github.assets.length, 104); + assert(github.calls.includes('GET /releases/7/assets?per_page=100&page=2')); + assert(github.calls.lastIndexOf('GET /git/ref/tags/desktop-v1.2.3') < github.calls.indexOf('PATCH /releases/7')); + }); + + test('leaves a partial upload as a recoverable draft and resumes only matching assets', async () => { + const { directory, files } = await createFinalAssets(2); + const github = createGitHub({ failUploadAt: 2 }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /failed with HTTP 500/); + assert.equal(github.release.draft, true); + assert.equal(github.patchCalls, 0); + assert.equal(github.assets.length, 1); + + github.failUploadAt = undefined; + await publish({ directory, fetchImpl: github.fetchImpl }); + assert.equal(github.release.draft, false); + assert.equal(github.assets.length, files.size); + assert.equal(new Set(github.assets.map(asset => asset.name)).size, files.size); + }); + + test('rejects unexpected, duplicate, size, and content-digest asset mismatches without publishing', async () => { + const { directory, files } = await createFinalAssets(); + const [name, bytes] = [...files].find(([candidate]) => candidate !== 'SHA256SUMS'); + const matching = { + id: 1, + name, + state: 'uploaded', + size: bytes.length, + digest: `sha256:${sha256(bytes)}`, + url: 'https://api.github.com/assets/1', + bytes, + }; + const cases = [ + [{ ...matching, name: 'unexpected.bin' }], + [matching, { ...matching, id: 2, url: 'https://api.github.com/assets/2' }], + [{ ...matching, size: bytes.length + 1 }], + [{ ...matching, bytes: Buffer.from('x'.repeat(bytes.length)), digest: `sha256:${sha256(bytes)}` }], + ]; + for (const assets of cases) { + const github = createGitHub({ seedAssets: assets }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /unexpected|duplicate|metadata|content digest/); + assert.equal(github.patchCalls, 0); + assert.equal(github.release.draft, true); + } + }); + + test('rejects tag drift before publishing the verified draft', async () => { + const { directory } = await createFinalAssets(); + const github = createGitHub({ driftAfterTagChecks: 4 }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /tag object drifted/); + assert.equal(github.patchCalls, 0); + assert.equal(github.release.draft, true); + }); + + test('rejects local files outside or missing from finalized checksums', async () => { + const { directory } = await createFinalAssets(); + await writeFile(join(directory, 'unexpected.bin'), 'unexpected'); + const github = createGitHub(); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /checksum allowlist/); + assert.equal(github.calls.length, 0); + }); +}); diff --git a/apps/desktop/scripts/run-bounded-darwin-command.mjs b/apps/desktop/scripts/run-bounded-darwin-command.mjs new file mode 100644 index 000000000..0a1a53a23 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node + +import { spawn as nodeSpawn } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; +const EXIT_FOR_SIGNAL = new Map([['SIGHUP', 129], ['SIGINT', 130], ['SIGTERM', 143]]); +const GROUP_GUARD_ARGUMENT = '--internal-process-group-guard'; +const GROUP_GUARD_RELEASE = 'release-process-group-guard'; +const GROUP_GUARD_RESULT = 'process-group-command-result'; + +const trustedCommandId = executable => { + switch (executable) { + case 'bash': + case '/bin/bash': return 'bash'; + case 'codesign': + case '/usr/bin/codesign': return 'codesign'; + case 'mktemp': + case '/usr/bin/mktemp': return 'mktemp'; + case 'node': return 'node'; + case 'openssl': + case '/usr/bin/openssl': return 'openssl'; + case 'rm': + case '/bin/rm': return 'rm'; + case 'security': + case '/usr/bin/security': return 'security'; + default: + if (executable === process.execPath) return 'node'; + throw new BoundedProcessError('invalid-input'); + } +}; + +// Keep every executable literal at the process-creation boundary. The identifier can come +// from the CLI or the guard's argv, but it can only select one of these fixed programs. +const spawnTrustedCommand = (spawn, commandId, arguments_, options) => { + switch (commandId) { + case 'bash': return spawn('/bin/bash', arguments_, options); + case 'codesign': return spawn('/usr/bin/codesign', arguments_, options); + case 'mktemp': return spawn('/usr/bin/mktemp', arguments_, options); + case 'node': return spawn(process.execPath, arguments_, options); + case 'openssl': return spawn('/usr/bin/openssl', arguments_, options); + case 'rm': return spawn('/bin/rm', arguments_, options); + case 'security': return spawn('/usr/bin/security', arguments_, options); + default: throw new BoundedProcessError('invalid-input'); + } +}; + +export class BoundedProcessError extends Error { + constructor(reason, result) { + super(`bounded-process-${reason}`); + this.name = 'BoundedProcessError'; + this.reason = reason; + this.result = result; + } +} + +const appendBounded = (chunks, chunk, state, maximumBytes, forward) => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const available = Math.max(0, maximumBytes - state.bytes); + const accepted = value.subarray(0, available); + if (accepted.length > 0) { + chunks.push(accepted); + state.bytes += accepted.length; + forward?.write(accepted); + } + if (accepted.length !== value.length) state.truncated = true; +}; + +const signalProcessGroup = (child, signal, platform) => { + if (!child?.pid) return; + try { + if (platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +}; + +const runProcessGroupGuard = async argv => { + if (argv[0] !== '--' || typeof argv[1] !== 'string' || argv[1].length === 0) { + process.exitCode = 1; + return; + } + + // The guard is the process-group leader and deliberately survives TERM. Keeping its PID + // occupied until its supervisor releases or kills it prevents the PGID from being reused + // while a TERM-ignoring descendant may still belong to the group. + const ignoredSignals = [...EXIT_FOR_SIGNAL.keys()]; + const ignoreSignal = () => {}; + for (const signal of ignoredSignals) process.on(signal, ignoreSignal); + + let commandResult; + let resultPublished = false; + const publishResult = result => { + if (resultPublished) return; + resultPublished = true; + commandResult = result; + if (process.send) { + process.send({ type: GROUP_GUARD_RESULT, ...result }, () => {}); + } + }; + + let command; + try { + command = spawnTrustedCommand(nodeSpawn, argv[1], argv.slice(2), { + detached: false, + shell: false, + windowsHide: true, + stdio: ['ignore', 'inherit', 'inherit'], + }); + } catch { + publishResult({ exitCode: 1, signal: null, spawnError: true }); + return; + } + command.once('error', () => publishResult({ + exitCode: 1, signal: null, spawnError: true, + })); + command.once('close', (exitCode, signal) => publishResult({ exitCode, signal })); + + process.on('message', message => { + if (message?.type !== GROUP_GUARD_RELEASE || !commandResult) return; + process.exitCode = commandResult.exitCode ?? 1; + process.disconnect?.(); + }); +}; + +export const runBoundedProcess = async ({ + executable, + arguments: arguments_ = [], + timeoutMs, + terminationGraceMs = 5_000, + maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, + forwardOutput = false, + spawn = nodeSpawn, + platform = process.platform, + onSpawn, + signalSource = process, +}) => { + if (typeof executable !== 'string' || executable.length === 0 + || !Array.isArray(arguments_) || !arguments_.every(argument => typeof argument === 'string') + || !Number.isInteger(timeoutMs) || timeoutMs <= 0 + || !Number.isInteger(terminationGraceMs) || terminationGraceMs <= 0 + || !Number.isInteger(maxOutputBytes) || maxOutputBytes <= 0) { + throw new BoundedProcessError('invalid-input'); + } + const commandId = trustedCommandId(executable); + + const stdoutChunks = []; + const stderrChunks = []; + const stdoutState = { bytes: 0, truncated: false }; + const stderrState = { bytes: 0, truncated: false }; + let primaryReason; + let requestedSignal; + let forceTimer; + let drainTimer; + let timeout; + let child; + let childClosed; + let commandResult; + let commandSpawnFailed = false; + let resolveForcedSettlement; + const forcedSettlement = new Promise(resolve => { resolveForcedSettlement = resolve; }); + + const requestTermination = reason => { + if (!primaryReason) primaryReason = reason; + try { + signalProcessGroup(child, 'SIGTERM', platform); + } catch { + // The primary failure remains the timeout/signal even if termination reports a race. + } + if (!forceTimer) { + forceTimer = setTimeout(() => { + try { + signalProcessGroup(child, 'SIGKILL', platform); + } catch { + // A failed final kill is reflected by the bounded supervisor exit, without arguments. + } + drainTimer = setTimeout(() => resolveForcedSettlement({ + exitCode: null, signal: 'SIGKILL', drainTimedOut: true, + }), 1_000); + }, terminationGraceMs); + } + }; + + const signalHandlers = new Map(); + for (const signal of EXIT_FOR_SIGNAL.keys()) { + const handler = () => { + requestedSignal ??= signal; + requestTermination('signal'); + }; + signalHandlers.set(signal, handler); + signalSource.on(signal, handler); + } + + try { + const guardProcessGroup = platform !== 'win32'; + const spawnOptions = { + detached: platform !== 'win32', + shell: false, + windowsHide: true, + stdio: guardProcessGroup + ? ['ignore', 'pipe', 'pipe', 'ipc'] + : ['ignore', 'pipe', 'pipe'], + }; + child = guardProcessGroup + ? spawn(process.execPath, [ + fileURLToPath(import.meta.url), GROUP_GUARD_ARGUMENT, '--', commandId, ...arguments_, + ], spawnOptions) + : spawnTrustedCommand(spawn, commandId, arguments_, spawnOptions); + const processError = new Promise(resolve => { + child.once('error', error => resolve({ operationError: error })); + }); + childClosed = new Promise(resolve => { + child.once('close', (exitCode, signal) => resolve(commandResult ?? { exitCode, signal })); + }); + if (guardProcessGroup) { + child.on('message', message => { + if (message?.type !== GROUP_GUARD_RESULT || commandResult) return; + commandResult = { exitCode: message.exitCode, signal: message.signal }; + commandSpawnFailed = message.spawnError === true; + if (primaryReason) return; + if (commandSpawnFailed) requestTermination('spawn-or-io'); + else if (commandResult.exitCode !== 0 || commandResult.signal) requestTermination('exit'); + else { + // Only success releases the guard. Every failure retains the PGID through SIGKILL. + child.send({ type: GROUP_GUARD_RELEASE }, () => {}); + } + }); + } + child.stdout?.on('data', chunk => appendBounded( + stdoutChunks, chunk, stdoutState, maxOutputBytes, + forwardOutput ? process.stdout : undefined, + )); + child.stderr?.on('data', chunk => appendBounded( + stderrChunks, chunk, stderrState, maxOutputBytes, + forwardOutput ? process.stderr : undefined, + )); + + onSpawn?.(child); + if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); + timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); + const settlement = await Promise.race([ + childClosed, processError, forcedSettlement, + ]) + .finally(() => clearTimeout(timeout)); + if ('operationError' in settlement) throw settlement.operationError; + const result = settlement; + if (forceTimer) clearTimeout(forceTimer); + if (drainTimer) clearTimeout(drainTimer); + if (result.drainTimedOut) { + try { child.disconnect?.(); } catch { /* The IPC channel may already be closed. */ } + child.channel?.unref?.(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + } + + const completed = { + ...result, + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + stdoutTruncated: stdoutState.truncated, + stderrTruncated: stderrState.truncated, + requestedSignal, + }; + if (primaryReason) throw new BoundedProcessError(primaryReason, completed); + if (result.exitCode !== 0) throw new BoundedProcessError('exit', completed); + return completed; + } catch (error) { + if (child?.pid && !primaryReason) requestTermination('spawn-or-io'); + if (child?.pid && childClosed && forceTimer) { + // The guard ignores TERM, so this settles only after SIGKILL or the final drain bound. + await Promise.race([childClosed, forcedSettlement]); + } + if (error instanceof BoundedProcessError) throw error; + throw new BoundedProcessError('spawn-or-io', { cause: error }); + } finally { + if (timeout) clearTimeout(timeout); + if (forceTimer) clearTimeout(forceTimer); + if (drainTimer) clearTimeout(drainTimer); + for (const [signal, handler] of signalHandlers) signalSource.off(signal, handler); + } +}; + +const parseCli = argv => { + const separator = argv.indexOf('--'); + if (separator < 0 || separator === argv.length - 1) throw new Error('invalid-cli'); + const options = argv.slice(0, separator); + const command = argv.slice(separator + 1); + const parsed = { + timeoutMs: undefined, + terminationGraceMs: 5_000, + maxOutputBytes: DEFAULT_MAX_OUTPUT_BYTES, + forwardOutput: false, + stdoutFile: undefined, + }; + for (let index = 0; index < options.length; index += 2) { + const option = options[index]; + const value = options[index + 1]; + if (value === undefined) throw new Error('invalid-cli'); + if (option === '--timeout-ms') parsed.timeoutMs = Number(value); + else if (option === '--termination-grace-ms') parsed.terminationGraceMs = Number(value); + else if (option === '--max-output-bytes') parsed.maxOutputBytes = Number(value); + else if (option === '--forward-output') parsed.forwardOutput = value === 'true'; + else if (option === '--stdout-file') parsed.stdoutFile = value; + else throw new Error('invalid-cli'); + } + return { ...parsed, executable: command[0], arguments: command.slice(1) }; +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + if (process.argv[2] === GROUP_GUARD_ARGUMENT) { + await runProcessGroupGuard(process.argv.slice(3)); + } else try { + const options = parseCli(process.argv.slice(2)); + const result = await runBoundedProcess(options); + if (options.stdoutFile) { + await writeFile(options.stdoutFile, result.stdout, { encoding: 'utf8', mode: 0o600 }); + } + } catch (error) { + if (error instanceof BoundedProcessError && error.reason === 'timeout') { + process.stderr.write('Bounded Darwin operation timed out.\n'); + process.exitCode = 124; + } else if (error instanceof BoundedProcessError && error.reason === 'signal') { + process.exitCode = EXIT_FOR_SIGNAL.get(error.result?.requestedSignal) ?? 1; + } else { + process.stderr.write('Bounded Darwin operation failed.\n'); + process.exitCode = error instanceof BoundedProcessError && error.reason === 'exit' + ? (error.result.exitCode ?? 1) : 1; + } + } +} diff --git a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs new file mode 100644 index 000000000..bef1a5db2 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { execFile as nodeExecFile } from 'node:child_process'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { BoundedProcessError, runBoundedProcess } from './run-bounded-darwin-command.mjs'; + +const helperPath = join(dirname(fileURLToPath(import.meta.url)), 'run-bounded-darwin-command.mjs'); + +const waitForProcessExit = async processId => { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + process.kill(processId, 0); + if (process.platform === 'linux') { + const processState = (await readFile(`/proc/${processId}/stat`, 'utf8')).split(' ')[2]; + if (processState === 'Z') return; + } + await delay(20); + } catch (error) { + if (error?.code === 'ESRCH') return; + throw error; + } + } + assert.fail('timed-out descendant process remained alive'); +}; + +test('bounds output while continuously draining both child streams', async () => { + const result = await runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', 'process.stdout.write("A".repeat(8192)); process.stderr.write("B".repeat(8192));'], + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }); + assert.equal(Buffer.byteLength(result.stdout), 1_024); + assert.equal(Buffer.byteLength(result.stderr), 1_024); + assert.equal(result.stdoutTruncated, true); + assert.equal(result.stderrTruncated, true); +}); + +test('timeout terminates the owned process group including a descendant', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-bound-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });', + 'writeFileSync(process.argv[1], String(child.pid));', + 'setInterval(() => {}, 1000);', + ].join(' '), descendantPidPath], + timeoutMs: 300, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError && error.reason === 'timeout'); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('SIGKILL escalation survives leader close and removes a TERM-ignoring descendant', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-escalation-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { spawn } = require("node:child_process");', + 'process.on("SIGTERM", () => process.exit(0));', + 'spawn(process.execPath, ["-e", [', + ' "const { writeFileSync } = require(\\"node:fs\\");",', + ' "process.on(\\"SIGTERM\\", () => {});",', + ' "writeFileSync(process.argv[1], String(process.pid));",', + ' "setInterval(() => {}, 1000);",', + '].join(" "), process.argv[1]], { stdio: "ignore" });', + 'setInterval(() => {}, 1000);', + ].join(' '), descendantPidPath], + timeoutMs: 500, + terminationGraceMs: 150, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'timeout' + && error.result.exitCode === 0); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('timeout remains primary while TERM runs the wrapper cleanup', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-cleanup-')); + const cleanupPath = join(fixtureRoot, 'cleanup.txt'); + try { + await assert.rejects(runBoundedProcess({ + executable: '/bin/bash', + arguments: ['-c', [ + 'trap \"printf CLEANED > \\\"$1\\\"; exit 143\" TERM', + 'sleep 30 &', + 'wait', + ].join('\n'), 'bash', cleanupPath], + timeoutMs: 300, + terminationGraceMs: 1_000, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'timeout' + && error.result.exitCode === 143); + assert.equal(await readFile(cleanupPath, 'utf8'), 'CLEANED'); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('a command failure is not replaced by timeout or cleanup status', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-primary-')); + const cleanupPath = join(fixtureRoot, 'cleanup.txt'); + try { + await assert.rejects(runBoundedProcess({ + executable: '/bin/bash', + arguments: ['-c', 'trap \"printf CLEANED > \\\"$1\\\"\" EXIT; exit 23', 'bash', cleanupPath], + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'exit' + && error.result.exitCode === 23); + assert.equal(await readFile(cleanupPath, 'utf8'), 'CLEANED'); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('nonzero exit escalates against a TERM-ignoring descendant before releasing the guard', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-nonzero-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { existsSync } = require("node:fs");', + 'const { spawn } = require("node:child_process");', + 'spawn(process.execPath, ["-e", [', + ' "const { writeFileSync } = require(\\"node:fs\\");",', + ' "process.on(\\"SIGTERM\\", () => {});",', + ' "writeFileSync(process.argv[1], String(process.pid));",', + ' "setInterval(() => {}, 1000);",', + '].join(" "), process.argv[1]], { stdio: "ignore" });', + 'const waitState = new Int32Array(new SharedArrayBuffer(4));', + 'const deadline = Date.now() + 1000;', + 'while (!existsSync(process.argv[1]) && Date.now() < deadline) Atomics.wait(waitState, 0, 0, 10);', + 'process.exit(existsSync(process.argv[1]) ? 23 : 24);', + ].join(' '), descendantPidPath], + timeoutMs: 2_000, + terminationGraceMs: 150, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'exit' + && error.result.exitCode === 23); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('rejects executable substitution before creating a child process', async () => { + let spawnCalled = false; + await assert.rejects(runBoundedProcess({ + executable: join(tmpdir(), 'propr-command-that-does-not-exist'), + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + spawn: () => { + spawnCalled = true; + throw new Error('unexpected-spawn'); + }, + }), error => error instanceof BoundedProcessError + && error.reason === 'invalid-input'); + assert.equal(spawnCalled, false); +}); + +test('passes shell metacharacters as one inert argument', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-metacharacters-')); + const injectedPath = join(fixtureRoot, 'injected.txt'); + const argument = `; touch ${injectedPath}; $(printf injected) &`; + try { + const result = await runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', 'process.stdout.write(process.argv[1])', argument], + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }); + assert.equal(result.stdout, argument); + await assert.rejects(readFile(injectedPath, 'utf8'), { code: 'ENOENT' }); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('CLI command selection ignores PATH and rejects non-allowlisted executables', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-environment-')); + const fakeNodePath = join(fixtureRoot, 'node'); + const maliciousMarker = join(fixtureRoot, 'malicious.txt'); + const intendedMarker = join(fixtureRoot, 'intended.txt'); + const substitutedExecutable = join(fixtureRoot, 'substituted'); + try { + await writeFile(fakeNodePath, [ + `#!${process.execPath}`, + `require('node:fs').writeFileSync(${JSON.stringify(maliciousMarker)}, 'MALICIOUS');`, + ].join('\n'), { mode: 0o700 }); + await chmod(fakeNodePath, 0o700); + + await new Promise((resolve, reject) => { + nodeExecFile(process.execPath, [ + helperPath, + '--timeout-ms', '2000', + '--termination-grace-ms', '100', + '--max-output-bytes', '1024', + '--forward-output', 'false', + '--', 'node', '-e', + `require('node:fs').writeFileSync(${JSON.stringify(intendedMarker)}, 'INTENDED')`, + ], { env: { ...process.env, PATH: fixtureRoot } }, error => { + if (error) reject(error); + else resolve(); + }); + }); + assert.equal(await readFile(intendedMarker, 'utf8'), 'INTENDED'); + await assert.rejects(readFile(maliciousMarker, 'utf8'), { code: 'ENOENT' }); + + await writeFile(substitutedExecutable, `#!${process.execPath}\n`, { mode: 0o700 }); + await assert.rejects(new Promise((resolve, reject) => { + nodeExecFile(process.execPath, [ + helperPath, + '--timeout-ms', '2000', + '--', substitutedExecutable, + ], { env: { ...process.env, PATH: fixtureRoot } }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve(); + }); + }), error => { + assert.equal(error.code, 1); + assert.equal(error.stdout, ''); + assert.equal(error.stderr, 'Bounded Darwin operation failed.\n'); + return true; + }); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('CLI timeout diagnostics never echo command arguments or secret values', async () => { + const secretArgument = 'DO_NOT_PRINT_THIS_SECRET'; + await assert.rejects(new Promise((resolve, reject) => { + nodeExecFile(process.execPath, [ + helperPath, + '--timeout-ms', '200', + '--termination-grace-ms', '100', + '--max-output-bytes', '1024', + '--forward-output', 'false', + '--', process.execPath, '-e', 'setInterval(() => {}, 1000)', secretArgument, + ], { encoding: 'utf8', timeout: 2_000 }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve(); + }); + }), error => { + assert.equal(error.code, 124); + assert.equal(error.stdout, ''); + assert.equal(error.stderr, 'Bounded Darwin operation timed out.\n'); + assert.doesNotMatch(`${error.stdout}${error.stderr}`, new RegExp(secretArgument, 'u')); + return true; + }); +}); diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 new file mode 100644 index 000000000..5d623555e --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -0,0 +1,1276 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [string]$WorkerPath, + [ValidateRange(1,60000)][int]$BootstrapTimeoutMilliseconds = 60 * 1000, + [ValidateRange(1,10000)][int]$WatchdogPollMilliseconds = 250, + [ValidateRange(1,30000)][int]$WatchdogTerminationMilliseconds = 30 * 1000, + [ValidateRange(1000,600000)][int]$PostTerminationCleanupMilliseconds = 4 * 60 * 1000, + [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, + [string]$CancellationEventName, + [string]$FixtureCleanupRoot, + [string]$OwnershipManifest, + [string]$ExpectedRunId, + [switch]$InjectTerminationFailure +) + +$ErrorActionPreference = 'Stop' +$maximumMarkerDeadlineMilliseconds = 11 * 60 * 1000 +$msiCriticalTransactionGraceMilliseconds = 30 * 1000 +$watchdogStages = @( + 'INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP' +) +$watchdogSubstages = @( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK' +) +$markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" +$markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName +$generatedRunId = [Guid]::NewGuid().ToString('N') +$ownershipManifestName = "propr-installed-app-ownership-$generatedRunId.json" +$ownershipManifestPath = Join-Path ([IO.Path]::GetTempPath()) $ownershipManifestName +$workflowManagedManifest = $false +$ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" +$productionWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' +$cleanupWorkerPath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' +$worker = $null +$job = $null +$ownershipReadyEvent = $null +$cancellationEvent = $null +$lastValidMarker = $null +$exitCode = 125 +$terminateOwnedTree = $false +$workerStarted = $false +$supervisorOutcomeComplete = $false +$postTerminationCleanupAuthorized = $true +$fixtureNoMarkerDiagnostic = $false +$fixtureWindowsPowerShellCleanup = $false +$fixtureWorkerTreeTerminationOutcome = 'FAILED' +$fixtureCleanupChildExitCategory = 'OTHER' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRKillOnCloseJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, + int informationClass, + IntPtr information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, + int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, + IntPtr returnLength); + + public ProPRKillOnCloseJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "worker ownership failed"); + } + + private uint ReadActiveProcessCount() + { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job accounting failed"); + return information.ActiveProcesses; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job termination failed"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + System.Threading.Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public void Dispose() + { + if (handle != null) handle.Dispose(); + } +} + +public static class ProPRInstallerEntryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity read failed"); + if ((information.FileAttributes & (0x10 | 0x400)) != 0) + throw new InvalidOperationException("installer entry is not an ordinary file"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} + +public enum ProPRMarkerReadState +{ + Missing, + Valid, + Invalid, + Inaccessible +} + +public sealed class ProPRMarkerReadResult +{ + public ProPRMarkerReadState State; + public long Deadline; + public string Stage; + public string Substage; + public string Status; +} + +public static class ProPRBoundedMarkerReader +{ + private const int MaximumMarkerBytes = 256; + private static readonly Regex MarkerPattern = new Regex( + "^(?[0-9]+)\\|(?[A-Z_]+)\\|(?[A-Z_]+)\\|(?BEGIN|COMPLETE|FAILED)$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static Task ReadAsync(string path) + { + return Task.Run(() => Read(path)); + } + + private static ProPRMarkerReadResult Result(ProPRMarkerReadState state) + { + return new ProPRMarkerReadResult { State = state }; + } + + private static ProPRMarkerReadResult Read(string path) + { + try + { + var item = new FileInfo(path); + item.Refresh(); + if (!item.Exists) return Result(ProPRMarkerReadState.Missing); + if ((item.Attributes & FileAttributes.ReparsePoint) != 0 || item.Length <= 0 || + item.Length > MaximumMarkerBytes) + return Result(ProPRMarkerReadState.Invalid); + + int length = checked((int)item.Length); + var bytes = new byte[length]; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 256, FileOptions.SequentialScan)) + { + int offset = 0; + while (offset < length) + { + int read = stream.Read(bytes, offset, length - offset); + if (read == 0) return Result(ProPRMarkerReadState.Invalid); + offset += read; + } + if (stream.ReadByte() != -1) return Result(ProPRMarkerReadState.Invalid); + } + + for (int index = 0; index < bytes.Length; index++) + if (bytes[index] > 0x7f) return Result(ProPRMarkerReadState.Invalid); + string text = Encoding.ASCII.GetString(bytes); + Match match = MarkerPattern.Match(text); + long deadline; + if (!match.Success || !long.TryParse(match.Groups["Deadline"].Value, + NumberStyles.None, CultureInfo.InvariantCulture, out deadline)) + return Result(ProPRMarkerReadState.Invalid); + return new ProPRMarkerReadResult { + State = ProPRMarkerReadState.Valid, + Deadline = deadline, + Stage = match.Groups["Stage"].Value, + Substage = match.Groups["Substage"].Value, + Status = match.Groups["Status"].Value + }; + } + catch (FileNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (DirectoryNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (UnauthorizedAccessException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch (IOException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch { return Result(ProPRMarkerReadState.Invalid); } + } +} + +public sealed class ProPRCleanupDiagnosticDrainResult +{ + public long StandardOutputBytes; + public long StandardOutputLines; + public byte[] StandardOutput; + public long StandardErrorBytes; + public long StandardErrorLines; +} + +public sealed class ProPRCleanupDiagnosticDrain : IDisposable +{ + public const int StandardOutputByteLimit = 96; + public const int StandardOutputLineLimit = 1; + public const int StandardErrorByteLimit = 0; + public const int StandardErrorLineLimit = 0; + + private sealed class PumpResult + { + public long Bytes; + public long Lines; + public byte[] Captured; + } + + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private Stream standardOutput; + private Stream standardError; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump( + Stream stream, + int byteLimit, + int lineLimit, + CancellationToken token) + { + var buffer = new byte[64]; + using (var captured = new MemoryStream(byteLimit + 1)) + { + long bytes = 0; + long lines = 0; + while (true) + { + int count = await stream.ReadAsync( + buffer, 0, buffer.Length, token).ConfigureAwait(false); + if (count == 0) + { + return new PumpResult { + Bytes = bytes, + Lines = lines, + Captured = captured.ToArray() + }; + } + bytes = Math.Min((long)byteLimit + 1, bytes + count); + for (int index = 0; index < count; index++) + if (buffer[index] == (byte)'\n') + lines = Math.Min((long)lineLimit + 1, lines + 1); + int remaining = byteLimit + 1 - checked((int)captured.Length); + if (remaining > 0) + captured.Write(buffer, 0, Math.Min(remaining, count)); + } + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("diagnostic drain was already started"); + standardOutput = process.StandardOutput.BaseStream; + standardError = process.StandardError.BaseStream; + standardOutputTask = Pump( + standardOutput, + StandardOutputByteLimit, + StandardOutputLineLimit, + cancellation.Token); + standardErrorTask = Pump( + standardError, + StandardErrorByteLimit, + StandardErrorLineLimit, + cancellation.Token); + } + + public ProPRCleanupDiagnosticDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("diagnostic drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("diagnostic drain failed"); + PumpResult output = standardOutputTask.Result; + PumpResult error = standardErrorTask.Result; + return new ProPRCleanupDiagnosticDrainResult { + StandardOutputBytes = output.Bytes, + StandardOutputLines = output.Lines, + StandardOutput = output.Captured, + StandardErrorBytes = error.Bytes, + StandardErrorLines = error.Lines + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutput != null) standardOutput.Dispose(); } catch { } + try { if (standardError != null) standardError.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} +'@ + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-MsiProductCode([string]$Path) { + $installerCom = $null + $database = $null + $view = $null + $record = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($Path, 0) + $view = $database.OpenView( + "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") + $view.Execute() + $record = $view.Fetch() + $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } + if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + return $productCode.ToUpperInvariant() + } finally { + foreach ($resource in @($record, $view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } +} + +function Get-InstallerAuthority([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'installer artifact is not an ordinary file' + } + $canonicalPath = (Resolve-Path -LiteralPath $item.FullName -ErrorAction Stop).ProviderPath + $entryIdentity = [ProPRInstallerEntryIdentity]::Read($canonicalPath) + $sha256 = Get-InstallerSha256 $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed before product identity capture' + } + $productCode = Get-MsiProductCode $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed during authority capture' + } + return [PSCustomObject]@{ + Path = $canonicalPath + EntryIdentity = $entryIdentity + Sha256 = $sha256 + ProductCode = $productCode + } +} + +function Test-InstallerArtifactAuthority($Record) { + try { + return [string]$Record.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$Record.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$Record.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + [ProPRInstallerEntryIdentity]::Read([string]$Record.InstallerPath) -ceq + [string]$Record.InstallerEntryIdentity -and + (Get-InstallerSha256 ([string]$Record.InstallerPath)) -ceq + [string]$Record.InstallerSha256 + } catch { + return $false + } +} + +function Write-WatchdogLine([string]$Line) { + Write-Host $Line + [Console]::Out.Flush() +} + +function Read-WatchdogMarker([string]$Path, [int]$TimeoutMilliseconds) { + $readTask = [ProPRBoundedMarkerReader]::ReadAsync($Path) + if (!$readTask.Wait($TimeoutMilliseconds)) { + return [PSCustomObject]@{ State = 'TimedOut' } + } + $result = $readTask.Result + if ($result.State -ne [ProPRMarkerReadState]::Valid) { + return [PSCustomObject]@{ State = $result.State.ToString() } + } + return [PSCustomObject]@{ + State = 'Valid' + Deadline = $result.Deadline + Stage = $result.Stage + Substage = $result.Substage + Status = $result.Status + } +} + +function Test-FreshMarker($Marker) { + $now = [DateTime]::UtcNow.Ticks + if ($Marker.Deadline -le $now) { return $false } + return ($Marker.Deadline - $now) -le + ([int64]$maximumMarkerDeadlineMilliseconds * [TimeSpan]::TicksPerMillisecond) +} + +function Test-WatchdogMarkerSchema($Marker) { + return $watchdogStages -ccontains $Marker.Stage -and + $watchdogSubstages -ccontains $Marker.Substage +} + +function Accept-WatchdogMarker($Marker) { + $identity = '{0}:{1}:{2}:{3}' -f $Marker.Deadline, $Marker.Stage, $Marker.Substage, $Marker.Status + $previousIdentity = if ($null -eq $script:lastValidMarker) { $null } else { + '{0}:{1}:{2}:{3}' -f $script:lastValidMarker.Deadline, $script:lastValidMarker.Stage, + $script:lastValidMarker.Substage, $script:lastValidMarker.Status + } + $script:lastValidMarker = $Marker + if ($identity -cne $previousIdentity) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:{0}:{1}:{2}' -f ` + $Marker.Stage, $Marker.Substage, $Marker.Status) + } +} + +function Stop-OwnedWorker([uint32]$TerminationExitCode) { + if ($null -eq $job) { return $false } + if ($InjectTerminationFailure) { + try { + $job.Dispose() + $script:job = $null + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } + try { + if (!$job.TerminateAndWait($TerminationExitCode, $WatchdogTerminationMilliseconds)) { + return $false + } + $job.Dispose() + $script:job = $null + if ($null -eq $worker) { return !$workerStarted } + if (!$worker.WaitForExit($WatchdogTerminationMilliseconds) -or !$worker.HasExited) { + return $false + } + return $true + } catch { + try { + if ($null -ne $job) { + $job.Dispose() + $script:job = $null + } + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } +} + +function Get-CanonicalManifestIdentifiers([string]$RunId, $InstallerAuthority) { + if ($RunId -cnotmatch '^[a-f0-9]{32}$') { + throw 'manifest run identifier is not canonical' + } + + $entryIdentity = [string]$InstallerAuthority.EntryIdentity + if ($entryIdentity -notmatch '^[A-Fa-f0-9]{24}$') { + throw 'installer entry identifier cannot be represented canonically' + } + $entryIdentity = $entryIdentity.ToLowerInvariant() + + $sha256 = [string]$InstallerAuthority.Sha256 + if ($sha256 -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'installer digest cannot be represented canonically' + } + $sha256 = $sha256.ToLowerInvariant() + + $productCodeText = [string]$InstallerAuthority.ProductCode + $productCode = [Guid]::Empty + if (![Guid]::TryParseExact($productCodeText, 'B', [ref]$productCode)) { + throw 'installer product code cannot be represented canonically' + } + $productCodeText = $productCode.ToString('B').ToUpperInvariant() + + if ($entryIdentity -cnotmatch '^[a-f0-9]{24}$' -or + $sha256 -cnotmatch '^[a-f0-9]{64}$' -or + $productCodeText -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'canonical manifest identifier construction failed' + } + + return [PSCustomObject]@{ + RunId = $RunId + InstallerEntryIdentity = $entryIdentity + InstallerSha256 = $sha256 + InstallerProductCode = $productCodeText + } +} + +function Write-InitialOwnershipManifest( + [string]$Path, + $InstallerAuthority, + [bool]$Fixture, + [string]$AuthorizedFixtureRoot +) { + $runId = [IO.Path]::GetFileNameWithoutExtension($Path).Substring( + 'propr-installed-app-ownership-'.Length) + $identifiers = Get-CanonicalManifestIdentifiers $runId $InstallerAuthority + $createdUtcTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $identifiers.RunId + CreatedUtcTicks = $createdUtcTicks + ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = [string]$InstallerAuthority.Path + InstallerEntryIdentity = $identifiers.InstallerEntryIdentity + InstallerSha256 = $identifiers.InstallerSha256 + InstallerProductCode = $identifiers.InstallerProductCode + Fixture = $Fixture + FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() + } + $manifestJson = $manifest | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + if ([string]$roundTrip.RunId -cne $identifiers.RunId -or + [string]$roundTrip.InstallerEntryIdentity -cne + $identifiers.InstallerEntryIdentity -or + [string]$roundTrip.InstallerSha256 -cne $identifiers.InstallerSha256 -or + [string]$roundTrip.InstallerProductCode -cne + $identifiers.InstallerProductCode) { + throw 'canonical manifest identifier round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($manifestJson) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Test-MsiCriticalMarker($Marker) { + return $null -ne $Marker -and [string]$Marker.Stage -ceq 'INSTALL' -and + [string]$Marker.Substage -in @('MSI_INSTALL','OWNERSHIP_CAPTURE') -and + !([string]$Marker.Substage -ceq 'OWNERSHIP_CAPTURE' -and + [string]$Marker.Status -ceq 'COMPLETE') +} + +function Get-DurableMsiTransactionReceipt { + try { + $item = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt 65536) { return 'UNAVAILABLE' } + $bytes = [byte[]]::new([int]$item.Length) + $stream = [IO.FileStream]::new( + $item.FullName, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan + ) + try { + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { return 'UNAVAILABLE' } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { return 'UNAVAILABLE' } + } finally { + $stream.Dispose() + } + $manifest = ConvertFrom-Json ` + -InputObject ([Text.UTF8Encoding]::new($false, $true).GetString($bytes)) ` + -ErrorAction Stop + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $manifest.SchemaVersion -ne 3 -or + [string]$manifest.RunId -cne $ownershipRunId -or + !(Test-InstallerArtifactAuthority $manifest) -or + [string]$manifest.State -notin @('ACTIVE','EMPTY')) { return 'UNAVAILABLE' } + if ([string]$manifest.State -ceq 'EMPTY' -and + [string]$manifest.MsiTransactionState -ceq 'NONE' -and + !$manifest.InstallAttempted) { return 'ROLLED_BACK_CLEAN' } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN' -and + @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and + @($manifest.RegistryKeys).Count -eq 0 -and + (($manifest.Fixture -and @($manifest.RegistryValues).Count -eq 0) -or + (!$manifest.Fixture -and @($manifest.RegistryValues).Count -eq 1 -and + !$manifest.RegistryValues[0].Owned))) { + return 'ROLLED_BACK_CLEAN' + } + if ([string]$manifest.MsiTransactionState -cne 'COMMITTED') { return 'UNAVAILABLE' } + $ownedDirectories = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') -and + !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{24}$' -and + [string]$_.TreeIdentity -match '^[a-f0-9]{64}$' + }) + $ownedFiles = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{64}$' -and + [string]$_.EntryIdentity -match '^[a-f0-9]{24}$' + }) + $ownedRegistryKeys = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') -and + !$_.Provisional -and [string]$_.Identity -match '^[a-f0-9]{64}$' + }) + $ownedRegistryValues = @($manifest.RegistryValues | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'HKCU_INSTALLED' -and !$_.Provisional -and + [string]$_.IdentityValueKind -and [string]$_.IdentityValueData + }) + if ($ownedDirectories.Count -ne 2 -or $ownedFiles.Count -ne 1 -or + (!$manifest.Fixture -and + ($ownedRegistryKeys.Count -ne 2 -or $ownedRegistryValues.Count -ne 1))) { + return 'UNAVAILABLE' + } + return 'COMMITTED' + } catch { + return 'UNAVAILABLE' + } +} + +function Wait-MsiCriticalTransactionReceipt { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $receipt = Get-DurableMsiTransactionReceipt + if ($receipt -in @('COMMITTED','ROLLED_BACK_CLEAN')) { + Write-WatchdogLine ` + "PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:$receipt" + return $true + } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt $msiCriticalTransactionGraceMilliseconds) + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:UNPROVEN' + return $false +} + +function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$AuthorizedFixtureRoot) { + $cleanupJob = $null + $cleanupProcess = $null + $cleanupReadyEvent = $null + $cleanupDiagnosticDrain = $null + try { + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $cleanupStartInfo = [Diagnostics.ProcessStartInfo]::new() + # Production and the principal fixture use the exact host that launched the + # supervisor. A separate fixture retains Windows PowerShell 5.1 coverage + # without attributing native pwsh 7 evidence to that compatibility host. + $cleanupHostPath = $hostPath + if ($fixtureWindowsPowerShellCleanup) { + $cleanupHostPath = Join-Path $env:SystemRoot ` + 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (!(Test-Path -LiteralPath $cleanupHostPath -PathType Leaf)) { + throw 'Windows PowerShell 5.1 fixture host is unavailable' + } + } + $cleanupStartInfo.FileName = $cleanupHostPath + $cleanupStartInfo.UseShellExecute = $false + $cleanupStartInfo.CreateNoWindow = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $cleanupWorkerPath, + '-OwnershipManifest', $ownershipManifestPath, + '-Installer', $InstallerPath, + '-ExpectedRunId', $ownershipRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $cleanupStartInfo.ArgumentList.Add($argument) + } + if ($AuthorizedFixtureRoot) { + $cleanupStartInfo.ArgumentList.Add('-FixtureRoot') + $cleanupStartInfo.ArgumentList.Add($AuthorizedFixtureRoot) + } + if ($fixtureNoMarkerDiagnostic) { + $cleanupStartInfo.ArgumentList.Add('-FixtureValidationDiagnostic') + $cleanupStartInfo.RedirectStandardOutput = $true + $cleanupStartInfo.RedirectStandardError = $true + } + + $cleanupJob = [ProPRKillOnCloseJob]::new() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain = [ProPRCleanupDiagnosticDrain]::new() + } + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $cleanupStartInfo + if (!$cleanupProcess.Start()) { throw 'post-termination cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + [void]$cleanupReadyEvent.Set() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain.Start($cleanupProcess) + } + } catch { + try { $cleanupProcess.Kill($true) } catch {} + throw 'post-termination cleanup ownership failed' + } + if (!$cleanupProcess.WaitForExit($PostTerminationCleanupMilliseconds)) { + $cleanupTreeGone = $false + try { + $cleanupTreeGone = $cleanupJob.TerminateAndWait( + 125, + $WatchdogTerminationMilliseconds + ) -and $cleanupProcess.WaitForExit($WatchdogTerminationMilliseconds) -and + $cleanupProcess.HasExited + } catch {} + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:TIMED_OUT' + return $false + } + $script:fixtureCleanupChildExitCategory = if ($cleanupProcess.ExitCode -in @(0,20,21)) { + ([int]$cleanupProcess.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + } else { 'OTHER' } + if ($fixtureNoMarkerDiagnostic) { + # The fixture protocol permits exactly one bounded phase line for + # validation exit 20 or post-validation exit 21. Exit 0 is the explicitly + # defined zero-byte success protocol. Any other child output leaves + # recovery authority in place and fails closed. + $diagnosticDrainResult = $cleanupDiagnosticDrain.Finish( + $WatchdogTerminationMilliseconds) + if ($null -eq $diagnosticDrainResult -or + $diagnosticDrainResult.StandardErrorBytes -ne 0 -or + $diagnosticDrainResult.StandardErrorLines -ne 0 -or + $diagnosticDrainResult.StandardOutputBytes -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputByteLimit -or + $diagnosticDrainResult.StandardOutputLines -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputLineLimit) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + if ($cleanupProcess.ExitCode -eq 0) { + if ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } elseif ($cleanupProcess.ExitCode -in @(20,21)) { + $diagnosticBytes = [byte[]]$diagnosticDrainResult.StandardOutput + if ($diagnosticDrainResult.StandardOutputLines -ne 1 -or + @($diagnosticBytes | Where-Object { $_ -gt 0x7f }).Count -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + $diagnosticMatch = [regex]::Match( + [Text.Encoding]::ASCII.GetString($diagnosticBytes), + ('\ACLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|' + + 'RUN_ID_FORMAT|INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|' + + 'INSTALLER_PRODUCT_CODE_FORMAT|LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|' + + 'INITIAL_ACTIVE_MATCH|INITIAL_INSTALLER_AUTHORITY_RECHECK|' + + 'EMPTY_RECEIPT_WRITE)\r?\n\z'), + [Text.RegularExpressions.RegexOptions]::CultureInvariant + ) + if (!$diagnosticMatch.Success) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine ( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + $diagnosticMatch.Groups[1].Value + ) + } elseif ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } + if ($cleanupProcess.ExitCode -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' + return $true + } catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } finally { + foreach ($resource in @( + $cleanupDiagnosticDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent + )) { + if ($null -ne $resource) { try { $resource.Dispose() } catch {} } + } + } +} + +try { + $installerAuthority = Get-InstallerAuthority $Installer + $installerPath = [string]$installerAuthority.Path + if ($OwnershipManifest -or $ExpectedRunId) { + if (!$OwnershipManifest -or $ExpectedRunId -notmatch '^[a-f0-9]{32}$') { + throw 'workflow ownership authority is invalid' + } + $candidateManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $candidateManifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $candidateManifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'workflow ownership manifest path is invalid' + } + $ownershipManifestPath = $candidateManifestPath + $ownershipRunId = $ExpectedRunId + $workflowManagedManifest = $true + } else { + $ownershipRunId = $generatedRunId + } + $selectedWorkerPath = if ($WorkerPath) { $WorkerPath } else { $productionWorkerPath } + $selectedWorkerPath = (Resolve-Path -LiteralPath $selectedWorkerPath -ErrorAction Stop).Path + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerPath -ErrorAction Stop).Path + $usingProductionWorker = [string]::Equals( + $selectedWorkerPath, $productionWorkerPath, [StringComparison]::OrdinalIgnoreCase) + if ($FixtureCleanupRoot) { + if ($usingProductionWorker) { throw 'production worker cannot use a fixture cleanup scope' } + $FixtureCleanupRoot = (Resolve-Path -LiteralPath $FixtureCleanupRoot -ErrorAction Stop).Path + $fixtureScenario = [string]$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO + $fixtureNoMarkerDiagnostic = $fixtureScenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + ) + $fixtureWindowsPowerShellCleanup = + $fixtureScenario -ceq 'NO_MARKER_WINDOWS_POWERSHELL' + } elseif (!$usingProductionWorker) { + throw 'injected workers require a fixture cleanup scope' + } + if ($InjectTerminationFailure -and $usingProductionWorker) { + throw 'termination failure injection requires an authorized fixture worker' + } + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + if ($CancellationEventName) { + if ($CancellationEventName -notmatch '^Local\\ProPRInstalledAppCancellation-[a-f0-9]{32}$') { + throw 'supervisor cancellation event name is invalid' + } + $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) + } + Write-InitialOwnershipManifest ` + $ownershipManifestPath $installerAuthority (!$usingProductionWorker) $FixtureCleanupRoot + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $ownershipReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $ownershipReadyEventName + ) + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $selectedWorkerPath, + '-Installer', $installerPath, + '-Architecture', $Architecture, + '-WatchdogMarker', $markerPath, + '-OwnershipReadyEvent', $ownershipReadyEventName, + '-OwnershipManifest', $ownershipManifestPath + )) { + $startInfo.ArgumentList.Add($argument) + } + + $job = [ProPRKillOnCloseJob]::new() + $worker = [Diagnostics.Process]::new() + $worker.StartInfo = $startInfo + if (!$worker.Start()) { throw 'installed-app worker did not start' } + $workerStarted = $true + $bootstrapStopwatch = [Diagnostics.Stopwatch]::StartNew() + try { + $job.AddProcess($worker.Handle) + [void]$ownershipReadyEvent.Set() + } catch { + try { $worker.Kill($true) } catch {} + throw 'installed-app worker ownership failed' + } + + $firstMarkerAccepted = $false + while ($true) { + if ($null -ne $cancellationEvent -and $cancellationEvent.WaitOne(0)) { + try { + $cancellationMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($cancellationMarker.State -eq 'Valid' -and + (Test-WatchdogMarkerSchema $cancellationMarker)) { + $lastValidMarker = $cancellationMarker + } + } catch {} + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' + if (Test-MsiCriticalMarker $lastValidMarker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } + $exitCode = 125 + $terminateOwnedTree = $true + break + } + + $waitMilliseconds = $WatchdogPollMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -le 0) { $waitMilliseconds = 1 } + else { $waitMilliseconds = [Math]::Min($waitMilliseconds, $remainingBootstrapMilliseconds) } + } + $workerExited = $worker.WaitForExit($waitMilliseconds) + + $readTimeout = $MarkerReadTimeoutMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -gt 0) { + $readTimeout = [Math]::Min($readTimeout, $remainingBootstrapMilliseconds) + } else { + $readTimeout = 1 + } + } + $marker = Read-WatchdogMarker $markerPath ([Math]::Max(1, $readTimeout)) + if ($marker.State -eq 'Valid' -and !(Test-WatchdogMarkerSchema $marker)) { + $marker = [PSCustomObject]@{ State = 'Invalid' } + } + + if ($marker.State -eq 'Valid') { + if (!$firstMarkerAccepted) { + if ($bootstrapStopwatch.ElapsedMilliseconds -gt $BootstrapTimeoutMilliseconds -or + !(Test-FreshMarker $marker)) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + $firstMarkerAccepted = $true + } elseif (!(Test-FreshMarker $marker)) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` + $marker.Stage, $marker.Substage, $marker.Status) + $exitCode = 124 + if (Test-MsiCriticalMarker $marker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } + $terminateOwnedTree = $true + break + } + Accept-WatchdogMarker $marker + } elseif (!$firstMarkerAccepted) { + if ($marker.State -in @('Invalid','Inaccessible','TimedOut')) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($workerExited) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($bootstrapStopwatch.ElapsedMilliseconds -ge $BootstrapTimeoutMilliseconds) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MARKER:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + + if ($workerExited) { + $exitCode = $worker.ExitCode + $supervisorOutcomeComplete = $exitCode -eq 0 + break + } + } +} catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' + $exitCode = 125 + $terminateOwnedTree = $true +} finally { + $workerLive = $false + if ($workerStarted -and $null -ne $worker) { + try { $workerLive = !$worker.HasExited } catch { $workerLive = $true } + } + $cleanupRequired = $terminateOwnedTree -or $workerStarted -or $workerLive -or + !$supervisorOutcomeComplete + $fixedCleanupResult = $null + if ($cleanupRequired -and $installerPath -and $ownershipRunId) { + # Process.ExitCode is signed and can be negative after a native crash. The + # Job Object API requires a valid uint32, so finalization always uses this + # fixed supervisor-owned termination code instead of casting worker status. + $workerTreeTerminated = Stop-OwnedWorker 125 + if ($fixtureNoMarkerDiagnostic) { + $fixtureWorkerTreeTerminationOutcome = if ($workerTreeTerminated) { + 'COMPLETE' + } else { 'FAILED' } + } + if ($workerTreeTerminated -and $postTerminationCleanupAuthorized) { + $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + $fixedCleanupResult = $false + } + if ($fixedCleanupResult -ne $true) { $exitCode = 125 } + } + + if ($fixtureNoMarkerDiagnostic) { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:{0}') -f $fixtureWorkerTreeTerminationOutcome) + if ($fixtureWorkerTreeTerminationOutcome -ceq 'COMPLETE') { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:{0}') -f $fixtureCleanupChildExitCategory) + } + } + + try { + $finalMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($finalMarker.State -eq 'Valid' -and (Test-WatchdogMarkerSchema $finalMarker) -and + (Test-FreshMarker $finalMarker)) { + $lastValidMarker = $finalMarker + } + } catch {} + if ($null -ne $lastValidMarker) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:{0}:{1}:{2}' -f ` + $lastValidMarker.Stage, $lastValidMarker.Substage, $lastValidMarker.Status) + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' + } + + foreach ($resource in @($job, $worker, $ownershipReadyEvent, $cancellationEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedCleanupResult = $false + $exitCode = 125 + } + } + try { + if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } + } catch {} + if ($fixedCleanupResult -eq $true -and !$workflowManagedManifest) { + foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { + try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + } + } +} + +exit $exitCode diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 new file mode 100644 index 000000000..76e6eeeea --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 @@ -0,0 +1,553 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [switch]$FixtureEarlyInitializationChild +) + +enum WorkflowCleanupControllerPhase { + INITIALIZATION + PARAMETER_VALIDATION + PATH_VALIDATION + PROCESS_START + PROCESS_WAIT + PROCESS_FINALIZATION + STREAM_FINALIZATION + RESOURCE_FINALIZATION + AUTHORITY_FINALIZATION + RESULT_EMISSION +} + +enum WorkflowCleanupControllerLine { + TYPE_LOAD + PARAMETERS + PATHS + START + WAIT + TERMINATE + DRAIN + DISPOSE + AUTHORITY + EMIT +} + +$ErrorActionPreference = 'Stop' +$cleanupProcess = $null +$cleanupJob = $null +$cleanupReadyEvent = $null +$outputDrain = $null +$fixedResult = 'FAILED' +$fixedStatus = 'CONTROLLER_FAILURE' +$fixedExitCode = 125 +$validatedManifestPath = $null +[WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' +[WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' +$cleanupTreeZeroVerified = $false + +function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { + [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") + [Console]::Out.WriteLine( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` + $script:fixedStatus, $script:fixedExitCode) + [Console]::Out.Flush() +} + +function Set-CaughtControllerFailure($ErrorRecord) { + $phases = @( + 'INITIALIZATION','PARAMETER_VALIDATION','PATH_VALIDATION','PROCESS_START', + 'PROCESS_WAIT','PROCESS_FINALIZATION','STREAM_FINALIZATION', + 'RESOURCE_FINALIZATION','AUTHORITY_FINALIZATION','RESULT_EMISSION' + ) + $lines = @( + 'TYPE_LOAD','PARAMETERS','PATHS','START','WAIT','TERMINATE','DRAIN', + 'DISPOSE','AUTHORITY','EMIT' + ) + $categories = @{ + AuthenticationError = 'AUTHENTICATION' + CloseError = 'CLOSE' + InvalidArgument = 'INVALID_ARGUMENT' + InvalidData = 'INVALID_DATA' + InvalidOperation = 'INVALID_OPERATION' + LimitsExceeded = 'LIMIT' + NotEnabled = 'NOT_ENABLED' + ObjectNotFound = 'NOT_FOUND' + OpenError = 'OPEN' + OperationStopped = 'STOPPED' + PermissionDenied = 'PERMISSION' + ReadError = 'READ' + ResourceBusy = 'BUSY' + ResourceUnavailable = 'UNAVAILABLE' + SecurityError = 'SECURITY' + WriteError = 'WRITE' + } + $phase = if ($phases -ccontains [string]$script:controllerPhase) { + [string]$script:controllerPhase + } else { 'INITIALIZATION' } + $line = if ($lines -ccontains [string]$script:controllerLine) { + [string]$script:controllerLine + } else { 'TYPE_LOAD' } + $categoryName = [string]$ErrorRecord.CategoryInfo.Category + $category = if ($categories.ContainsKey($categoryName)) { + $categories[$categoryName] + } else { 'UNCLASSIFIED' } + $script:fixedResult = 'FAILED' + $script:fixedStatus = 'CONTROLLER_{0}_{1}_{2}' -f $phase, $line, $category + $script:fixedExitCode = 125 +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRWorkflowCleanupJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + public ProPRWorkflowCleanupJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); + } + + private uint ReadActiveProcessCount() + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup accounting failed"); + return information.ActiveProcesses; + } + + public bool WaitForNoActiveProcesses(int timeoutMilliseconds) + { + var stopwatch = Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public bool HasNoActiveProcesses() + { + return ReadActiveProcessCount() == 0; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); + return WaitForNoActiveProcesses(timeoutMilliseconds); + } + + public void Dispose() { if (handle != null) handle.Dispose(); } +} + +public sealed class ProPRWorkflowCleanupDrainResult +{ + public long StandardOutputCharacters; + public long StandardErrorCharacters; +} + +public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable +{ + private const long CharacterLimit = 4096; + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private StreamReader standardOutputReader; + private StreamReader standardErrorReader; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump(StreamReader reader, CancellationToken token) + { + var buffer = new char[1024]; + long characters = 0; + while (true) + { + int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (count == 0) return characters; + token.ThrowIfCancellationRequested(); + characters = Math.Min(CharacterLimit + 1, characters + count); + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("stream drain was already started"); + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + standardOutputTask = Pump(standardOutputReader, cancellation.Token); + standardErrorTask = Pump(standardErrorReader, cancellation.Token); + } + + public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("stream drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("stream drain failed"); + return new ProPRWorkflowCleanupDrainResult { + StandardOutputCharacters = standardOutputTask.Result, + StandardErrorCharacters = standardErrorTask.Result + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutputReader != null) standardOutputReader.Dispose(); } catch { } + try { if (standardErrorReader != null) standardErrorReader.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} +'@ + +try { +$controllerPhase = 'PARAMETER_VALIDATION' +$controllerLine = 'PARAMETERS' +$cleanupTimeout = 0 +$terminationTimeout = 0 +if ([string]::IsNullOrWhiteSpace([string]$OwnershipManifest) -or + [string]::IsNullOrWhiteSpace([string]$Installer) -or + [string]::IsNullOrWhiteSpace([string]$ExpectedRunId) -or + ![int]::TryParse( + [string]$CleanupTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$cleanupTimeout + ) -or $cleanupTimeout -lt 1 -or $cleanupTimeout -gt 600000 -or + ![int]::TryParse( + [string]$TerminationTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$terminationTimeout + ) -or $terminationTimeout -lt 1 -or $terminationTimeout -gt 30000) { + throw 'workflow cleanup controller parameters are invalid' +} +$OwnershipManifest = [string]$OwnershipManifest +$Installer = [string]$Installer +$ExpectedRunId = [string]$ExpectedRunId +$FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot } +$CleanupTimeoutMilliseconds = $cleanupTimeout +$TerminationTimeoutMilliseconds = $terminationTimeout + + $controllerPhase = 'PATH_VALIDATION' + $controllerLine = 'PATHS' + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $manifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'cleanup manifest path is invalid' + } + $validatedManifestPath = $manifestPath + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $cleanupWorkerCandidatePath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerCandidatePath -ErrorAction Stop).Path + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, + '-OwnershipManifest', $manifestPath, + '-Installer', $installerPath, + '-ExpectedRunId', $ExpectedRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) + } + if ($FixtureEarlyInitializationChild) { + if (!$FixtureRoot) { throw 'early initialization fixture requires a fixture scope' } + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + $cleanupJob = [ProPRWorkflowCleanupJob]::new() + $controllerPhase = 'PROCESS_START' + $controllerLine = 'START' + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $startInfo + if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() + $outputDrain.Start($cleanupProcess) + [void]$cleanupReadyEvent.Set() + } catch { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + try { + if (!$cleanupProcess.HasExited) { + $cleanupProcess.Kill($true) + [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) + } + } catch {} + throw 'workflow cleanup ownership failed' + } + $controllerPhase = 'PROCESS_WAIT' + $controllerLine = 'WAIT' + if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { + $controllerLine = 'TERMINATE' + $terminationVerified = $false + try { + $terminationVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + if ($terminationVerified) { + $cleanupTreeZeroVerified = $true + $fixedResult = 'TIMED_OUT' + $fixedStatus = 'TIMEOUT' + $fixedExitCode = 124 + } else { + $fixedResult = 'FAILED' + $fixedStatus = 'TERMINATION_FAILURE' + $fixedExitCode = 125 + } + } else { + $cleanupTreeZeroVerified = $cleanupJob.HasNoActiveProcesses() + if (!$cleanupTreeZeroVerified) { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + $fixedResult = 'FAILED' + $fixedStatus = 'ACTIVE_PROCESS_AFTER_ROOT_EXIT' + $fixedExitCode = 125 + } elseif ($cleanupProcess.ExitCode -eq 0) { + $fixedResult = 'COMPLETE' + $fixedStatus = 'EMPTY_OR_CLEANED' + $fixedExitCode = 0 + } elseif ($cleanupProcess.ExitCode -eq 20) { + $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' + $fixedExitCode = 20 + } elseif ($cleanupProcess.ExitCode -eq 21) { + $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + $fixedExitCode = 21 + } + } +} catch { + Set-CaughtControllerFailure $_ +} + +try { + $controllerPhase = 'PROCESS_FINALIZATION' + $controllerLine = 'TERMINATE' + if ($null -ne $cleanupJob -and !$cleanupTreeZeroVerified) { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + if (!$cleanupTreeZeroVerified) { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' + $fixedExitCode = 125 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_FAILURE' + $fixedExitCode = 125 +} + +try { + $controllerPhase = 'STREAM_FINALIZATION' + $controllerLine = 'DRAIN' + if ($null -ne $outputDrain) { + $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) + if ($null -eq $drainResult) { + [void]$outputDrain.CancelAndFinish($TerminationTimeoutMilliseconds) + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_TIMEOUT' + $fixedExitCode = 125 + } elseif ($drainResult.StandardErrorCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardErrorCharacters -gt 4096) { + 'CHILD_STDERR_LIMIT' + } else { 'CHILD_STDERR' } + $fixedExitCode = 123 + } elseif ($drainResult.StandardOutputCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardOutputCharacters -gt 4096) { + 'CHILD_STDOUT_LIMIT' + } else { 'CHILD_STDOUT' } + $fixedExitCode = 122 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_FAILURE' + $fixedExitCode = 125 +} + +$controllerPhase = 'RESOURCE_FINALIZATION' +$controllerLine = 'DISPOSE' +foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'RESOURCE_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +if ($fixedResult -ceq 'COMPLETE' -and $cleanupTreeZeroVerified -and + $validatedManifestPath) { + try { + $controllerPhase = 'AUTHORITY_FINALIZATION' + $controllerLine = 'AUTHORITY' + foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { + if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } + } + } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'AUTHORITY_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +try { + $controllerPhase = 'RESULT_EMISSION' + $controllerLine = 'EMIT' + Write-FixedResult $fixedResult +} catch { + Set-CaughtControllerFailure $_ + exit 125 +} + +exit $fixedExitCode diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 new file mode 100644 index 000000000..e96daa0e8 --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -0,0 +1,90 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [object]$FixtureEarlyInitializationChild, + [object]$StartupFailureClass +) + +$ErrorActionPreference = 'Stop' +$bodyPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup-body.ps1' + +function Get-StartupFailureClass($ErrorRecord) { + $exception = $ErrorRecord.Exception + while ($null -ne $exception) { + if ($exception -is [Management.Automation.ParseException]) { return 'PARSER' } + if ($exception -is [Management.Automation.ParameterBindingException]) { + return 'PARAMETER_BINDING' + } + if ($exception -is [TypeLoadException] -or + $exception -is [TypeInitializationException] -or + $exception -is [IO.FileLoadException]) { + return 'TYPE_LOAD' + } + $exception = $exception.InnerException + } + return 'OTHER' +} + +function Write-StartupFailure($ErrorRecord) { + $failureClass = Get-StartupFailureClass $ErrorRecord + $line = 0 + try { + $candidateLine = [int64]$ErrorRecord.InvocationInfo.ScriptLineNumber + if ($candidateLine -ge 0 -and $candidateLine -le 999999) { $line = $candidateLine } + } catch {} + [Console]::Out.WriteLine('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED') + [Console]::Out.WriteLine(( + ('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:' + + 'EXIT_CODE:125:STARTUP_CLASS:{0}:PROCESS_EXIT:125:LINE:{1}') -f ` + $failureClass, $line + )) + [Console]::Out.Flush() +} + +try { + if ($null -ne $StartupFailureClass) { + switch ([string]$StartupFailureClass) { + 'PARSER' { [void][scriptblock]::Create('{') } + 'PARAMETER_BINDING' { + function Invoke-StartupBindingProbe { + param([Parameter(Mandatory=$true)][int]$Value) + } + Invoke-StartupBindingProbe -Value ([object]::new()) + } + 'TYPE_LOAD' { throw [TypeLoadException]::new('startup type-load fixture') } + 'OTHER' { throw [InvalidOperationException]::new('startup other fixture') } + default { throw [InvalidOperationException]::new('startup fixture class is invalid') } + } + } + $bodyParameters = @{ + OwnershipManifest = $OwnershipManifest + Installer = $Installer + ExpectedRunId = $ExpectedRunId + CleanupTimeoutMilliseconds = $CleanupTimeoutMilliseconds + TerminationTimeoutMilliseconds = $TerminationTimeoutMilliseconds + FixtureRoot = $FixtureRoot + } + if ([bool]$FixtureEarlyInitializationChild) { + $bodyParameters.FixtureEarlyInitializationChild = $true + } + $LASTEXITCODE = $null + & $bodyPath @bodyParameters + $bodyExitCode = 0 + if ($null -eq $LASTEXITCODE -or + ![int]::TryParse( + [string]$LASTEXITCODE, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$bodyExitCode + ) -or $bodyExitCode -notin @(0,20,21,122,123,124,125)) { + throw [InvalidOperationException]::new('workflow cleanup body returned without a fixed exit') + } + exit $bodyExitCode +} catch { + Write-StartupFailure $_ + exit 125 +} diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs new file mode 100644 index 000000000..c4a5e1832 --- /dev/null +++ b/apps/desktop/scripts/run-native-durability.mjs @@ -0,0 +1,128 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const EXPECTED = Object.freeze({ + 'credential-service': 72, + 'profile-store': 37, + 'pairing-shutdown': 10, + 'pairing-browser': 1, +}); +const expectedTotal = Object.values(EXPECTED).reduce((total, count) => total + count, 0); +const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli')); +const child = spawn(process.execPath, [ + tsxCli, + '--test', + '--test-concurrency=1', + 'src/profile-store.test.ts', + 'src/credential-service.test.ts', + 'src/pairing-response-lifecycle.test.ts', + 'src/credential-service.pairing-browser.test.ts', +], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + env: process.env, + stdio: ['inherit', 'pipe', 'pipe'], +}); + +let output = ''; +const forward = (stream, destination) => { + stream.setEncoding('utf8'); + stream.on('data', chunk => { + output += chunk; + destination.write(chunk); + }); +}; +forward(child.stdout, process.stdout); +forward(child.stderr, process.stderr); + +const result = await new Promise((resolve, reject) => { + child.once('error', reject); + // close fires only after both TAP pipes are drained; exit can race the final + // summary on Windows and would make a complete run look like setup failure. + child.once('close', (code, signal) => resolve({ code, signal })); +}); + +const plannedForSuite = (suiteName) => { + const escaped = suiteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = output.match(new RegExp( + `# Subtest: ${escaped}[\\s\\S]*?\\n 1\\.\\.(\\d+)\\n(?:ok|not ok) \\d+ - ${escaped}`, + )); + return match ? Number(match[1]) : 0; +}; + +const executed = { + 'credential-service': plannedForSuite('main-process desktop credential service'), + 'profile-store': plannedForSuite('desktop profile store'), + 'pairing-shutdown': plannedForSuite('desktop pairing service IPC native shutdown lifecycle'), + 'pairing-browser': plannedForSuite('DesktopCredentialService pairing browser sink'), +}; +const reportedCategory = (category) => { + const match = output.match(new RegExp( + `NATIVE_CATEGORY ${category} expected=(\\d+) executed=(\\d+)`, + )); + return match ? { expected: Number(match[1]), executed: Number(match[2]) } : { expected: -1, executed: -1 }; +}; +const countedCategory = (category, expected) => ({ + expected, + executed: output.match(new RegExp(`NATIVE_SCENARIO ${category}`, 'g'))?.length ?? 0, +}); +const pairingShutdownCategory = category => ({ + expected: 1, + executed: output.match(new RegExp(`NATIVE_PAIRING_SHUTDOWN ${category}(?:\\r?\\n|$)`, 'g'))?.length ?? 0, +}); +const scenarioCategories = { + barriers: reportedCategory('barriers'), + 'transaction-boundaries': reportedCategory('transaction-boundaries'), + 'bootstrap-migration': reportedCategory('bootstrap-migration'), + 'verified-handle-swap': reportedCategory('verified-handle-swap'), + 'reordered-visibility': reportedCategory('reordered-visibility'), + 'mirror-repair': countedCategory('mirror-repair', 6), + 'revocation-crash': countedCategory('revocation-crash', 2), + 'cancellation-switch': countedCategory('cancellation-switch', 4), + 'detach-crash': countedCategory('detach-crash', process.platform === 'win32' ? 12 : 13), + 'transient-revocation': countedCategory('transient-revocation', 4), + provisional: countedCategory('provisional', 1), + delivery: countedCategory('delivery', 1), + dispose: countedCategory('dispose', 1), + 'start-header': pairingShutdownCategory('start-header'), + 'start-body': pairingShutdownCategory('start-body'), + 'poll-header': pairingShutdownCategory('poll-header'), + 'poll-body': pairingShutdownCategory('poll-body'), + 'activate-header': pairingShutdownCategory('activate-header'), + 'activate-body': pairingShutdownCategory('activate-body'), + 'cancel-header': pairingShutdownCategory('cancel-header'), + 'cancel-body': pairingShutdownCategory('cancel-body'), + 'never-settling-reader-cancel': pairingShutdownCategory('never-settling-reader-cancel'), + 'never-settling-body-cancel': pairingShutdownCategory('never-settling-body-cancel'), +}; +const summary = Object.fromEntries( + ['tests', 'pass', 'fail', 'cancelled', 'skipped'].map(key => { + const match = output.match(new RegExp(`^# ${key} (\\d+)$`, 'm')); + return [key, match ? Number(match[1]) : -1]; + }), +); + +for (const [category, expected] of Object.entries(EXPECTED)) { + console.log(`Native durability category ${category}: expected=${expected} executed=${executed[category]}`); +} +for (const [category, counts] of Object.entries(scenarioCategories)) { + console.log(`Native durability category ${category}: expected=${counts.expected} executed=${counts.executed}`); +} +console.log( + `Native durability total: expected=${expectedTotal} executed=${summary.tests} ` + + `passed=${summary.pass} failed=${summary.fail} cancelled=${summary.cancelled} skipped=${summary.skipped}`, +); + +const complete = Object.entries(EXPECTED).every(([category, expected]) => executed[category] === expected) + && Object.values(scenarioCategories).every(({ expected, executed }) => expected >= 0 && executed === expected) + && summary.tests === expectedTotal + && summary.pass === expectedTotal + && summary.fail === 0 + && summary.cancelled === 0 + && summary.skipped === 0 + && result.code === 0 + && result.signal === null; +if (!complete) { + throw new Error( + `Native durability matrix incomplete (child code=${String(result.code)}, signal=${String(result.signal)})`, + ); +} diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh new file mode 100644 index 000000000..955bfbfdb --- /dev/null +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -0,0 +1,260 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$(uname -s)" != 'Darwin' ]]; then + echo 'Packaged Darwin Connect acceptance requires macOS.' >&2 + exit 1 +fi + +architecture="${1:-}" +if [[ "$architecture" != 'arm64' && "$architecture" != 'x64' ]]; then + echo 'Packaged Darwin Connect acceptance requires an explicit supported architecture.' >&2 + exit 1 +fi + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repository_root="$(cd "$script_directory/../../.." && pwd -P)" +application="$repository_root/apps/desktop/out/propr-desktop-darwin-$architecture/propr-desktop.app" +signature_verifier="$script_directory/verify-darwin-packaged-connect-signature.mjs" +application_signer="$script_directory/sign-darwin-packaged-connect.mjs" +bounded_runner="$script_directory/run-bounded-darwin-command.mjs" +if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" + || ! -f "$bounded_runner" ]]; then + echo 'Packaged Darwin Connect acceptance artifact is missing.' >&2 + exit 1 +fi +cd "$repository_root" + +readonly COMMAND_TIMEOUT_MS=30000 +readonly CLEANUP_TIMEOUT_MS=10000 +readonly SIGNING_TIMEOUT_MS=180000 +readonly JOURNEY_TIMEOUT_MS=240000 +readonly TERMINATION_GRACE_MS=5000 +readonly MAX_OUTPUT_BYTES=262144 + +stage_marker() { + local stage="$1" + local code="$2" + case "$stage" in + KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; + *) return 1 ;; + esac + case "$code" in + STARTED|PASSED|FAILED) ;; + *) return 1 ;; + esac + printf 'DARWIN_PACKAGED_CONNECT_SETUP:%s:%s\n' "$stage" "$code" +} + +run_bounded() { + local timeout_ms="$1" + shift + node "$bounded_runner" --timeout-ms "$timeout_ms" \ + --termination-grace-ms "$TERMINATION_GRACE_MS" \ + --max-output-bytes "$MAX_OUTPUT_BYTES" --forward-output false -- "$@" +} + +run_bounded_forward() { + local timeout_ms="$1" + shift + node "$bounded_runner" --timeout-ms "$timeout_ms" \ + --termination-grace-ms "$TERMINATION_GRACE_MS" \ + --max-output-bytes "$MAX_OUTPUT_BYTES" --forward-output true -- "$@" +} + +run_stage() { + local stage="$1" + shift + active_stage="$stage" + stage_marker "$stage" STARTED + if "$@"; then + stage_marker "$stage" PASSED + active_stage='' + return 0 + else + local stage_status=$? + stage_marker "$stage" FAILED + active_stage='' + return "$stage_status" + fi +} + +umask 077 +keychain_root='' +keychain_path='' +leaf_private_key='' +leaf_certificate='' +identity_archive='' +leaf_config='' +requirement_proof='' +identity_sha1='' +original_default='' +original_keychains=() +keychain_created=0 +keychain_state_captured=0 +active_stage='' + +restore_and_delete_keychain() { + local restore_status=0 + if (( keychain_state_captured != 0 )); then + if (( ${#original_keychains[@]} > 0 )); then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security list-keychains -d user -s \ + "${original_keychains[@]}" || restore_status=1 + else + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security list-keychains -d user -s \ + || restore_status=1 + fi + if [[ -n "$original_default" ]]; then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security default-keychain -d user -s \ + "$original_default" || restore_status=1 + fi + fi + if (( keychain_created != 0 )); then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security delete-keychain "$keychain_path" \ + || restore_status=1 + fi + return "$restore_status" +} + +remove_temporary_files() { + if [[ -z "$keychain_root" ]]; then return 0; fi + run_bounded "$CLEANUP_TIMEOUT_MS" /bin/rm -rf -- "$keychain_root" +} + +cleanup_keychain() { + local primary_status=$? + local cleanup_status=0 + trap - EXIT HUP INT TERM + set +e + run_stage KEYCHAIN_RESTORATION_DELETION restore_and_delete_keychain || cleanup_status=1 + run_stage TEMPORARY_FILE_CLEANUP remove_temporary_files || cleanup_status=1 + unset keychain_password identity_password + if (( cleanup_status != 0 )); then + echo 'Packaged Darwin Connect acceptance cleanup failed.' >&2 + if (( primary_status == 0 )); then primary_status=1; fi + fi + exit "$primary_status" +} + +exit_for_signal() { + local exit_code="$1" + if [[ -n "$active_stage" ]]; then + stage_marker "$active_stage" FAILED + active_stage='' + fi + exit "$exit_code" +} +trap cleanup_keychain EXIT +trap 'exit_for_signal 129' HUP +trap 'exit_for_signal 130' INT +trap 'exit_for_signal 143' TERM + +create_and_select_keychain() { + local original_keychain_output + original_keychain_output="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/security list-keychains -d user)" || return $? + while IFS= read -r keychain; do + keychain="${keychain#"${keychain%%[![:space:]]*}"}" + keychain="${keychain#\"}" + keychain="${keychain%\"}" + if [[ -n "$keychain" ]]; then original_keychains+=("$keychain"); fi + done <<< "$original_keychain_output" + original_default="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/security default-keychain -d user)" || return $? + original_default="${original_default#"${original_default%%[![:space:]]*}"}" + original_default="${original_default#\"}" + original_default="${original_default%\"}" + keychain_state_captured=1 + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security create-keychain \ + -p "$keychain_password" "$keychain_path" || return $? + keychain_created=1 + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security set-keychain-settings \ + -lut 21600 "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security unlock-keychain \ + -p "$keychain_password" "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security list-keychains \ + -d user -s "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security default-keychain \ + -d user -s "$keychain_path" +} + +generate_key_and_certificates() { + local fingerprint_output + keychain_root="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" /usr/bin/mktemp -d)" || return $? + [[ -n "$keychain_root" ]] || return 1 + keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" + leaf_private_key="$keychain_root/leaf-private.pem" + leaf_certificate="$keychain_root/leaf-certificate.pem" + identity_archive="$keychain_root/identity.p12" + leaf_config="$keychain_root/leaf.cnf" + requirement_proof="$keychain_root/designated-requirement.txt" + keychain_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/openssl rand -hex 32)" || return $? + identity_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/openssl rand -hex 32)" || return $? + builtin printf '%s\n' '[req]' 'distinguished_name = leaf_name' \ + 'x509_extensions = leaf_extensions' 'prompt = no' '' '[leaf_name]' \ + 'CN = ProPR Packaged Connect CI' '' '[leaf_extensions]' \ + 'basicConstraints = critical,CA:FALSE' 'keyUsage = critical,digitalSignature' \ + 'extendedKeyUsage = critical,codeSigning' 'subjectKeyIdentifier = hash' \ + 'authorityKeyIdentifier = keyid:always,issuer' > "$leaf_config" || return $? + + # A self-signed leaf makes the disposable PKCS#12 chain complete without modifying trust. + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl req -new -x509 -newkey rsa:2048 \ + -sha256 -nodes -days 1 -config "$leaf_config" -keyout "$leaf_private_key" \ + -out "$leaf_certificate" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl pkcs12 -export \ + -inkey "$leaf_private_key" -in "$leaf_certificate" -out "$identity_archive" \ + -passout "pass:$identity_password" || return $? + fingerprint_output="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" /usr/bin/openssl x509 \ + -in "$leaf_certificate" -noout -fingerprint -sha1)" || return $? + identity_sha1="${fingerprint_output##*=}" + identity_sha1="${identity_sha1//:/}" + if [[ ! "$identity_sha1" =~ ^[A-F0-9]{40}$ ]]; then + echo 'Disposable Darwin signing certificate fingerprint is invalid.' >&2 + return 1 + fi +} + +import_identity() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security import "$identity_archive" \ + -k "$keychain_path" -P "$identity_password" -T /usr/bin/codesign +} + +update_partition_list() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security set-key-partition-list \ + -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" +} + +sign_application() { + run_bounded_forward "$SIGNING_TIMEOUT_MS" node "$application_signer" \ + "$application" "$keychain_path" "$identity_sha1" +} + +verify_initial_signature() { + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" +} + +run_pair_and_reprobe() { + ( + cd "$repository_root/apps/desktop" || exit 1 + run_bounded_forward "$JOURNEY_TIMEOUT_MS" node "$script_directory/smoke-packaged-connect.mjs" + ) +} + +verify_stable_signature() { + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" +} + +run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates +run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain +run_stage IDENTITY_IMPORT import_identity +run_stage PARTITION_LIST_UPDATE update_partition_list +unset keychain_password identity_password +run_stage APPLICATION_SIGNING sign_application +run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature +run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe +run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature diff --git a/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 b/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 new file mode 100644 index 000000000..b4d5d1da2 --- /dev/null +++ b/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 @@ -0,0 +1,2584 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet('x64','arm64')] + [string]$Architecture, + [ValidateSet('none','terminate-tree','cleanup-timeout','diagnostic-subphase','host-node-producer','launcher-authority','capture-parser','capture-redirection')] + [string]$LifecycleTestMode = 'none', + [ValidateRange(0,2147483647)] + [int]$LifecycleTestProcessId = 0, + [ValidateSet( + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', + 'host-capture-contract', + 'host-staging-handoff' + )] + [string]$DiagnosticTestSubphase = 'host-node-command-cardinality', + [ValidateSet( + 'positive','zero','duplicate','multiple','mixed-types','case-collision', + 'non-application','missing-source','non-scalar-source' + )] + [string]$HostNodeProducerTestCase = 'positive', + [ValidateSet('normal','alias','retarget-alias','identity-mismatch')] + [string]$LauncherAuthorityTestCase = 'normal', + [string]$LauncherAuthorityTestPath = '', + [string]$LauncherAuthorityTestRetargetPath = '', + [string]$CaptureParserTestPath = '', + [ValidateSet( + 'administrators-owner','current-owner','foreign-owner','ordinary-owner', + 'ordinary-write','broad-write','unprotected-dacl','foreign-parent-owner', + 'identity-change','existing' + )] + [string]$CaptureParserAuthorityTestCase = 'existing', + [ValidateSet('success','nonzero','empty','hostile')] + [string]$CaptureRedirectionProducerTestCase = 'success' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$failureCategories = @( + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed' +) +$failurePhases = @( + 'source-layout', + 'runner-authority', + 'account-setup', + 'staging-copy', + 'staging-acl', + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'capture-parse', + 'result-verify', + 'cleanup' +) +$hostFailureSubphases = @( + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', + 'host-capture-contract', + 'host-staging-handoff', + 'host-state-contract' +) +$childFailureSubphases = @( + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract' +) +$childStagedContractSubphases = @( + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding' +) +$captureParseSubphases = @( + 'capture-authority', + 'capture-size', + 'capture-read', + 'capture-utf8', + 'capture-json', + 'capture-line-cardinality', + 'capture-event-cardinality', + 'capture-schema-cardinality', + 'capture-lifecycle-category', + 'capture-lifecycle-phase', + 'capture-lifecycle-subphase', + 'capture-redaction' +) +$captureAuthorityPredicates = @( + 'parent-owner', + 'capture-owner', + 'dacl-canonicality', + 'unauthorized-writer', + 'link-path-type', + 'identity-replacement', + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'post-redirection-identity', + 'capture-content', + 'cleanup' +) +$captureProducerExitBuckets = @('zero','forced-23','other') +$captureProducerOutputStates = @('exact-expected','empty','other-bounded') +$captureProducerResultPredicates = @('redirect-child-exit','capture-content') +$lifecycleFailureSubphases = @( + 'fixture-setup', + 'package-validation', + 'lifecycle-internal', + 'spawn-error', + 'output-rejected', + 'ready-validation', + 'timeout-before-ready', + 'child-exit-before-ready', + 'child-exit-after-ready', + 'tree-termination', + 'ready-clean-exit', + 'ready-forced-exit', + 'ready-duplicate', + 'child-remained-alive' +) +$failureSubphases = @( + $hostFailureSubphases + + $childFailureSubphases + + $childStagedContractSubphases + + $captureParseSubphases + + $lifecycleFailureSubphases +) +$applicationTimeoutMilliseconds = 5 * 60 * 1000 +$terminationTimeoutMilliseconds = 30 * 1000 +$cleanupTimeoutMilliseconds = 60 * 1000 +$streamCloseTimeoutMilliseconds = 30 * 1000 +$taskkillExecutable = 'C:\Windows\System32\taskkill.exe' +$primaryFailure = $null +$primaryPhase = $null +$primarySubphase = $null +$failurePhase = 'source-layout' +$failureSubphase = $null +$cleanupSecondary = 'none' +$testUser = $null +$testUserSid = $null +$stageParent = $null +$stageRoot = $null +$stageLeaf = $null +$stdout = $null +$stderr = $null +$stdoutAuthority = $null +$stderrAuthority = $null +$privilegedSid = $null +$launcherAuthority = $null +$plainPassword = $null +$handoffArgument = $null +$captureAuthorityPredicate = $null +$captureProducerResultAttributed = $false +$captureProducerExitBucket = $null +$captureProducerStdoutState = $null +$captureProducerStderrState = $null + +function Stop-PackagedConnect { + param([Parameter(Mandatory=$true)][ValidateSet( + 'artifact-missing','artifact-inaccessible','artifact-type','architecture-mismatch','spawn-failed' + )][string]$Category) + throw [InvalidOperationException]::new("PROPR_PACKAGED_CONNECT_FAILURE:$Category") +} + +function Get-FixedFailureCategory { + param([Parameter(Mandatory=$true)][Exception]$Exception) + if ($Exception.Message -cmatch '^PROPR_PACKAGED_CONNECT_FAILURE:(artifact-missing|artifact-inaccessible|artifact-type|architecture-mismatch|spawn-failed)$') { + return $Matches[1] + } + if ($failurePhase -in @('application-spawn','application-runtime','result-verify')) { + return 'spawn-failed' + } + return 'artifact-inaccessible' +} + +function Set-FailurePhase { + param([Parameter(Mandatory=$true)][string]$Phase) + if ($failurePhases -cnotcontains $Phase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-phase') + } + $script:failurePhase = $Phase + if ($Phase -cnotin @('staged-contract','ordinary-user-preflight','capture-parse','application-runtime')) { + $script:failureSubphase = $null + } +} + +function Set-CaptureParseSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($captureParseSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-capture-subphase') + } + $script:failurePhase = 'capture-parse' + $script:failureSubphase = $Subphase +} + +function Set-CaptureAuthorityPredicate { + param([Parameter(Mandatory=$true)][string]$Predicate) + if ($captureAuthorityPredicates -cnotcontains $Predicate) { + throw [InvalidOperationException]::new('invalid-fixed-capture-authority-predicate') + } + $script:captureAuthorityPredicate = $Predicate +} + +function Get-TestOnlyCaptureProducerOutputState { + param( + [Parameter(Mandatory=$true)]$Authority, + [Parameter(Mandatory=$true)] + [Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [Parameter(Mandatory=$true)][string]$Expected + ) + if ($LifecycleTestMode -cne 'capture-redirection') { + throw [InvalidOperationException]::new('capture-producer-state-outside-test-mode') + } + $captureReadHandle = $null + try { + $maximumAttributedBytes = 256 + if ($null -eq $Authority -or !($Authority.Path -is [string]) -or + !($Authority.Identity -is [string]) -or + !($Authority.SecurityDescriptor -is [string]) -or + !($Authority.Handle -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $Authority.Handle.IsInvalid -or $Authority.Handle.IsClosed -or + ![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + )) { + return 'other-bounded' + } + + $captureReadHandle = [ProprHostLauncherNative]::OpenCapture($Authority.Path, $true) + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $captureReadHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate 'capture-content' + Set-CaptureAuthorityPredicate 'capture-content' + if ((Get-CaptureAuthorityDescriptor $Authority.Path) -cne + $Authority.SecurityDescriptor) { + return 'other-bounded' + } + + $state = 'other-bounded' + $length = [ProprHostLauncherNative]::GetLength($captureReadHandle) + if ($length -eq 0) { + $state = 'empty' + } elseif ($length -le $maximumAttributedBytes) { + $bytes = [ProprHostLauncherNative]::ReadBounded( + $captureReadHandle, $maximumAttributedBytes + ) + if ($bytes.Length -eq $length -and + [Text.Encoding]::UTF8.GetString($bytes) -ceq $Expected) { + $state = 'exact-expected' + } + } + + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $captureReadHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate 'capture-content' + Set-CaptureAuthorityPredicate 'capture-content' + if (![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + ) -or (Get-CaptureAuthorityDescriptor $Authority.Path) -cne + $Authority.SecurityDescriptor) { + return 'other-bounded' + } + return $state + } catch {} + finally { + if ($null -ne $captureReadHandle) { + try { $captureReadHandle.Dispose() } catch {} + } + } + return 'other-bounded' +} + +function Set-LifecycleFailureSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($lifecycleFailureSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-lifecycle-subphase') + } + $script:failurePhase = 'application-runtime' + $script:failureSubphase = $Subphase +} + +function Set-StagedContractSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($childStagedContractSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-subphase') + } + $script:failureSubphase = $Subphase + $script:failurePhase = 'staged-contract' +} + +function Set-OrdinaryUserPreflightSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($failureSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-subphase') + } + $script:failureSubphase = $Subphase + $script:failurePhase = 'ordinary-user-preflight' +} + +function Set-PrimaryFailureFromException { + param([Parameter(Mandatory=$true)][Exception]$Exception) + $script:primaryFailure = Get-FixedFailureCategory $Exception + $script:primaryPhase = $failurePhase + $script:primarySubphase = $null + if ($script:primaryPhase -ceq 'ordinary-user-preflight') { + $script:primarySubphase = if ($failureSubphases -ccontains $failureSubphase) { + $failureSubphase + } else { + 'host-state-contract' + } + } elseif ($script:primaryPhase -ceq 'staged-contract' -and + $childStagedContractSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } elseif ($script:primaryPhase -ceq 'capture-parse' -and + $captureParseSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } elseif ($script:primaryPhase -ceq 'application-runtime' -and + $lifecycleFailureSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } +} + +function Get-ValidatedHostNodePath { + param( + [switch]$UseTestOnlyCommandResults, + [AllowNull()][AllowEmptyCollection()][object[]]$TestOnlyCommandResults, + [scriptblock]$TestOnlySourceProducer + ) + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + if ($UseTestOnlyCommandResults) { + $commandResults = @($TestOnlyCommandResults) + } else { + $commandResults = @( + Get-Command node.exe ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop + ) + } + if ($commandResults.Count -ne 1) { + Stop-PackagedConnect 'artifact-type' + } + + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + $candidate = $commandResults[0] + if (!($candidate -is [System.Management.Automation.ApplicationInfo])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-OrdinaryUserPreflightSubphase 'host-node-source' + if ($null -eq $TestOnlySourceProducer) { + $sourceResults = @($candidate.Source) + } else { + $sourceResults = @(& $TestOnlySourceProducer $candidate) + } + if ($sourceResults.Count -ne 1 -or + !($sourceResults[0] -is [string]) -or + [String]::IsNullOrEmpty($sourceResults[0])) { + Stop-PackagedConnect 'artifact-type' + } + return $sourceResults[0] +} + +function Stop-SpawnedProcess { + param([Parameter(Mandatory=$true)][Diagnostics.Process]$Process) + try { + if ($Process.HasExited) { return } + $processId = $Process.Id + $processIdText = $processId.ToString([Globalization.CultureInfo]::InvariantCulture) + $validatedProcessId = 0 + if ($processIdText -cnotmatch '^[1-9][0-9]{0,9}$' -or + ![Int32]::TryParse( + $processIdText, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$validatedProcessId + ) -or $validatedProcessId -ne $processId) { + Stop-PackagedConnect 'spawn-failed' + } + + $taskkillStart = [Diagnostics.ProcessStartInfo]::new() + $taskkillStart.FileName = $taskkillExecutable + $taskkillStart.Arguments = [String]::Join(' ', [string[]]@('/PID', $processIdText, '/T', '/F')) + $taskkillStart.UseShellExecute = $false + $taskkillStart.CreateNoWindow = $true + $taskkillStart.RedirectStandardOutput = $true + $taskkillStart.RedirectStandardError = $true + $taskkillProcess = [Diagnostics.Process]::new() + $taskkillProcess.StartInfo = $taskkillStart + try { + if (!$taskkillProcess.Start()) { Stop-PackagedConnect 'spawn-failed' } + $taskkillOutputClose = $taskkillProcess.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $taskkillErrorClose = $taskkillProcess.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + if (!$taskkillProcess.WaitForExit($terminationTimeoutMilliseconds)) { + try { $taskkillProcess.Kill() } catch {} + try { $null = $taskkillProcess.WaitForExit($terminationTimeoutMilliseconds) } catch {} + try { + $null = [Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($taskkillOutputClose, $taskkillErrorClose), + $streamCloseTimeoutMilliseconds + ) + } catch {} + Stop-PackagedConnect 'spawn-failed' + } + if (![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($taskkillOutputClose, $taskkillErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $taskkillOutputClose.IsFaulted -or $taskkillErrorClose.IsFaulted -or + $taskkillProcess.ExitCode -ne 0 -or !$Process.WaitForExit($terminationTimeoutMilliseconds) -or + !$Process.HasExited) { + Stop-PackagedConnect 'spawn-failed' + } + } finally { + $taskkillProcess.Dispose() + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } +} + +function Get-CanonicalItem { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][ValidateSet('directory','file')][string]$Kind + ) + try { + if (![IO.Path]::IsPathRooted($Path) -or [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch [Management.Automation.ItemNotFoundException] { + Stop-PackagedConnect 'artifact-missing' + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } + if (($Kind -eq 'directory') -ne $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ![String]::Equals($item.FullName, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + return $item +} + +function Test-ExactJsonProperties { + param( + [AllowNull()][object]$Object, + [Parameter(Mandatory=$true)][string[]]$Expected + ) + if ($null -eq $Object -or $Object -is [Array] -or $Object -is [string] -or + $Object -is [ValueType]) { + return $false + } + $actual = @($Object.PSObject.Properties | ForEach-Object { $_.Name }) + if ($actual.Count -ne $Expected.Count) { return $false } + foreach ($name in $Expected) { + if ($actual -cnotcontains $name) { return $false } + } + return $true +} + +function Test-UniqueJsonPropertyNames { + param([Parameter(Mandatory=$true)][string]$Text) + $objectKeys = [Collections.ArrayList]::new() + $index = 0 + while ($index -lt $Text.Length) { + $character = $Text[$index] + if ($character -ceq '{') { + $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $null = $objectKeys.Add($keys) + $index++ + continue + } + if ($character -ceq '}') { + if ($objectKeys.Count -eq 0) { return $true } + $objectKeys.RemoveAt($objectKeys.Count - 1) + $index++ + continue + } + if ($character -cne '"') { + $index++ + continue + } + $start = $index + 1 + $escaped = $false + $containsEscape = $false + $index++ + while ($index -lt $Text.Length) { + $stringCharacter = $Text[$index] + if ($escaped) { + $escaped = $false + } elseif ($stringCharacter -ceq '\') { + $escaped = $true + $containsEscape = $true + } elseif ($stringCharacter -ceq '"') { + break + } + $index++ + } + if ($index -ge $Text.Length) { return $true } + $end = $index + $lookahead = $index + 1 + while ($lookahead -lt $Text.Length -and [Char]::IsWhiteSpace($Text[$lookahead])) { + $lookahead++ + } + if ($lookahead -lt $Text.Length -and $Text[$lookahead] -ceq ':') { + if ($objectKeys.Count -eq 0 -or $containsEscape) { return $false } + $propertyName = $Text.Substring($start, $end - $start) + $keys = $objectKeys[$objectKeys.Count - 1] + if (!$keys.Add($propertyName)) { return $false } + } + $index++ + } + return $true +} + +function Assert-CaptureAuthorityAcl { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)] + [Security.Principal.SecurityIdentifier]$CapturePrivilegedSid + ) + Set-CaptureAuthorityPredicate 'capture-owner' + try { + $sections = [Security.AccessControl.AccessControlSections]::Access -bor + [Security.AccessControl.AccessControlSections]::Owner + $acl = [IO.File]::GetAccessControl($Path, $sections) + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + $ownerValues = @($CapturePrivilegedSid.Value, $administratorsSid.Value) + if ($null -eq $owner -or + $ownerValues -cnotcontains $owner.Value -or + ($null -ne $testUserSid -and $owner.Value -ceq $testUserSid.Value)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureAuthorityPredicate 'dacl-canonicality' + if (!$acl.AreAccessRulesProtected -or !$acl.AreAccessRulesCanonical) { + Stop-PackagedConnect 'artifact-type' + } + + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $authorizedWriters = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($identity in @($CapturePrivilegedSid, $administratorsSid, $systemSid)) { + if ($null -ne $identity) { $null = $authorizedWriters.Add($identity.Value) } + } + $mutationRights = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + Set-CaptureAuthorityPredicate 'unauthorized-writer' + foreach ($rule in $rules) { + if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + ($rule.FileSystemRights -band $mutationRights) -ne 0 -and + !$authorizedWriters.Contains($rule.IdentityReference.Value)) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +function Assert-PrivilegedCaptureFile { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][Microsoft.Win32.SafeHandles.SafeFileHandle]$AuthorityHandle, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [string]$ExpectedIdentity = '', + [string]$TestOnlyIdentityPredicate = 'identity-replacement', + [switch]$SkipAcl + ) + Set-CaptureAuthorityPredicate 'link-path-type' + $attributes = [ProprHostLauncherNative]::GetAttributes($AuthorityHandle) + $finalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($AuthorityHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($AuthorityHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($attributes -band ( + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT + )) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($AuthorityHandle) -ne 1 -or + ![String]::Equals($finalPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + $identity = [ProprHostLauncherNative]::GetIdentity($AuthorityHandle) + Set-CaptureAuthorityPredicate $TestOnlyIdentityPredicate + if (![String]::IsNullOrEmpty($ExpectedIdentity) -and + ![String]::Equals($identity, $ExpectedIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + if (!$SkipAcl) { Assert-CaptureAuthorityAcl $Path $CapturePrivilegedSid } + return $identity +} + +function Get-CaptureAuthorityDescriptor { + param([Parameter(Mandatory=$true)][string]$Path) + $sections = [Security.AccessControl.AccessControlSections]::Access -bor + [Security.AccessControl.AccessControlSections]::Owner + return [IO.File]::GetAccessControl($Path, $sections).GetSecurityDescriptorSddlForm($sections) +} + +function Initialize-PrivilegedCaptureFile { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [switch]$NormalizeExisting + ) + $authorityHandle = $null + try { + Set-CaptureAuthorityPredicate 'link-path-type' + if ([String]::IsNullOrEmpty($authenticatedRunnerTemp) -or + ![IO.Path]::IsPathRooted($authenticatedRunnerTemp) -or + [IO.Path]::GetFullPath($authenticatedRunnerTemp).TrimEnd('\') -cne $authenticatedRunnerTemp -or + [IO.Path]::GetDirectoryName($Path) -cne $authenticatedRunnerTemp -or + [IO.Path]::GetFileName($Path) -cnotmatch '^propr-connect-[a-f0-9]{32}\.(stdout|stderr)$' -or + [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + + Initialize-HostLauncherNative + if ($NormalizeExisting) { + $authorityHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Path) + $null = Assert-PrivilegedCaptureFile ` + $Path $authorityHandle $CapturePrivilegedSid -SkipAcl + } elseif (Test-Path -LiteralPath $Path) { + Stop-PackagedConnect 'artifact-type' + } + + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + if ($LifecycleTestMode -ceq 'capture-redirection') { + Set-CaptureAuthorityPredicate 'pre-create' + } + $captureAcl = [Security.AccessControl.FileSecurity]::new() + $captureAcl.SetAccessRuleProtection($true, $false) + $captureAcl.SetOwner($CapturePrivilegedSid) + foreach ($identity in @($CapturePrivilegedSid, $administratorsSid, $systemSid)) { + $null = $captureAcl.AddAccessRule( + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + ) + } + + if ($NormalizeExisting) { + [IO.File]::SetAccessControl($Path, $captureAcl) + $authorityHandle.Dispose() + $authorityHandle = $null + } else { + $captureStream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [Security.AccessControl.FileSystemRights]::FullControl, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::None, + $captureAcl + ) + $captureStream.Dispose() + } + + $authorityHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Path) + $identity = Assert-PrivilegedCaptureFile $Path $authorityHandle $CapturePrivilegedSid + $result = [PSCustomObject]@{ + Path = $Path + Identity = $identity + SecurityDescriptor = (Get-CaptureAuthorityDescriptor $Path) + Handle = $authorityHandle + } + $authorityHandle = $null + return $result + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $authorityHandle) { $authorityHandle.Dispose() } + } +} + +function Assert-PrivilegedCaptureIdentity { + param( + [Parameter(Mandatory=$true)]$Authority, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [string]$TestOnlyIdentityPredicate = 'identity-replacement' + ) + $reopenHandle = $null + try { + Set-CaptureAuthorityPredicate $TestOnlyIdentityPredicate + if ($null -eq $Authority -or !($Authority.Path -is [string]) -or + !($Authority.Identity -is [string]) -or + !($Authority.SecurityDescriptor -is [string]) -or + !($Authority.Handle -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $Authority.Handle.IsInvalid -or $Authority.Handle.IsClosed -or + ![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + $reopenHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Authority.Path) + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $reopenHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate $TestOnlyIdentityPredicate + Set-CaptureAuthorityPredicate 'dacl-canonicality' + if ((Get-CaptureAuthorityDescriptor $Authority.Path) -cne $Authority.SecurityDescriptor) { + Stop-PackagedConnect 'artifact-type' + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $reopenHandle) { $reopenHandle.Dispose() } + } +} + +function Read-AuthorizedCaptureBytes { + param( + [Parameter(Mandatory=$true)][string]$Path, + [scriptblock]$TestOnlyBeforeReopen, + [switch]$TestOnlyAllowReplacement, + [Security.Principal.SecurityIdentifier]$TestOnlyCapturePrivilegedSid, + [Security.Principal.SecurityIdentifier]$TestOnlyExpectedParentOwnerSid, + [string]$ExpectedCaptureIdentity = '' + ) + $parentHandle = $null + $parentReopenHandle = $null + $captureHandle = $null + $captureReopenHandle = $null + $captureFinalHandle = $null + try { + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'link-path-type' + $capturePrivilegedSid = if ($null -eq $TestOnlyCapturePrivilegedSid) { + $privilegedSid + } else { + $TestOnlyCapturePrivilegedSid + } + if ([String]::IsNullOrEmpty($authenticatedRunnerTemp) -or + $null -eq $capturePrivilegedSid -or + ![IO.Path]::IsPathRooted($authenticatedRunnerTemp) -or + [IO.Path]::GetFullPath($authenticatedRunnerTemp).TrimEnd('\') -cne $authenticatedRunnerTemp -or + [IO.Path]::GetDirectoryName($Path) -cne $authenticatedRunnerTemp -or + [IO.Path]::GetFileName($Path) -cnotmatch '^propr-connect-[a-f0-9]{32}\.stderr$' -or + [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + + Initialize-HostLauncherNative + $parentHandle = [ProprHostLauncherNative]::Open($authenticatedRunnerTemp, $true) + $parentAttributes = [ProprHostLauncherNative]::GetAttributes($parentHandle) + $parentFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($parentHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($parentHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -eq 0 -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0 -or + ![String]::Equals( + $parentFinalPath, $authenticatedRunnerTemp, [StringComparison]::OrdinalIgnoreCase + )) { + Stop-PackagedConnect 'artifact-type' + } + $parentIdentity = [ProprHostLauncherNative]::GetIdentity($parentHandle) + try { + $parentAcl = [IO.Directory]::GetAccessControl( + $authenticatedRunnerTemp, + [Security.AccessControl.AccessControlSections]::Owner + ) + $parentOwner = $parentAcl.GetOwner([Security.Principal.SecurityIdentifier]) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + Set-CaptureAuthorityPredicate 'parent-owner' + if ($null -eq $parentOwner -or @( + $privilegedSid.Value, $administratorsSid.Value, 'S-1-5-18' + ) -cnotcontains $parentOwner.Value) { + Stop-PackagedConnect 'artifact-type' + } + if ($null -ne $TestOnlyExpectedParentOwnerSid -and + ($LifecycleTestMode -cne 'capture-parser' -or + $CaptureParserAuthorityTestCase -cne 'foreign-parent-owner')) { + Stop-PackagedConnect 'artifact-type' + } + if ($null -ne $TestOnlyExpectedParentOwnerSid -and + $parentOwner.Value -cne $TestOnlyExpectedParentOwnerSid.Value) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureAuthorityPredicate 'link-path-type' + $captureHandle = [ProprHostLauncherNative]::OpenCapture( + $Path, !$TestOnlyAllowReplacement.IsPresent + ) + $captureAttributes = [ProprHostLauncherNative]::GetAttributes($captureHandle) + $captureFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($captureHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($captureHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -ne 0 -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($captureHandle) -ne 1 -or + ![String]::Equals($captureFinalPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + $captureIdentity = [ProprHostLauncherNative]::GetIdentity($captureHandle) + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::IsNullOrEmpty($ExpectedCaptureIdentity) -and + ![String]::Equals($captureIdentity, $ExpectedCaptureIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + + if ($null -ne $TestOnlyBeforeReopen) { & $TestOnlyBeforeReopen } + $captureReopenHandle = [ProprHostLauncherNative]::OpenCapture($Path, $true) + $captureReopenAttributes = [ProprHostLauncherNative]::GetAttributes($captureReopenHandle) + $captureReopenIdentity = [ProprHostLauncherNative]::GetIdentity($captureReopenHandle) + $captureReopenFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($captureReopenHandle)) + ) + Set-CaptureAuthorityPredicate 'link-path-type' + if ([ProprHostLauncherNative]::GetHandleType($captureReopenHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($captureReopenAttributes -band ( + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT + )) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($captureReopenHandle) -ne 1 -or + ![String]::Equals($captureFinalPath, $captureReopenFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::Equals($captureIdentity, $captureReopenIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + + Set-CaptureParseSubphase 'capture-size' + $captureLength = [ProprHostLauncherNative]::GetLength($captureReopenHandle) + if ($captureLength -lt 1 -or $captureLength -gt 65536) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-read' + $captureBytes = [ProprHostLauncherNative]::ReadBounded($captureReopenHandle, 65536) + if ($captureBytes.Length -ne $captureLength) { Stop-PackagedConnect 'artifact-type' } + + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::Equals( + $captureReopenIdentity, + [ProprHostLauncherNative]::GetIdentity($captureReopenHandle), + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + $captureFinalHandle = [ProprHostLauncherNative]::OpenCapture($Path, $true) + if (![String]::Equals( + $captureReopenIdentity, + [ProprHostLauncherNative]::GetIdentity($captureFinalHandle), + [StringComparison]::Ordinal + ) -or [ProprHostLauncherNative]::GetLinkCount($captureFinalHandle) -ne 1) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + $parentReopenHandle = [ProprHostLauncherNative]::Open($authenticatedRunnerTemp, $true) + if (![String]::Equals( + $parentIdentity, + [ProprHostLauncherNative]::GetIdentity($parentReopenHandle), + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + return ,$captureBytes + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + foreach ($handle in @( + $captureFinalHandle, $captureReopenHandle, $captureHandle, + $parentReopenHandle, $parentHandle + )) { + if ($null -ne $handle) { $handle.Dispose() } + } + } +} + +function Read-PackagedConnectSmokeFailure { + param( + [Parameter(Mandatory=$true)][string]$Path, + [scriptblock]$TestOnlyBeforeReopen, + [switch]$TestOnlyAllowReplacement, + [Security.Principal.SecurityIdentifier]$TestOnlyCapturePrivilegedSid, + [Security.Principal.SecurityIdentifier]$TestOnlyExpectedParentOwnerSid, + [string]$ExpectedCaptureIdentity = '' + ) + + $captureBytes = Read-AuthorizedCaptureBytes ` + -Path $Path ` + -TestOnlyBeforeReopen $TestOnlyBeforeReopen ` + -TestOnlyAllowReplacement:$TestOnlyAllowReplacement ` + -TestOnlyCapturePrivilegedSid $TestOnlyCapturePrivilegedSid ` + -TestOnlyExpectedParentOwnerSid $TestOnlyExpectedParentOwnerSid ` + -ExpectedCaptureIdentity $ExpectedCaptureIdentity + + Set-CaptureParseSubphase 'capture-utf8' + try { + $captureText = [Text.UTF8Encoding]::new($false, $true).GetString($captureBytes) + } catch { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-redaction' + $sensitiveValues = @( + $stageRoot, $stageParent, $stageLeaf, $stdout, $stderr, $testUser, + $plainPassword, $handoffArgument, 'S-1-5-', 'SENTINEL' + ) + foreach ($sensitiveValue in $sensitiveValues) { + if ($sensitiveValue -is [string] -and $sensitiveValue.Length -gt 0 -and + $captureText.IndexOf($sensitiveValue, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-line-cardinality' + if (!$captureText.EndsWith("`n", [StringComparison]::Ordinal) -or + $captureText.IndexOf("`r", [StringComparison]::Ordinal) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + $jsonLine = $captureText.Substring(0, $captureText.Length - 1) + if ($jsonLine.Length -eq 0 -or $jsonLine.IndexOf("`n", [StringComparison]::Ordinal) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-schema-cardinality' + if (!(Test-UniqueJsonPropertyNames $jsonLine)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureParseSubphase 'capture-json' + try { + $failureRecord = ConvertFrom-Json -InputObject $jsonLine -ErrorAction Stop + } catch { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-event-cardinality' + if ($null -eq $failureRecord -or $failureRecord -is [Array] -or + $failureRecord -is [string] -or $failureRecord -is [ValueType] -or + !($failureRecord.event -is [string]) -or + $failureRecord.event -cnotin @( + 'packaged_connect.artifact_failed','packaged_connect.smoke_failed' + )) { + Stop-PackagedConnect 'artifact-type' + } + + if ($failureRecord.event -ceq 'packaged_connect.artifact_failed') { + $artifactPhases = @( + 'staged-contract','staged-tree','staged-architecture','ordinary-user-preflight' + ) + Set-CaptureParseSubphase 'capture-lifecycle-phase' + if (!($failureRecord.phase -is [string]) -or + $artifactPhases -cnotcontains $failureRecord.phase) { + Stop-PackagedConnect 'artifact-type' + } + + $artifactRequiresSubphase = $failureRecord.phase -cin @( + 'staged-contract','ordinary-user-preflight' + ) + $artifactProperties = @('event','category','phase') + if ($artifactRequiresSubphase) { $artifactProperties += 'subphase' } + Set-CaptureParseSubphase 'capture-schema-cardinality' + if (!(Test-ExactJsonProperties $failureRecord $artifactProperties) -or + !($failureRecord.category -is [string])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-category' + $artifactCategories = if ($failureRecord.phase -ceq 'staged-contract') { + @('artifact-type') + } elseif ($failureRecord.phase -ceq 'staged-tree') { + @('artifact-missing','artifact-inaccessible','artifact-type') + } elseif ($failureRecord.phase -ceq 'staged-architecture') { + @('artifact-missing','artifact-inaccessible','artifact-type','architecture-mismatch') + } else { + @('artifact-inaccessible','artifact-type') + } + if ($artifactCategories -cnotcontains $failureRecord.category) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if ($failureRecord.phase -ceq 'staged-contract') { + if (!($failureRecord.subphase -is [string]) -or + $childStagedContractSubphases -cnotcontains $failureRecord.subphase) { + Stop-PackagedConnect 'artifact-type' + } + } elseif ($failureRecord.phase -ceq 'ordinary-user-preflight') { + if (!($failureRecord.subphase -is [string]) -or + $childFailureSubphases -cnotcontains $failureRecord.subphase) { + Stop-PackagedConnect 'artifact-type' + } + } + + $script:failurePhase = $failureRecord.phase + $script:failureSubphase = if ($artifactRequiresSubphase) { + $failureRecord.subphase + } else { + $null + } + return $failureRecord.category + } + + Set-CaptureParseSubphase 'capture-schema-cardinality' + $hasSecondary = $null -ne $failureRecord -and + $null -ne $failureRecord.PSObject.Properties['secondary'] + $topLevelProperties = @('event','category','capture','records') + if ($hasSecondary) { $topLevelProperties += 'secondary' } + if (!(Test-ExactJsonProperties $failureRecord $topLevelProperties) -or + !($failureRecord.category -is [string]) -or + !($failureRecord.capture -is [string]) -or + !($failureRecord.records -is [Array])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-category' + if ($lifecycleFailureSubphases -cnotcontains $failureRecord.category) { + Stop-PackagedConnect 'artifact-type' + } + if ($failureRecord.capture -cnotin @('complete','truncated')) { + Stop-PackagedConnect 'artifact-type' + } + $diagnosticRecords = @($failureRecord.records) + if ($diagnosticRecords.Count -gt 20) { + Stop-PackagedConnect 'artifact-type' + } + + $diagnosticEvents = @( + 'desktop.app.ready', + 'desktop.app.start_failed', + 'desktop.log.write_failed', + 'desktop.main_process.uncaught_exception', + 'desktop.renderer.connect_discovery.ready', + 'desktop.renderer.connect_discovery.phase', + 'desktop.renderer.connect_discovery.proof', + 'desktop.renderer.connect_discovery.status', + 'desktop.renderer.gone', + 'desktop.renderer.ready' + ) + $diagnosticCodes = @( + 'CONNECT_STATUS_INCOMPATIBLE','CONNECT_STATUS_INTERNAL_FAILURE', + 'CONNECT_STATUS_INVALID_CONFIG','CONNECT_STATUS_NOT_READY','CONNECT_STATUS_READY', + 'CONNECT_STATUS_TIMEOUT','DETAIL_REDACTED','LOG_WRITE_FAILED','OPERATION_FAILED', + 'UNCAUGHT_EXCEPTION' + ) + $diagnosticPhases = @( + 'config-read','addon-integrity-type','addon-load','descriptor-operation', + 'authority-inspection','status-resolution' + ) + $diagnosticSubsteps = @('directory-open','addon-open','fstat-type') + $diagnosticCategories = @( + 'access-denied','invalid-argument','io-failure','missing-entry','not-directory', + 'symlink-refused','type-mismatch','unexpected' + ) + foreach ($diagnosticRecord in $diagnosticRecords) { + Set-CaptureParseSubphase 'capture-schema-cardinality' + if ($null -eq $diagnosticRecord -or $diagnosticRecord -is [Array] -or + $diagnosticRecord -is [string] -or $diagnosticRecord -is [ValueType]) { + Stop-PackagedConnect 'artifact-type' + } + $hasCode = $null -ne $diagnosticRecord.PSObject.Properties['code'] + $hasPhase = $null -ne $diagnosticRecord.PSObject.Properties['phase'] + $hasSubstep = $null -ne $diagnosticRecord.PSObject.Properties['substep'] + $hasCategory = $null -ne $diagnosticRecord.PSObject.Properties['category'] + $expectedProperties = @('event') + if ($hasCode) { $expectedProperties += 'code' } + if ($hasPhase) { $expectedProperties += 'phase' } + if ($hasSubstep) { $expectedProperties += 'substep' } + if ($hasCategory) { $expectedProperties += 'category' } + if (!(Test-ExactJsonProperties $diagnosticRecord $expectedProperties)) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-event-cardinality' + if (!($diagnosticRecord.event -is [string]) -or + $diagnosticEvents -cnotcontains $diagnosticRecord.event) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-phase' + if ($hasPhase) { + if (!$hasCode -or !($diagnosticRecord.phase -is [string]) -or + $diagnosticPhases -cnotcontains $diagnosticRecord.phase -or + !($diagnosticRecord.code -is [string]) -or + $diagnosticRecord.code -cnotin @('STARTED','PASSED','FAILED')) { + Stop-PackagedConnect 'artifact-type' + } + } elseif ($hasCode) { + if (!($diagnosticRecord.code -is [string]) -or + $diagnosticCodes -cnotcontains $diagnosticRecord.code) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if (($hasSubstep -or $hasCategory) -and + (!$hasPhase -or $diagnosticRecord.code -cne 'FAILED')) { + Stop-PackagedConnect 'artifact-type' + } + if ($hasSubstep -and (!($diagnosticRecord.substep -is [string]) -or + $diagnosticSubsteps -cnotcontains $diagnosticRecord.substep)) { + Stop-PackagedConnect 'artifact-type' + } + if ($hasCategory -and (!($diagnosticRecord.category -is [string]) -or + $diagnosticCategories -cnotcontains $diagnosticRecord.category)) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if ($hasSecondary) { + if (!($failureRecord.secondary -is [Array])) { + Stop-PackagedConnect 'artifact-type' + } + $secondaryValues = @($failureRecord.secondary) + if ($secondaryValues.Count -lt 1 -or $secondaryValues.Count -gt 5) { + Stop-PackagedConnect 'artifact-type' + } + $allowedSecondary = @( + 'tree-termination-failed','child-close-unconfirmed','stream-drain-failed', + 'fixture-cleanup-failed','fixture-cleanup-authorization-failed' + ) + $uniqueSecondary = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($secondaryValue in $secondaryValues) { + if (!($secondaryValue -is [string]) -or + $allowedSecondary -cnotcontains $secondaryValue -or + !$uniqueSecondary.Add($secondaryValue)) { + Stop-PackagedConnect 'artifact-type' + } + } + } + Set-LifecycleFailureSubphase $failureRecord.category + return 'spawn-failed' +} + +$hostLauncherNativeSource = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class ProprHostLauncherNative { + public const uint GENERIC_READ = 0x80000000; + public const uint READ_CONTROL = 0x00020000; + public const uint FILE_READ_ATTRIBUTES = 0x00000080; + public const uint FILE_SHARE_READ = 0x00000001; + public const uint FILE_SHARE_WRITE = 0x00000002; + public const uint FILE_SHARE_DELETE = 0x00000004; + public const uint OPEN_EXISTING = 3; + public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + public const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + public const uint FILE_ATTRIBUTE_DEVICE = 0x00000040; + public const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + public const uint FILE_TYPE_DISK = 0x0001; + + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ID_128 { + public ulong Low; + public ulong High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ID_INFO { + public ulong VolumeSerialNumber; + public FILE_ID_128 FileId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out BY_HANDLE_FILE_INFORMATION information + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle file, + int fileInformationClass, + out FILE_ID_INFO information, + uint bufferSize + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetFileType(SafeFileHandle file); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + private static extern bool ReadFile( + SafeFileHandle file, + byte[] buffer, + uint bytesToRead, + out uint bytesRead, + IntPtr overlapped + ); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, + StringBuilder path, + uint pathLength, + uint flags + ); + + public static SafeFileHandle Open(string path, bool finalPathAuthority) { + uint share = finalPathAuthority + ? FILE_SHARE_READ + : FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + uint flags = FILE_FLAG_BACKUP_SEMANTICS; + if (finalPathAuthority) flags |= FILE_FLAG_OPEN_REPARSE_POINT; + SafeFileHandle handle = CreateFileW( + path, + FILE_READ_ATTRIBUTES, + share, + IntPtr.Zero, + OPEN_EXISTING, + flags, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static SafeFileHandle OpenCapture(string path, bool lockAuthority) { + uint share = lockAuthority + ? FILE_SHARE_READ + : FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + SafeFileHandle handle = CreateFileW( + path, + GENERIC_READ | READ_CONTROL, + share, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static SafeFileHandle OpenRedirectCaptureAuthority(string path) { + SafeFileHandle handle = CreateFileW( + path, + FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static string GetIdentity(SafeFileHandle handle) { + const int FileIdInfo = 18; + FILE_ID_INFO information; + if (!GetFileInformationByHandleEx( + handle, + FileIdInfo, + out information, + (uint)Marshal.SizeOf(typeof(FILE_ID_INFO)) + )) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return String.Format( + System.Globalization.CultureInfo.InvariantCulture, + "{0:X16}:{1:X16}:{2:X16}", + information.VolumeSerialNumber, + information.FileId.High, + information.FileId.Low + ); + } + + public static uint GetAttributes(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return information.FileAttributes; + } + + public static uint GetLinkCount(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return information.NumberOfLinks; + } + + public static long GetLength(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return ((long)information.FileSizeHigh << 32) | information.FileSizeLow; + } + + public static byte[] ReadBounded(SafeFileHandle handle, int maximumLength) { + if (maximumLength < 1) throw new ArgumentOutOfRangeException("maximumLength"); + using (System.IO.MemoryStream output = new System.IO.MemoryStream()) { + byte[] buffer = new byte[Math.Min(4096, maximumLength + 1)]; + while (output.Length <= maximumLength) { + int remaining = maximumLength + 1 - (int)output.Length; + uint requested = (uint)Math.Min(buffer.Length, remaining); + uint read; + if (!ReadFile(handle, buffer, requested, out read, IntPtr.Zero)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + if (read == 0) break; + output.Write(buffer, 0, (int)read); + } + return output.ToArray(); + } + } + + public static uint GetHandleType(SafeFileHandle handle) { + uint type = GetFileType(handle); + if (type == 0) { + int error = Marshal.GetLastWin32Error(); + if (error != 0) throw new Win32Exception(error); + } + return type; + } + + public static string GetFinalPath(SafeFileHandle handle) { + StringBuilder path = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= path.Capacity) throw new Win32Exception(206); + return path.ToString(); + } +} +'@ + +function Initialize-HostLauncherNative { + if ($null -eq ('ProprHostLauncherNative' -as [type])) { + Add-Type -TypeDefinition $hostLauncherNativeSource -Language CSharp -ErrorAction Stop + } +} + +function Get-BoundedAbsoluteWindowsPath { + param( + [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Path, + [switch]$SelectedPathPredicates + ) + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-input' + } + if ([String]::IsNullOrEmpty($Path) -or $Path.Length -gt 259 -or $Path -cmatch '[\x00-\x1f\x7f]' -or + $Path.StartsWith('\\?\', [StringComparison]::Ordinal) -or + $Path.StartsWith('\\.\', [StringComparison]::Ordinal) -or + $Path.StartsWith('\??\', [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-extra-colon' + } + if ($Path.Length -gt 2 -and $Path.Substring(2).Contains(':')) { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-get-full-path' + } + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } catch { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-absolute-shape' + } + $driveAbsolute = $fullPath -cmatch '^[A-Za-z]:\\' + $uncAbsolute = $fullPath -cmatch '^\\\\[^\\:]+\\[^\\:]+\\' + if (!$driveAbsolute -and !$uncAbsolute) { Stop-PackagedConnect 'artifact-type' } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-canonical-equality' + } + if (![String]::Equals($fullPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + return $fullPath +} + +function ConvertFrom-NativeFinalPath { + param([Parameter(Mandatory=$true)][string]$Path) + if ($Path.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { + return '\\' + $Path.Substring(8) + } + if ($Path.StartsWith('\\?\', [StringComparison]::OrdinalIgnoreCase)) { + return $Path.Substring(4) + } + Stop-PackagedConnect 'artifact-type' +} + +function Assert-OrdinaryHostLauncherHandle { + param([Parameter(Mandatory=$true)]$Handle) + $attributes = [ProprHostLauncherNative]::GetAttributes($Handle) + if ([ProprHostLauncherNative]::GetHandleType($Handle) -ne [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -ne 0 -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0) { + Stop-PackagedConnect 'artifact-type' + } +} + +function Get-TrustedHostLauncher { + param( + [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Path, + [scriptblock]$TestOnlyBeforeFinalReopen, + [scriptblock]$TestOnlyBeforeSourceReopen + ) + $sourceHandle = $null + $authorityHandle = $null + $sourceReopenHandle = $null + $authorityTransferred = $false + try { + Set-OrdinaryUserPreflightSubphase 'host-launcher-native-initialization' + Initialize-HostLauncherNative + $selectedPath = Get-BoundedAbsoluteWindowsPath -Path $Path -SelectedPathPredicates + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-open' + $sourceHandle = [ProprHostLauncherNative]::Open($selectedPath, $false) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-type' + Assert-OrdinaryHostLauncherHandle $sourceHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-identity' + $sourceIdentity = [ProprHostLauncherNative]::GetIdentity($sourceHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-final-path' + $finalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($sourceHandle)) + ) + + if ($null -ne $TestOnlyBeforeFinalReopen) { & $TestOnlyBeforeFinalReopen } + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-open' + $authorityHandle = [ProprHostLauncherNative]::Open($finalPath, $true) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-type' + Assert-OrdinaryHostLauncherHandle $authorityHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-identity' + $authorityIdentity = [ProprHostLauncherNative]::GetIdentity($authorityHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-path' + $authorityFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($authorityHandle)) + ) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-match' + if (![String]::Equals($sourceIdentity, $authorityIdentity, [StringComparison]::Ordinal) -or + ![String]::Equals($finalPath, $authorityFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + + if ($null -ne $TestOnlyBeforeSourceReopen) { & $TestOnlyBeforeSourceReopen } + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen' + $sourceReopenHandle = [ProprHostLauncherNative]::Open($selectedPath, $false) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-type' + Assert-OrdinaryHostLauncherHandle $sourceReopenHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-identity' + $sourceReopenIdentity = [ProprHostLauncherNative]::GetIdentity($sourceReopenHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-final-path' + $sourceReopenFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($sourceReopenHandle)) + ) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-match' + if (![String]::Equals($authorityIdentity, $sourceReopenIdentity, [StringComparison]::Ordinal) -or + ![String]::Equals($finalPath, $sourceReopenFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + + $authorityTransferred = $true + return [PSCustomObject]@{ Path = $finalPath; Handle = $authorityHandle } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + $nativeException = $_.Exception + while ($null -ne $nativeException.InnerException) { $nativeException = $nativeException.InnerException } + if ($nativeException -is [ComponentModel.Win32Exception] -and $nativeException.NativeErrorCode -in @(2,3)) { + Stop-PackagedConnect 'artifact-missing' + } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $sourceHandle) { $sourceHandle.Dispose() } + if ($null -ne $sourceReopenHandle) { $sourceReopenHandle.Dispose() } + if (!$authorityTransferred -and $null -ne $authorityHandle) { $authorityHandle.Dispose() } + } +} + +function Assert-PeArchitecture { + param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$ExpectedArchitecture + ) + try { + $stream = [IO.FileStream]::new($Executable, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $header = New-Object byte[] 4096 + $length = $stream.Read($header, 0, $header.Length) + } finally { + $stream.Dispose() + } + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($length -lt 64 -or [Text.Encoding]::ASCII.GetString($header, 0, 2) -cne 'MZ') { + Stop-PackagedConnect 'artifact-type' + } + $pe = [BitConverter]::ToUInt32($header, 0x3c) + if ($pe -lt 0x40 -or $pe + 6 -gt $length -or + [Text.Encoding]::ASCII.GetString($header, [int]$pe, 4) -cne "PE`0`0") { + Stop-PackagedConnect 'artifact-type' + } + $expectedMachine = if ($ExpectedArchitecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ([BitConverter]::ToUInt16($header, [int]$pe + 4) -ne $expectedMachine) { + Stop-PackagedConnect 'architecture-mismatch' + } +} + +function Assert-PackageTreeTypes { + param([Parameter(Mandatory=$true)][string]$Root) + try { + $entries = @(Get-ChildItem -LiteralPath $Root -Force -Recurse -ErrorAction Stop) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($entries.Count -lt 1 -or $entries.Count -gt 20000) { Stop-PackagedConnect 'artifact-type' } + foreach ($entry in $entries) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + (!$entry.PSIsContainer -and !($entry -is [IO.FileInfo]))) { + Stop-PackagedConnect 'artifact-type' + } + } + return $entries +} + +function Assert-CopiedPackageTree { + param( + [Parameter(Mandatory=$true)][string]$SourceRoot, + [Parameter(Mandatory=$true)][object[]]$SourceEntries, + [Parameter(Mandatory=$true)][string]$DestinationRoot, + [Parameter(Mandatory=$true)][object[]]$DestinationEntries + ) + if ($SourceEntries.Count -ne $DestinationEntries.Count) { Stop-PackagedConnect 'artifact-type' } + $destinationByRelativePath = @{} + foreach ($entry in $DestinationEntries) { + $relative = $entry.FullName.Substring($DestinationRoot.Length).TrimStart('\') + if ([String]::IsNullOrEmpty($relative) -or $destinationByRelativePath.ContainsKey($relative)) { + Stop-PackagedConnect 'artifact-type' + } + $destinationByRelativePath.Add($relative, $entry) + } + foreach ($source in $SourceEntries) { + $relative = $source.FullName.Substring($SourceRoot.Length).TrimStart('\') + if (!$destinationByRelativePath.ContainsKey($relative)) { Stop-PackagedConnect 'artifact-missing' } + $destination = $destinationByRelativePath[$relative] + if ($source.PSIsContainer -ne $destination.PSIsContainer -or + (!$source.PSIsContainer -and $source.Length -ne $destination.Length)) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +function Set-StagedEntryAcl { + param( + [Parameter(Mandatory=$true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$OrdinaryUser, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Administrators + ) + $system = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $directory = $Item.PSIsContainer + try { + $acl = if ($directory) { + [Security.AccessControl.DirectorySecurity]::new() + } else { + [Security.AccessControl.FileSecurity]::new() + } + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($Administrators) + foreach ($identity in @($OrdinaryUser, $system, $Administrators)) { + $rights = if ($identity.Value -eq $OrdinaryUser.Value) { + [Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize + } else { + [Security.AccessControl.FileSystemRights]::FullControl + } + $rule = if ($directory) { + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, + $rights, + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow + ) + } else { + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, $rights, [Security.AccessControl.AccessControlType]::Allow + ) + } + $null = $acl.AddAccessRule($rule) + } + if ($directory) { + [IO.Directory]::SetAccessControl($Item.FullName, [Security.AccessControl.DirectorySecurity]$acl) + } else { + [IO.File]::SetAccessControl($Item.FullName, [Security.AccessControl.FileSecurity]$acl) + } + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } +} + +function Assert-StagedEntryAcl { + param( + [Parameter(Mandatory=$true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$OrdinaryUser, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Administrators + ) + $system = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + try { + $sections = [Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner + $acl = if ($Item.PSIsContainer) { + [IO.Directory]::GetAccessControl($Item.FullName, $sections) + } else { + [IO.File]::GetAccessControl($Item.FullName, $sections) + } + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($owner.Value -ne $Administrators.Value -or !$acl.AreAccessRulesProtected -or + !$acl.AreAccessRulesCanonical -or $rules.Count -ne 3) { + Stop-PackagedConnect 'artifact-type' + } + foreach ($identity in @($OrdinaryUser, $system, $Administrators)) { + $matches = @($rules | Where-Object { $_.IdentityReference.Value -eq $identity.Value }) + $expected = if ($identity.Value -eq $OrdinaryUser.Value) { + [Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize + } else { + [Security.AccessControl.FileSystemRights]::FullControl + } + $expectedInheritance = if ($Item.PSIsContainer) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit + } else { + [Security.AccessControl.InheritanceFlags]::None + } + if ($matches.Count -ne 1 -or + $matches[0].AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $matches[0].FileSystemRights -ne $expected -or + $matches[0].InheritanceFlags -ne $expectedInheritance -or + $matches[0].PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None -or + $matches[0].IsInherited) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +$boundedCleanupSource = @' +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +try { + $runnerTemp=$env:PROPR_CLEANUP_RUNNER_TEMP + $parent=$env:PROPR_CLEANUP_STAGE_PARENT + $leaf=$env:PROPR_CLEANUP_STAGE_LEAF + $privileged=[Security.Principal.SecurityIdentifier]::new($env:PROPR_CLEANUP_PRIVILEGED_SID) + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + if([String]::IsNullOrEmpty($runnerTemp) -or ![IO.Path]::IsPathRooted($runnerTemp) -or + ![String]::Equals([IO.Path]::GetFullPath($runnerTemp),$runnerTemp,[StringComparison]::OrdinalIgnoreCase)){exit 91} + if(![String]::IsNullOrEmpty($parent) -or ![String]::IsNullOrEmpty($leaf)){ + if([IO.Path]::GetDirectoryName($parent) -cne $runnerTemp -or + [IO.Path]::GetFileName($parent) -cne 'propr-connect-packaged-stage' -or + $leaf -cnotmatch '^propr-connect-package-[a-f0-9]{32}$'){exit 91} + $root=[IO.Path]::Combine($parent,$leaf) + if([IO.Path]::GetDirectoryName($root) -cne $parent -or [IO.Path]::GetFileName($root) -cne $leaf){exit 91} + if(Test-Path -LiteralPath $root){ + $items=@((Get-Item -LiteralPath $root -Force -ErrorAction Stop)) + $items+=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + if($items.Count -gt 20001){exit 91} + foreach($item in $items){ + $isRoot=[String]::Equals($item.FullName,$root,[StringComparison]::OrdinalIgnoreCase) + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ![String]::Equals([IO.Path]::GetFullPath($item.FullName),$item.FullName,[StringComparison]::OrdinalIgnoreCase) -or + (!$isRoot -and !$item.FullName.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)) -or + ($isRoot -and !$item.PSIsContainer)){exit 91} + $sections=[Security.AccessControl.AccessControlSections]::Owner + $acl=if($item.PSIsContainer){[IO.Directory]::GetAccessControl($item.FullName,$sections)}else{[IO.File]::GetAccessControl($item.FullName,$sections)} + $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $owner.Value){exit 91} + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop + if(Test-Path -LiteralPath $root){exit 92} + } + if(Test-Path -LiteralPath $parent){ + $parentItem=Get-Item -LiteralPath $parent -Force -ErrorAction Stop + if(!$parentItem.PSIsContainer -or ($parentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @(Get-ChildItem -LiteralPath $parent -Force -ErrorAction Stop).Count -ne 0){exit 91} + $parentAcl=[IO.Directory]::GetAccessControl($parent,[Security.AccessControl.AccessControlSections]::Owner) + $parentOwner=$parentAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $parentOwner.Value){exit 91} + Remove-Item -LiteralPath $parent -Force -ErrorAction Stop + if(Test-Path -LiteralPath $parent){exit 92} + } + } + foreach($capture in @($env:PROPR_CLEANUP_STDOUT,$env:PROPR_CLEANUP_STDERR)){ + if(![String]::IsNullOrEmpty($capture)){ + if([IO.Path]::GetDirectoryName($capture) -cne $runnerTemp -or + [IO.Path]::GetFileName($capture) -cnotmatch '^propr-connect-[a-f0-9]{32}\.(stdout|stderr)$'){exit 91} + if(Test-Path -LiteralPath $capture){ + $captureItem=Get-Item -LiteralPath $capture -Force -ErrorAction Stop + if($captureItem.PSIsContainer -or ($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){exit 91} + $captureAcl=[IO.File]::GetAccessControl($capture,[Security.AccessControl.AccessControlSections]::Owner) + $captureOwner=$captureAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $captureOwner.Value){exit 91} + Remove-Item -LiteralPath $capture -Force -ErrorAction Stop + if(Test-Path -LiteralPath $capture){exit 92} + } + } + } + $user=$env:PROPR_CLEANUP_USER + $userSid=$env:PROPR_CLEANUP_USER_SID + if(![String]::IsNullOrEmpty($user) -or ![String]::IsNullOrEmpty($userSid)){ + if($user -cnotmatch '^prpc[a-f0-9]{12}$' -or [String]::IsNullOrEmpty($userSid)){exit 91} + $account=Get-LocalUser -Name $user -ErrorAction Stop + if($account.SID.Value -cne $userSid){exit 91} + Remove-LocalUser -Name $user -ErrorAction Stop + if($null -ne (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)){exit 92} + } + exit 0 +} catch { exit 93 } +'@ + +function Invoke-BoundedCleanup { + param( + [string]$CleanupSource = $boundedCleanupSource, + [ref]$ObservedProcessId + ) + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupSource)) + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path $PSHOME 'powershell.exe' + $start.Arguments="-NoLogo -NoProfile -NonInteractive -EncodedCommand $encoded" + $start.UseShellExecute=$false + $start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true + $start.RedirectStandardError=$true + $start.EnvironmentVariables['PROPR_CLEANUP_RUNNER_TEMP']=[string]$authenticatedRunnerTemp + $cleanupStageParent=if($null -eq $stageLeaf){''}else{[string]$stageParent} + $cleanupStageLeaf=if($null -eq $stageLeaf){''}else{[string]$stageLeaf} + $start.EnvironmentVariables['PROPR_CLEANUP_STAGE_PARENT']=$cleanupStageParent + $start.EnvironmentVariables['PROPR_CLEANUP_STAGE_LEAF']=$cleanupStageLeaf + $start.EnvironmentVariables['PROPR_CLEANUP_PRIVILEGED_SID']=if($null -eq $privilegedSid){''}else{$privilegedSid.Value} + $start.EnvironmentVariables['PROPR_CLEANUP_STDOUT']=[string]$stdout + $start.EnvironmentVariables['PROPR_CLEANUP_STDERR']=[string]$stderr + $start.EnvironmentVariables['PROPR_CLEANUP_USER']=[string]$testUser + $start.EnvironmentVariables['PROPR_CLEANUP_USER_SID']=if($null -eq $testUserSid){''}else{$testUserSid.Value} + $cleanupProcess=[Diagnostics.Process]::new() + $cleanupProcess.StartInfo=$start + $cleanupOutputBuffer=[IO.MemoryStream]::new() + $cleanupErrorBuffer=[IO.MemoryStream]::new() + try { + if(!$cleanupProcess.Start()){return 'failed'} + if($null -ne $ObservedProcessId){$ObservedProcessId.Value=$cleanupProcess.Id} + $cleanupOutputClose=$cleanupProcess.StandardOutput.BaseStream.CopyToAsync($cleanupOutputBuffer) + $cleanupErrorClose=$cleanupProcess.StandardError.BaseStream.CopyToAsync($cleanupErrorBuffer) + if(!$cleanupProcess.WaitForExit($cleanupTimeoutMilliseconds)){ + try{$cleanupProcess.Kill()}catch{return 'failed'} + try{if(!$cleanupProcess.WaitForExit($terminationTimeoutMilliseconds)){return 'failed'}}catch{return 'failed'} + try { + if(![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($cleanupOutputClose,$cleanupErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $cleanupOutputClose.IsFaulted -or $cleanupErrorClose.IsFaulted){return 'failed'} + } catch { return 'failed' } + return 'timeout' + } + if(![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($cleanupOutputClose,$cleanupErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $cleanupOutputClose.IsFaulted -or $cleanupErrorClose.IsFaulted -or + $cleanupProcess.ExitCode -ne 0 -or $cleanupOutputBuffer.Length -ne 0 -or + $cleanupErrorBuffer.Length -ne 0){return 'failed'} + return 'none' + } catch { + try{ + if(!$cleanupProcess.HasExited){ + $cleanupProcess.Kill() + $null=$cleanupProcess.WaitForExit($terminationTimeoutMilliseconds) + } + }catch{} + return 'failed' + } finally { + $cleanupProcess.Dispose() + $cleanupOutputBuffer.Dispose() + $cleanupErrorBuffer.Dispose() + } +} + +$authenticatedRunnerTemp = $null +$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + +if ($LifecycleTestMode -eq 'capture-redirection') { + $redirectionProcess = $null + $redirectionAccepted = $false + $redirectionFailurePredicate = $null + try { + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'pre-create' + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Stop-PackagedConnect 'artifact-type' + } + $privilegedSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $stdout = Join-Path $authenticatedRunnerTemp ( + 'propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stdout' + ) + $stderr = Join-Path $authenticatedRunnerTemp ( + 'propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stderr' + ) + $stdoutAuthority = Initialize-PrivilegedCaptureFile $stdout $privilegedSid + $stderrAuthority = Initialize-PrivilegedCaptureFile $stderr $privilegedSid + Set-CaptureAuthorityPredicate 'redirect-open' + $captureProducerExitCode = if ( + $CaptureRedirectionProducerTestCase -ceq 'nonzero' + ) { 23 } elseif ($CaptureRedirectionProducerTestCase -in @('empty','hostile')) { + 71 + } else { 0 } + $captureProducerSource = if ($CaptureRedirectionProducerTestCase -ceq 'empty') { + "exit $captureProducerExitCode" + } elseif ($CaptureRedirectionProducerTestCase -ceq 'hostile') { + "[Console]::Out.Write('C:\hostile\capture stdout environment-secret');" + + "[Console]::Error.Write('S-1-5-21 stderr native-text');" + + "exit $captureProducerExitCode" + } else { + "[Console]::Out.Write('capture-stdout');" + + "[Console]::Error.Write('capture-stderr');" + + "exit $captureProducerExitCode" + } + $captureProducerArgument = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($captureProducerSource) + ) + $captureProducerArguments = ( + '-NoLogo -NoProfile -NonInteractive -EncodedCommand "' + + $captureProducerArgument + '"' + ) + $redirectionProcess = Start-Process ` + -FilePath (Join-Path $PSHOME 'powershell.exe') ` + -ArgumentList $captureProducerArguments ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -ErrorAction Stop + if ($null -eq $redirectionProcess -or + !($redirectionProcess -is [System.Diagnostics.Process])) { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'redirect-open' + # PS5.1 must acquire the redirected process handle before waiting or ExitCode can remain unset. + $redirectionProcessHandle = $redirectionProcess.Handle + if ($redirectionProcessHandle -eq [IntPtr]::Zero) { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'redirect-timeout' + if (!$redirectionProcess.WaitForExit($terminationTimeoutMilliseconds)) { + Stop-PackagedConnect 'spawn-failed' + } + Assert-PrivilegedCaptureIdentity ` + $stdoutAuthority $privilegedSid -TestOnlyIdentityPredicate 'post-redirection-identity' + Assert-PrivilegedCaptureIdentity ` + $stderrAuthority $privilegedSid -TestOnlyIdentityPredicate 'post-redirection-identity' + Set-CaptureAuthorityPredicate 'redirect-child-exit' + $captureProducerActualExit = try { $redirectionProcess.ExitCode } catch { $null } + $captureProducerExitBucket = if ($captureProducerActualExit -eq 0) { + 'zero' + } elseif ($CaptureRedirectionProducerTestCase -ceq 'nonzero' -and + $captureProducerActualExit -eq 23) { + 'forced-23' + } else { + 'other' + } + Set-CaptureAuthorityPredicate 'capture-content' + $captureProducerStdoutState = Get-TestOnlyCaptureProducerOutputState ` + $stdoutAuthority $privilegedSid 'capture-stdout' + $captureProducerStderrState = Get-TestOnlyCaptureProducerOutputState ` + $stderrAuthority $privilegedSid 'capture-stderr' + $captureProducerResultAttributed = $true + Set-CaptureAuthorityPredicate 'redirect-child-exit' + if ($CaptureRedirectionProducerTestCase -cne 'success' -or + $captureProducerExitBucket -cne 'zero') { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'capture-content' + if ($captureProducerStdoutState -cne 'exact-expected' -or + $captureProducerStderrState -cne 'exact-expected') { + Stop-PackagedConnect 'artifact-type' + } + $redirectionAccepted = $true + } catch { + $redirectionFailurePredicate = if ( + $captureAuthorityPredicates -ccontains $captureAuthorityPredicate + ) { $captureAuthorityPredicate } else { 'pre-create' } + } finally { + $redirectionCleanupFailed = $false + Set-CaptureAuthorityPredicate 'cleanup' + if ($null -ne $redirectionProcess) { + try { + if (!$redirectionProcess.HasExited) { Stop-SpawnedProcess $redirectionProcess } + } catch { $redirectionCleanupFailed = $true } + try { $redirectionProcess.Dispose() } catch { $redirectionCleanupFailed = $true } + } + foreach ($authority in @($stdoutAuthority, $stderrAuthority)) { + if ($null -ne $authority -and $null -ne $authority.Handle) { + try { $authority.Handle.Dispose() } catch { $redirectionCleanupFailed = $true } + } + } + foreach ($capture in @($stdout, $stderr)) { + if (![String]::IsNullOrEmpty($capture)) { + try { + if (Test-Path -LiteralPath $capture) { + Remove-Item -LiteralPath $capture -Force -ErrorAction Stop + } + if (Test-Path -LiteralPath $capture) { $redirectionCleanupFailed = $true } + } catch { $redirectionCleanupFailed = $true } + } + } + if ($redirectionCleanupFailed -and $null -eq $redirectionFailurePredicate) { + $redirectionFailurePredicate = 'cleanup' + } + } + if ($redirectionAccepted -and $null -eq $redirectionFailurePredicate) { + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:accepted') + exit 0 + } + $primaryFailure = 'artifact-type' + $primaryPhase = 'capture-parse' + $primarySubphase = 'capture-authority' + if ($captureAuthorityPredicates -cnotcontains $redirectionFailurePredicate) { + $redirectionFailurePredicate = 'pre-create' + } + Set-CaptureAuthorityPredicate $redirectionFailurePredicate +} + +if ($LifecycleTestMode -eq 'capture-parser') { + try { + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Set-CaptureParseSubphase 'capture-authority' + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Set-CaptureParseSubphase 'capture-authority' + Stop-PackagedConnect 'artifact-type' + } + $privilegedSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $testUserSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-42424242-42424242-42424242-1001' + ) + $stderr = $CaptureParserTestPath + Set-CaptureParseSubphase 'capture-authority' + $fixtureAuthority = Initialize-PrivilegedCaptureFile ` + -Path $stderr ` + -CapturePrivilegedSid $privilegedSid ` + -NormalizeExisting + $fixtureAuthority.Handle.Dispose() + $beforeCaptureReopen = $null + $allowCaptureReplacement = $false + $captureExpectedPrivilegedSid = $null + $captureExpectedParentOwnerSid = $null + if ($CaptureParserAuthorityTestCase -in @( + 'administrators-owner','current-owner','foreign-owner','ordinary-owner' + )) { + $captureOwner = if ($CaptureParserAuthorityTestCase -eq 'administrators-owner') { + $administratorsSid + } else { + $privilegedSid + } + $captureAcl = [IO.File]::GetAccessControl($stderr) + $captureAcl.SetOwner($captureOwner) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } + if ($CaptureParserAuthorityTestCase -eq 'foreign-owner') { + $captureExpectedPrivilegedSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-51515151-51515151-51515151-1001' + ) + } elseif ($CaptureParserAuthorityTestCase -eq 'ordinary-owner') { + $testUserSid = $privilegedSid + } elseif ($CaptureParserAuthorityTestCase -in @('ordinary-write','broad-write')) { + $writeSid = if ($CaptureParserAuthorityTestCase -eq 'ordinary-write') { + $testUserSid + } else { + [Security.Principal.SecurityIdentifier]::new('S-1-1-0') + } + $captureAcl = [IO.File]::GetAccessControl($stderr) + $null = $captureAcl.AddAccessRule( + [Security.AccessControl.FileSystemAccessRule]::new( + $writeSid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + ) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } elseif ($CaptureParserAuthorityTestCase -eq 'unprotected-dacl') { + $captureAcl = [IO.File]::GetAccessControl($stderr) + $captureAcl.SetAccessRuleProtection($false, $true) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } elseif ($CaptureParserAuthorityTestCase -eq 'foreign-parent-owner') { + $captureExpectedParentOwnerSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-61616161-61616161-61616161-1001' + ) + } elseif ($CaptureParserAuthorityTestCase -eq 'identity-change') { + $allowCaptureReplacement = $true + $beforeCaptureReopen = { + $captureBackup = $stderr + '.propr-replaced' + $captureContent = [IO.File]::ReadAllBytes($stderr) + Move-Item -LiteralPath $stderr -Destination $captureBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($stderr, $captureContent) + } + } + $childFailureCategory = Read-PackagedConnectSmokeFailure ` + -Path $stderr ` + -TestOnlyBeforeReopen $beforeCaptureReopen ` + -TestOnlyAllowReplacement:$allowCaptureReplacement ` + -TestOnlyCapturePrivilegedSid $captureExpectedPrivilegedSid ` + -TestOnlyExpectedParentOwnerSid $captureExpectedParentOwnerSid + Stop-PackagedConnect $childFailureCategory + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'diagnostic-subphase') { + Set-OrdinaryUserPreflightSubphase $DiagnosticTestSubphase + try { + throw [InvalidOperationException]::new( + 'C:\hostile\package S-1-5-21-123 account-name stdout stderr exception environment-secret' + ) + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'terminate-tree') { + $lifecycleTarget = $null + try { + if ($LifecycleTestProcessId -lt 1) { Stop-PackagedConnect 'spawn-failed' } + $lifecycleTarget = [Diagnostics.Process]::GetProcessById($LifecycleTestProcessId) + if ($lifecycleTarget.HasExited) { Stop-PackagedConnect 'spawn-failed' } + if ($lifecycleTarget.WaitForExit(250)) { Stop-PackagedConnect 'spawn-failed' } + Stop-SpawnedProcess $lifecycleTarget + if (!$lifecycleTarget.HasExited) { Stop-PackagedConnect 'spawn-failed' } + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:tree-terminated') + exit 0 + } catch { + [Console]::Error.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:failed:category=spawn-failed') + exit 1 + } finally { + if ($null -ne $lifecycleTarget) { $lifecycleTarget.Dispose() } + } +} + +if ($LifecycleTestMode -eq 'host-node-producer') { + try { + if ($HostNodeProducerTestCase -eq 'positive') { + $node = Get-ValidatedHostNodePath + } elseif ($HostNodeProducerTestCase -eq 'zero') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@()) + } else { + $knownApplications = @(Get-Command ` + -Name ([Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop) + if ($knownApplications.Count -ne 1) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + Stop-PackagedConnect 'artifact-type' + } + $knownApplication = $knownApplications[0] + if (!($knownApplication -is [System.Management.Automation.ApplicationInfo])) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + Stop-PackagedConnect 'artifact-type' + } + if ($HostNodeProducerTestCase -eq 'non-application') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@( + [PSCustomObject]@{ Source = 'C:\hostile\node.exe' } + )) + } elseif ($HostNodeProducerTestCase -eq 'duplicate') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $knownApplication)) + } elseif ($HostNodeProducerTestCase -eq 'mixed-types') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@( + $knownApplication, + [PSCustomObject]@{ Source = 'C:\hostile\node.exe' } + )) + } elseif ($HostNodeProducerTestCase -in @('multiple','case-collision')) { + $otherApplications = @(Get-Command ` + -Name $taskkillExecutable ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop) + if ($otherApplications.Count -ne 1) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + Stop-PackagedConnect 'artifact-type' + } + $otherApplication = $otherApplications[0] + if (!($otherApplication -is [System.Management.Automation.ApplicationInfo])) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + Stop-PackagedConnect 'artifact-type' + } + if ($HostNodeProducerTestCase -eq 'multiple') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $otherApplication)) + } else { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $otherApplication)) ` + -TestOnlySourceProducer { + if ([String]::Equals( + $args[0].Source, + $knownApplication.Source, + [StringComparison]::Ordinal + )) { + 'C:\hostile\node.exe' + } else { + 'c:\hostile\node.exe' + } + } + } + } elseif ($HostNodeProducerTestCase -eq 'missing-source') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication)) ` + -TestOnlySourceProducer { $null } + } else { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication)) ` + -TestOnlySourceProducer { [object[]]@('C:\hostile\one.exe', 'C:\hostile\two.exe') } + } + } + if (!($node -is [string]) -or [String]::IsNullOrEmpty($node)) { + Set-OrdinaryUserPreflightSubphase 'host-node-source' + Stop-PackagedConnect 'artifact-type' + } + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:accepted') + exit 0 + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'launcher-authority') { + Set-OrdinaryUserPreflightSubphase 'host-node-path-binding' + try { + $beforeFinalReopen = $null + $beforeSourceReopen = $null + if ($LauncherAuthorityTestCase -eq 'identity-mismatch') { + $beforeFinalReopen = { + $replacementBackup = $LauncherAuthorityTestPath + '.propr-identity-' + [Guid]::NewGuid().ToString('N') + Move-Item -LiteralPath $LauncherAuthorityTestPath -Destination $replacementBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($LauncherAuthorityTestPath, [byte[]]@(0x4d,0x5a)) + } + } elseif ($LauncherAuthorityTestCase -eq 'retarget-alias') { + $beforeSourceReopen = { + $null = Get-BoundedAbsoluteWindowsPath $LauncherAuthorityTestRetargetPath + Remove-Item -LiteralPath $LauncherAuthorityTestPath -Force -ErrorAction Stop + $null = New-Item ` + -ItemType SymbolicLink ` + -Path $LauncherAuthorityTestPath ` + -Target $LauncherAuthorityTestRetargetPath ` + -ErrorAction Stop + } + } + $launcherAuthority = Get-TrustedHostLauncher ` + -Path $LauncherAuthorityTestPath ` + -TestOnlyBeforeFinalReopen $beforeFinalReopen ` + -TestOnlyBeforeSourceReopen $beforeSourceReopen + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted') + exit 0 + } catch { + Set-PrimaryFailureFromException $_.Exception + } finally { + if ($null -ne $launcherAuthority) { + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + } + } +} + +if ($LifecycleTestMode -in @( + 'diagnostic-subphase','host-node-producer','launcher-authority','capture-parser','capture-redirection' + )) { + # The shared final diagnostic below emits the injected fixed state. +} elseif ($LifecycleTestMode -eq 'cleanup-timeout') { + $cleanupTimeoutMilliseconds = 750 + $terminationTimeoutMilliseconds = 3000 + $streamCloseTimeoutMilliseconds = 3000 + $primaryFailure = 'artifact-type' + $primaryPhase = 'staged-tree' + $neverSettlingCleanupSource = 'while($true){Start-Sleep -Seconds 1}' + $observedCleanupProcessId = 0 + $cleanupResult = Invoke-BoundedCleanup ` + -CleanupSource $neverSettlingCleanupSource ` + -ObservedProcessId ([ref]$observedCleanupProcessId) + $cleanupProcessStillRunning = $false + if ($observedCleanupProcessId -gt 0) { + try { + $observedCleanupProcess = [Diagnostics.Process]::GetProcessById($observedCleanupProcessId) + try { $cleanupProcessStillRunning = !$observedCleanupProcess.HasExited } finally { $observedCleanupProcess.Dispose() } + } catch {} + } + if ($cleanupResult -eq 'timeout' -and !$cleanupProcessStillRunning) { + $cleanupSecondary = 'cleanup-timeout' + } else { + $cleanupSecondary = 'cleanup-failed' + } +} else { +try { + try { + Set-FailurePhase 'source-layout' + $desktopDirectory = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $sourceRoot = [IO.Path]::GetFullPath((Join-Path $desktopDirectory "out\propr-desktop-win32-$Architecture")) + if ([IO.Path]::GetDirectoryName($sourceRoot) -cne (Join-Path $desktopDirectory 'out') -or + [IO.Path]::GetFileName($sourceRoot) -cne "propr-desktop-win32-$Architecture") { + Stop-PackagedConnect 'artifact-type' + } + $null = Get-CanonicalItem $sourceRoot 'directory' + $sourceExecutable = Join-Path $sourceRoot 'propr-desktop.exe' + $sourceResources = Join-Path $sourceRoot 'resources' + $sourceArchive = Join-Path $sourceResources 'app.asar' + $sourceLocales = Join-Path $sourceRoot 'locales' + $null = Get-CanonicalItem $sourceExecutable 'file' + $null = Get-CanonicalItem $sourceResources 'directory' + $null = Get-CanonicalItem $sourceArchive 'file' + $null = Get-CanonicalItem $sourceLocales 'directory' + foreach ($requiredFile in @('chrome_100_percent.pak','chrome_200_percent.pak','icudtl.dat','resources.pak','v8_context_snapshot.bin')) { + $null = Get-CanonicalItem (Join-Path $sourceRoot $requiredFile) 'file' + } + $sourceEntries = @(Assert-PackageTreeTypes $sourceRoot) + Assert-PeArchitecture $sourceExecutable $Architecture + + Set-FailurePhase 'runner-authority' + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP) + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Stop-PackagedConnect 'artifact-type' + } + $runnerTempItem = Get-CanonicalItem $authenticatedRunnerTemp 'directory' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $privilegedSid = $currentSid + $runnerTempAcl = [IO.Directory]::GetAccessControl( + $authenticatedRunnerTemp, + [Security.AccessControl.AccessControlSections]::Owner + ) + $runnerTempOwner = $runnerTempAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if ($null -eq $currentSid -or @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544') -cnotcontains $runnerTempOwner.Value) { + Stop-PackagedConnect 'artifact-type' + } + $privilegedPrincipal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if (!$privilegedPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Stop-PackagedConnect 'artifact-inaccessible' + } + + $stageParent = Join-Path $authenticatedRunnerTemp 'propr-connect-packaged-stage' + if (Test-Path -LiteralPath $stageParent) { Stop-PackagedConnect 'artifact-type' } + + Set-FailurePhase 'account-setup' + $testUser = 'prpc' + [Guid]::NewGuid().ToString('N').Substring(0, 12) + $plainPassword = [Guid]::NewGuid().ToString('N') + 'aA1!' + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + $credential = [Management.Automation.PSCredential]::new("$env:COMPUTERNAME\$testUser", $securePassword) + $createdUser = New-LocalUser -Name $testUser -Password $securePassword -PasswordNeverExpires -ErrorAction Stop + $testUserSid = $createdUser.SID + if ($null -eq $testUserSid -or $testUser.Length -gt 20) { Stop-PackagedConnect 'artifact-type' } + $createdAccount = Get-LocalUser -Name $testUser -ErrorAction Stop + if ($createdAccount.SID.Value -cne $testUserSid.Value) { Stop-PackagedConnect 'artifact-type' } + $administratorsAccount = $administratorsSid.Translate([Security.Principal.NTAccount]).Value + $administratorsName = $administratorsAccount.Substring($administratorsAccount.IndexOf('\') + 1) + if ([String]::IsNullOrEmpty($administratorsName)) { Stop-PackagedConnect 'artifact-type' } + $administratorsGroup = [ADSI]("WinNT://$env:COMPUTERNAME/$administratorsName,group") + $ordinaryUserEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$testUser,user") + if ([bool]$administratorsGroup.psbase.Invoke('IsMember', $ordinaryUserEntry.Path)) { + Stop-PackagedConnect 'artifact-type' + } + + Set-FailurePhase 'staging-copy' + $stageLeaf = 'propr-connect-package-' + [Guid]::NewGuid().ToString('N') + $stageRoot = Join-Path $stageParent $stageLeaf + $null = New-Item -ItemType Directory -Path $stageParent -ErrorAction Stop + $null = New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop + foreach ($entry in Get-ChildItem -LiteralPath $sourceRoot -Force -ErrorAction Stop) { + Copy-Item -LiteralPath $entry.FullName -Destination $stageRoot -Recurse -Force -ErrorAction Stop + } + $stagedEntries = @(Assert-PackageTreeTypes $stageRoot) + Assert-CopiedPackageTree $sourceRoot $sourceEntries $stageRoot $stagedEntries + $null = Get-CanonicalItem $stageRoot 'directory' + $stagedExecutable = Join-Path $stageRoot 'propr-desktop.exe' + $null = Get-CanonicalItem $stagedExecutable 'file' + $null = Get-CanonicalItem (Join-Path $stageRoot 'resources') 'directory' + $null = Get-CanonicalItem (Join-Path $stageRoot 'resources\app.asar') 'file' + Assert-PeArchitecture $stagedExecutable $Architecture + + Set-FailurePhase 'staging-acl' + $aclEntries = @((Get-Item -LiteralPath $stageParent -Force), (Get-Item -LiteralPath $stageRoot -Force)) + $aclEntries += @(Get-ChildItem -LiteralPath $stageRoot -Force -Recurse -ErrorAction Stop) + foreach ($item in $aclEntries) { Set-StagedEntryAcl $item $testUserSid $administratorsSid } + foreach ($item in $aclEntries) { Assert-StagedEntryAcl $item $testUserSid $administratorsSid } + + $node = Get-ValidatedHostNodePath + Set-OrdinaryUserPreflightSubphase 'host-node-path-binding' + $launcherAuthority = Get-TrustedHostLauncher -Path $node + Set-OrdinaryUserPreflightSubphase 'host-node-launcher-return-authority' + $launcherAuthorityResults = @($launcherAuthority) + if ($launcherAuthorityResults.Count -ne 1) { Stop-PackagedConnect 'artifact-type' } + $launcherAuthority = $launcherAuthorityResults[0] + $launcherPathProperty = $launcherAuthority.PSObject.Properties['Path'] + $launcherHandleProperty = $launcherAuthority.PSObject.Properties['Handle'] + if ($null -eq $launcherPathProperty -or $null -eq $launcherHandleProperty -or + !($launcherPathProperty.Value -is [string]) -or + [String]::IsNullOrEmpty($launcherPathProperty.Value) -or + !($launcherHandleProperty.Value -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $launcherHandleProperty.Value.IsInvalid -or $launcherHandleProperty.Value.IsClosed) { + Stop-PackagedConnect 'artifact-type' + } + $node = $launcherPathProperty.Value + Set-OrdinaryUserPreflightSubphase 'host-capture-contract' + $stdout = Join-Path $authenticatedRunnerTemp ('propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stdout') + $stderr = Join-Path $authenticatedRunnerTemp ('propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stderr') + if ((Test-Path -LiteralPath $stdout) -or (Test-Path -LiteralPath $stderr)) { + Stop-PackagedConnect 'artifact-type' + } + $stdoutAuthority = Initialize-PrivilegedCaptureFile $stdout $privilegedSid + $stderrAuthority = Initialize-PrivilegedCaptureFile $stderr $privilegedSid + Set-OrdinaryUserPreflightSubphase 'host-staging-handoff' + $handoffText = [String]::Join("`n", [string[]]@($authenticatedRunnerTemp, $stageParent, $stageLeaf)) + $handoffBytes = [Text.Encoding]::UTF8.GetBytes($handoffText) + $handoffArgument = '--propr-windows-staged-contract=' + [Convert]::ToBase64String($handoffBytes) + if ($handoffArgument.Length -gt 16384 -or $handoffArgument -cnotmatch '^--propr-windows-staged-contract=[A-Za-z0-9+/]+={0,2}$') { + Stop-PackagedConnect 'artifact-type' + } + try { + Set-FailurePhase 'application-spawn' + try { + $process = Start-Process ` + -FilePath $node ` + -ArgumentList @('scripts/smoke-packaged-connect.mjs', $handoffArgument) ` + -WorkingDirectory $desktopDirectory ` + -Credential $credential ` + -LoadUserProfile ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -ErrorAction Stop + Set-CaptureParseSubphase 'capture-authority' + Assert-PrivilegedCaptureIdentity $stdoutAuthority $privilegedSid + Assert-PrivilegedCaptureIdentity $stderrAuthority $privilegedSid + } finally { + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } + Set-FailurePhase 'application-runtime' + try { + if (!$process.WaitForExit($applicationTimeoutMilliseconds)) { + Stop-SpawnedProcess $process + Stop-PackagedConnect 'spawn-failed' + } + } catch { + try { Stop-SpawnedProcess $process } catch {} + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureParseSubphase 'capture-authority' + Assert-PrivilegedCaptureIdentity $stdoutAuthority $privilegedSid + Assert-PrivilegedCaptureIdentity $stderrAuthority $privilegedSid + if ($process.ExitCode -ne 0) { + try { + $childFailureCategory = Read-PackagedConnectSmokeFailure ` + -Path $stderr ` + -ExpectedCaptureIdentity $stderrAuthority.Identity + Stop-PackagedConnect $childFailureCategory + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-type' + } + } + Set-FailurePhase 'result-verify' + foreach ($capture in @($stdout, $stderr)) { + $captureItem = Get-CanonicalItem $capture 'file' + if ($captureItem.Length -gt 65536) { Stop-PackagedConnect 'spawn-failed' } + } + $capturedStdout = [IO.File]::ReadAllText($stdout) + $capturedStderr = [IO.File]::ReadAllText($stderr) + $expectedSuccess = "Packaged Connect discovery passed for win32-$Architecture`: inherited-standard-handle." + if ($capturedStderr.Length -ne 0 -or $capturedStdout.TrimEnd("`r", "`n") -cne $expectedSuccess) { + Stop-PackagedConnect 'spawn-failed' + } + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} finally { + if ($null -ne $launcherAuthority) { + try { $launcherAuthority.Handle.Dispose() } catch {} + $launcherAuthority = $null + } + foreach ($authority in @($stdoutAuthority, $stderrAuthority)) { + if ($null -ne $authority -and $null -ne $authority.Handle) { + try { $authority.Handle.Dispose() } catch {} + } + } + if ($null -ne $authenticatedRunnerTemp -and $null -ne $privilegedSid) { + $cleanupResult = Invoke-BoundedCleanup + if ($cleanupResult -eq 'timeout') { + $cleanupSecondary = 'cleanup-timeout' + } elseif ($cleanupResult -ne 'none') { + $cleanupSecondary = 'cleanup-failed' + } + } +} +} + +if ($null -eq $primaryFailure -and $cleanupSecondary -ne 'none') { + $primaryFailure = 'artifact-inaccessible' + $primaryPhase = 'cleanup' +} +if ($null -ne $primaryFailure) { + if ($failureCategories -cnotcontains $primaryFailure) { $primaryFailure = 'spawn-failed' } + if ($failurePhases -cnotcontains $primaryPhase) { $primaryPhase = 'application-runtime' } + $subphaseEvidence = '' + if ($primaryPhase -ceq 'ordinary-user-preflight') { + if ($failureSubphases -cnotcontains $primarySubphase) { + $primarySubphase = 'host-state-contract' + } + $subphaseEvidence = ":subphase=$primarySubphase" + } elseif ($primaryPhase -ceq 'staged-contract' -and + $childStagedContractSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + } elseif ($primaryPhase -ceq 'capture-parse' -and + $captureParseSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + if ($LifecycleTestMode -in @('capture-parser','capture-redirection') -and + $primarySubphase -ceq 'capture-authority' -and + $captureAuthorityPredicates -ccontains $captureAuthorityPredicate) { + $subphaseEvidence += ":predicate=$captureAuthorityPredicate" + if ($LifecycleTestMode -ceq 'capture-redirection' -and + $captureProducerResultPredicates -ccontains $captureAuthorityPredicate -and + $captureProducerResultAttributed -and + $captureProducerExitBuckets -ccontains $captureProducerExitBucket -and + $captureProducerOutputStates -ccontains $captureProducerStdoutState -and + $captureProducerOutputStates -ccontains $captureProducerStderrState) { + $subphaseEvidence += ":exit=$captureProducerExitBucket" + + ":out=$captureProducerStdoutState`:err=$captureProducerStderrState" + } + } + } elseif ($primaryPhase -ceq 'application-runtime' -and + $lifecycleFailureSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + } + [Console]::Error.WriteLine("PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=$primaryFailure`:phase=$primaryPhase$subphaseEvidence`:cleanup=$cleanupSecondary") + exit 1 +} +[Console]::Out.WriteLine("PROPR_WINDOWS_PACKAGED_CONNECT:passed:$Architecture") diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs new file mode 100644 index 000000000..b494d6076 --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import { lstat, open, readdir } from 'node:fs/promises'; +import { extname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; + +const CERTIFICATE_SHA1 = /^[A-F0-9]{40}$/u; +const CERTIFICATE_LINE = /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/gmu; +const PACKAGED_CONNECT_NATIVE_ARTIFACTS = /\/Resources\/app\.asar\.unpacked\/\.vite\/native\/prebuilds\//u; +const CODESIGN_TIMEOUT_MS = 30_000; +const CODESIGN_MAX_OUTPUT_BYTES = 256 * 1024; +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const MACH_O_MAGICS = new Set([ + 0xFEEDFACE, 0xFEEDFACF, 0xCEFAEDFE, 0xCFFAEDFE, + 0xCAFEBABE, 0xBEBAFECA, 0xCAFEBABF, 0xBFBAFECA, +]); + +export const DARWIN_SIGNING_DIAGNOSTICS = Object.freeze({ + missingIdentityOrChain: 'MISSING_IDENTITY_OR_CHAIN', + trustRejection: 'TRUST_REJECTION', + requirementsFailure: 'REQUIREMENTS_FAILURE', + codesignFailure: 'CODESIGN_FAILURE', +}); + +export class DarwinSigningDiagnosticError extends Error { + constructor(diagnostic, cause) { + super(`darwin-signing-${diagnostic.toLowerCase()}`, { cause }); + this.name = 'DarwinSigningDiagnosticError'; + this.diagnostic = diagnostic; + } +} + +const failureText = error => [ + error?.message, + error?.stdout, + error?.stderr, + error?.result?.stdout, + error?.result?.stderr, + error?.cause?.message, +].filter(value => typeof value === 'string').join('\n'); + +export const classifyDarwinSigningFailure = error => { + if (error instanceof DarwinSigningDiagnosticError) return error.diagnostic; + const details = failureText(error); + if (/CSSMERR_TP_NOT_TRUSTED|errSecNotTrusted|certificate (?:is )?not trusted|trust evaluation/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.trustRejection; + } + if (/unable to build chain|incomplete certificate chain|no identity found|identity[^\n]*not found|specified item could not be found in the keychain/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain; + } + if (/designated requirement|invalid requirement|code requirement|requirement compilation/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure; + } + return DARWIN_SIGNING_DIAGNOSTICS.codesignFailure; +}; + +export const darwinSigningDiagnosticLine = error => ( + `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${classifyDarwinSigningFailure(error)}\n` +); + +const signingRank = filePath => { + const depth = filePath.split(sep).length; + return depth * 2 + (/\.app\/Contents\/MacOS\/[^/]+$/u.test(filePath) ? 0 : 1); +}; + +const runSigningCommand = (runCommand, executable, arguments_) => runCommand({ + executable, + arguments: arguments_, + timeoutMs: CODESIGN_TIMEOUT_MS, + // Settle the nested command group before the outer signing wrapper's five-second escalation. + terminationGraceMs: 1_000, + maxOutputBytes: CODESIGN_MAX_OUTPUT_BYTES, + forwardOutput: false, +}); + +const assertExactImportedCertificate = (output, certificateSha1) => { + const fingerprints = [...output.matchAll(CERTIFICATE_LINE)] + .map(match => match[1].toUpperCase()); + if (fingerprints.length !== 1 || fingerprints[0] !== certificateSha1) { + throw new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + ); + } +}; + +const isMachO = async filePath => { + const handle = await open(filePath, 'r'); + try { + const header = Buffer.alloc(4); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + return bytesRead === header.length && MACH_O_MAGICS.has(header.readUInt32BE(0)); + } finally { + await handle.close(); + } +}; + +export const discoverDarwinSignablePaths = async root => { + const discovered = []; + const visit = async directory => { + const entries = await readdir(directory); + entries.sort(); + for (const entry of entries) { + const filePath = join(directory, entry); + const stats = await lstat(filePath); + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + await visit(filePath); + if (extname(filePath) === '.app' || extname(filePath) === '.framework') { + discovered.push(filePath); + } + } else if (stats.isFile() && await isMachO(filePath)) { + discovered.push(filePath); + } + } + }; + await visit(root); + return discovered; +}; + +export const signDarwinPackagedConnectApplication = async ({ + application, + keychain, + certificateSha1, + discover = discoverDarwinSignablePaths, + runCommand = runBoundedProcess, +}) => { + if (!application.endsWith('.app') || !keychain.endsWith('.keychain-db') + || !CERTIFICATE_SHA1.test(certificateSha1)) { + throw new Error('invalid-acceptance-signing-input'); + } + const certificateResult = await runSigningCommand(runCommand, '/usr/bin/security', [ + 'find-certificate', '-a', '-Z', keychain, + ]).catch(error => { + throw new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + error, + ); + }); + assertExactImportedCertificate( + `${certificateResult.stdout}\n${certificateResult.stderr}`, + certificateSha1, + ); + + const designatedRequirement = `designated => identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${certificateSha1}"`; + const discovered = (await discover(join(application, 'Contents'))) + .filter(filePath => !PACKAGED_CONNECT_NATIVE_ARTIFACTS.test(filePath)); + const targets = [...discovered, application] + .sort((left, right) => signingRank(right) - signingRank(left)); + const targetsByRank = new Map(); + for (const target of targets) { + const rank = signingRank(target); + targetsByRank.set(rank, [...(targetsByRank.get(rank) ?? []), target]); + } + + for (const targetGroup of targetsByRank.values()) { + const isApplication = targetGroup.length === 1 && targetGroup[0] === application; + const arguments_ = [ + '--sign', certificateSha1, + '--force', + '--keychain', keychain, + '--timestamp=none', + ...(isApplication ? [ + '--identifier', REQUIRED_IDENTIFIER, + '--preserve-metadata=entitlements,flags', + ] : [ + '--preserve-metadata=identifier,entitlements,flags', + ]), + ...(isApplication ? [`-r=${designatedRequirement}`] : []), + ...targetGroup, + ]; + await runSigningCommand(runCommand, '/usr/bin/codesign', arguments_); + } + await runSigningCommand(runCommand, '/usr/bin/codesign', [ + '--verify', '--deep', '--strict', application, + ]); +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [application, keychain, certificateSha1] = process.argv.slice(2); + try { + if (process.platform !== 'darwin' || !application || !keychain || !certificateSha1) { + throw new Error('invalid-invocation'); + } + await signDarwinPackagedConnectApplication({ application, keychain, certificateSha1 }); + } catch (error) { + process.stderr.write(darwinSigningDiagnosticLine(error)); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs new file mode 100644 index 000000000..aea733459 --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + DARWIN_SIGNING_DIAGNOSTICS, + classifyDarwinSigningFailure, + darwinSigningDiagnosticLine, + discoverDarwinSignablePaths, + signDarwinPackagedConnectApplication, +} from './sign-darwin-packaged-connect.mjs'; + +const fingerprint = 'A'.repeat(40); +const application = '/tmp/propr-desktop.app'; +const keychain = '/tmp/propr-smoke.keychain-db'; +const nativeArtifact = `${application}/Contents/Resources/app.asar.unpacked/.vite/native/prebuilds/darwin-arm64/directory-operations.node`; + +describe('Darwin packaged Connect direct signing', () => { + test('discovers Mach-O files and nested code bundles', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-darwin-signables-')); + try { + const helper = join(root, 'Helper.app'); + const executable = join(helper, 'Contents', 'MacOS', 'Helper'); + const framework = join(root, 'Library.framework'); + await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true }); + await mkdir(framework); + await writeFile(executable, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x00])); + await writeFile(join(root, 'data.bin'), Buffer.from('not executable code')); + assert.deepEqual(await discoverDarwinSignablePaths(root), [ + executable, + helper, + framework, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('selects the exact certificate and signs inside-out with fixed noninteractive options', async () => { + const calls = []; + const framework = `${application}/Contents/Frameworks/Electron Framework.framework`; + const helper = `${application}/Contents/Frameworks/propr Helper.app`; + const helperExecutable = `${helper}/Contents/MacOS/propr Helper`; + const mainExecutable = `${application}/Contents/MacOS/propr-desktop`; + await signDarwinPackagedConnectApplication({ + application, + keychain, + certificateSha1: fingerprint, + discover: async () => [mainExecutable, framework, helper, helperExecutable, nativeArtifact], + runCommand: async options => { + calls.push(options); + if (options.executable === '/usr/bin/security') { + return { stdout: `SHA-1 hash: ${fingerprint}\n`, stderr: '' }; + } + return { stdout: '', stderr: '' }; + }, + }); + + assert.deepEqual(calls[0].arguments, ['find-certificate', '-a', '-Z', keychain]); + const signingCalls = calls.filter(call => call.arguments[0] === '--sign'); + const signedTargets = signingCalls.flatMap(call => call.arguments.filter(argument => ( + argument.startsWith(application) + ))); + assert.equal(signedTargets.includes(nativeArtifact), false); + assert.equal(signedTargets.at(-1), application); + assert.ok(signedTargets.indexOf(helperExecutable) < signedTargets.indexOf(helper)); + assert.ok(signedTargets.indexOf(helper) < signedTargets.indexOf(mainExecutable)); + for (const call of signingCalls) { + assert.equal(call.executable, '/usr/bin/codesign'); + assert.equal(call.forwardOutput, false); + assert.equal(call.timeoutMs, 30_000); + assert.equal(call.terminationGraceMs, 1_000); + } + const nestedArguments = targets => [ + '--sign', fingerprint, + '--force', + '--keychain', keychain, + '--timestamp=none', + '--preserve-metadata=identifier,entitlements,flags', + ...targets, + ]; + assert.deepEqual(signingCalls.map(call => call.arguments), [ + nestedArguments([helperExecutable]), + nestedArguments([framework, helper]), + nestedArguments([mainExecutable]), + [ + '--sign', fingerprint, + '--force', + '--keychain', keychain, + '--timestamp=none', + '--identifier', 'dev.propr.desktop', + '--preserve-metadata=entitlements,flags', + `-r=designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`, + application, + ], + ]); + assert.deepEqual(calls.at(-1).arguments, [ + '--verify', '--deep', '--strict', application, + ]); + }); + + test('fails before codesign when the imported certificate is absent, duplicate, or wrong', async () => { + for (const stdout of [ + '', + `SHA-1 hash: ${fingerprint}\nSHA-1 hash: ${fingerprint}\n`, + `SHA-1 hash: ${fingerprint}\nSHA-1 hash: ${'B'.repeat(40)}\n`, + `SHA-1 hash: ${'B'.repeat(40)}\n`, + ]) { + await assert.rejects(signDarwinPackagedConnectApplication({ + application, + keychain, + certificateSha1: fingerprint, + discover: async () => [], + runCommand: async () => ({ stdout, stderr: '' }), + }), error => ( + classifyDarwinSigningFailure(error) + === DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain + )); + } + }); + + test('emits only fixed classified diagnostics for sensitive native failures', () => { + const secret = 'SECRET_PATH_PASSWORD_FINGERPRINT'; + const cases = [ + ['CSSMERR_TP_NOT_TRUSTED', DARWIN_SIGNING_DIAGNOSTICS.trustRejection], + ['unable to build chain to self-signed root', DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain], + ['invalid designated requirement', DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure], + ['codesign failed', DARWIN_SIGNING_DIAGNOSTICS.codesignFailure], + ]; + for (const [stderr, diagnostic] of cases) { + const line = darwinSigningDiagnosticLine({ stderr: `${stderr} ${secret}` }); + assert.equal(line, `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${diagnostic}\n`); + assert.doesNotMatch(line, new RegExp(secret, 'u')); + } + }); +}); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs new file mode 100644 index 000000000..b6cebc1bf --- /dev/null +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -0,0 +1,723 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import { Server as SocketIOServer } from 'socket.io'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + DESKTOP_TRANSPORT_SCOPE_QUERY, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { + createIdempotentJourneyFixtureClose, + preservePrimaryWithCleanup, + removeAuthorizedConnectFixture, + runPackagedConnectLifecycle, +} from './packaged-connect-lifecycle.mjs'; +import { + createPackagedConnectLaunchArguments, + spawnPackagedConnectBinary, +} from './packaged-connect-launch.mjs'; +import { + collectAcceptedSocketEvidence, + evaluatePackagedConnectEvidence, +} from './packaged-connect-evidence.mjs'; +import { + canonicalizeWindowsFixtureEntry, + encodedWindowsFixtureAcl, + windowsPowerShell51Path, +} from './windows-fixture-acl.mjs'; +import { + describeWindowsArtifactFailure, + packagedConnectArtifactSensitiveNeedles, + parseWindowsStagedPackageHandoff, + validateWindowsStagedPackage, +} from './windows-packaged-connect-staging.mjs'; + +if (!['darwin', 'linux', 'win32'].includes(process.platform)) { + throw new Error('Packaged Connect discovery smoke requires Darwin, Linux, or Windows'); +} +if (process.arch !== 'x64' && process.arch !== 'arm64') { + throw new Error('Packaged Connect discovery smoke requires x64 or arm64'); +} + +let artifactRoot = resolve('out', `propr-desktop-${process.platform}-${process.arch}`); +let binaryPath = process.platform === 'darwin' + ? join(artifactRoot, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop') + : join(artifactRoot, process.platform === 'linux' ? 'propr-desktop' : 'propr-desktop.exe'); +let resourcesPath = process.platform === 'darwin' + ? join(artifactRoot, 'propr-desktop.app', 'Contents', 'Resources') + : join(artifactRoot, 'resources'); +let unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); +const endpoint = 'https://t-packaged123.propr.dev'; +const identity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const secrets = [ + 'tunnel-secret-SENTINEL', 'connector-secret-SENTINEL', + 'relay-secret-SENTINEL', 'github-secret-SENTINEL', +]; +const nativeHashes = { + darwin: { + arm64: { + 'connect-authority-broker': '75fda2624bf093555e726b968401321fef61ea7ae0479f4c1892be0dfc6554c0', + 'directory-operations.node': '88f07c0c7a4371f4fb227a4691009d09517de582ba49297d28d03ac94e586615', + }, + x64: { + 'connect-authority-broker': 'e5a49be0db85655b9ff1d0614de9d61defd41a0a1b2eff8f11571407f10d809b', + 'directory-operations.node': '62183c0f4083cb8c98e09e2d2c688f8f81703e12b0f22320c335b51e927eaf53', + }, + }, + linux: { + arm64: { + 'directory-operations.node': '916679f413251c4b23c51167987a874bbbdd9d96991882bfac9093e0ea5fa051', + }, + x64: { + 'directory-operations.node': '7199378f1c7b443a05c596eae7c66f9a77cc01b4a493c07748df0df1083950f6', + }, + }, +}; +let packagedConnectPhase = 'fixture-setup'; +let windowsStagedContract; +let windowsStagedHandoff; + +const createPackagedJourneyFixture = async () => { + const pairingId = `dpr_${'P'.repeat(22)}`; + const deviceSecret = 'D'.repeat(43); + const activationTicket = 'A'.repeat(43); + const token = `propr_it_${'T'.repeat(43)}`; + const receipt = 'R'.repeat(22); + const requests = []; + let endpoint; + let approved = false; + let active = false; + let binding; + let mode = 'success'; + let modeGeneration = 0; + const approvalReadinessDelayMs = process.platform === 'darwin' ? 300 : 0; + const cors = { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-ProPR-Desktop-Transport-Scope', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Allow-Private-Network': 'true', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }; + const readJson = request => new Promise((resolveBody, rejectBody) => { + const chunks = []; + let bytes = 0; + request.on('data', chunk => { + bytes += chunk.length; + if (bytes > 16 * 1024) { + rejectBody(new Error('oversized request')); + request.destroy(); + } else chunks.push(chunk); + }); + request.on('end', () => { + try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch (error) { rejectBody(error); } + }); + request.on('error', rejectBody); + }); + const server = createServer(async (request, response) => { + const record = { + method: request.method, + url: request.url, + authorization: request.headers.authorization ?? null, + origin: request.headers.origin ?? null, + transportScope: request.headers[DESKTOP_TRANSPORT_SCOPE_HEADER.toLowerCase()] ?? null, + credentialHeadersPresent: Object.keys(request.headers).some(name => + ['authorization', 'cookie', 'proxy-authorization'].includes(name.toLowerCase())), + socketIo: false, + fixtureMode: mode, + fixtureModeGeneration: modeGeneration, + }; + requests.push(record); + if (request.method === 'OPTIONS') { + response.writeHead(204, cors); + response.end(); + return; + } + try { + if (request.method === 'POST' && request.url?.startsWith('/__packaged/control/')) { + const requestedMode = request.url.slice('/__packaged/control/'.length); + if (!['success', 'malformed', 'oversized', 'expiry', 'cancel'].includes(requestedMode)) { + throw new Error('invalid fixture mode'); + } + mode = requestedMode; + modeGeneration += 1; + approved = false; + binding = undefined; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/__packaged/evidence') { + const authenticatedRest = requests.filter(item => item.socketIo === false + && item.url === '/api/auth/user' + && item.authorization === `Bearer ${token}` + && item.transportScope === null); + const authenticatedSockets = requests.filter(item => item.socketIo === true + && item.accepted === true + && item.authorization === `Bearer ${token}` + && item.transportScope !== null + && item.socketAuthScope === item.transportScope); + response.writeHead(200, cors); + response.end(JSON.stringify({ + authenticatedRest: authenticatedRest.length, + authenticatedSockets: authenticatedSockets.length, + })); + return; + } + if (request.method === 'GET' && request.url === '/api/desktop/discovery') { + response.writeHead(200, cors); + if (mode === 'malformed') { + response.end('{"product":"ProPR"}'); + return; + } + if (mode === 'oversized') { + response.end(`{"ignored":"${'x'.repeat(9 * 1024)}"}`); + return; + } + response.end(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: identity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + })); + return; + } + if (request.method === 'POST' && request.url === '/api/desktop/pairings') { + binding = await readJson(request); + response.writeHead(201, cors); + response.end(JSON.stringify({ + pairingId, + deviceSecret, + approvalUrl: `${endpoint}/api/desktop/pairings/${pairingId}/browser`, + expiresAt: new Date(Date.now() + (mode === 'expiry' ? 200 : 60_000)).toISOString(), + interval: 1, + })); + return; + } + if (request.method === 'GET' && request.url === `/api/desktop/pairings/${pairingId}/browser`) { + if (approvalReadinessDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, approvalReadinessDelayMs)); + record.approvalReadinessDelayed = true; + } + record.fixtureModeStable = record.fixtureMode === mode + && record.fixtureModeGeneration === modeGeneration; + approved = true; + response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' }); + response.end('Desktop approved

Approved

'); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/poll`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || !approved || !binding) throw new Error('pairing not approved'); + if (mode === 'cancel' || mode === 'expiry') { + response.writeHead(202, cors); + response.end('{"status":"pending","interval":1}'); + return; + } + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'provisional', token, tokenType: 'Bearer', activationTicket, + activationExpiresAt: new Date(Date.now() + 60_000).toISOString(), + instanceId: binding.instanceId, + origin: binding.origin, + scope: binding.scope, + credentialGeneration: binding.credentialGeneration, + })); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/activate`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || body.activationTicket !== activationTicket) { + throw new Error('activation binding rejected'); + } + active = true; + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'active', receipt, activatedAt: new Date().toISOString(), expiresAt: null, + })); + return; + } + if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') { + active = false; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/api/auth/user' + && active && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end(JSON.stringify({ + id: 'packaged-owner', login: 'packaged-owner', username: 'packaged-owner', + displayName: 'Packaged Owner', email: null, avatarUrl: null, + role: 'admin', permissions: [], authorizationSource: 'bootstrap', + })); + return; + } + if (request.method === 'GET' && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end('{}'); + return; + } + } catch { + response.writeHead(400, cors); + response.end('{"code":"INVALID_SMOKE_REQUEST"}'); + return; + } + response.writeHead(401, cors); + response.end('{"code":"INVALID_INSTANCE_TOKEN"}'); + }); + const io = new SocketIOServer(server, { + path: '/socket.io/', + transports: ['websocket'], + cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, + }); + io.of('/').use((socket, next) => { + const scopes = new URL(socket.handshake.url, 'http://fixture.invalid') + .searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + const accepted = active + && socket.handshake.headers.authorization === `Bearer ${token}` + && scopes.length === 1 + && socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] === scopes[0]; + requests.push({ + method: 'SOCKET.IO', + url: socket.handshake.url, + authorization: socket.handshake.headers.authorization ?? null, + origin: socket.handshake.headers.origin ?? null, + transportScope: scopes[0] ?? null, + socketQueryScopeCount: scopes.length, + socketAuthScope: socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] ?? null, + socketIo: true, + accepted, + }); + if (!accepted) { + const error = new Error('INVALID_INSTANCE_TOKEN'); + error.data = { code: 'INVALID_INSTANCE_TOKEN' }; + next(error); + return; + } + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Packaged journey fixture did not bind'); + endpoint = `http://127.0.0.1:${address.port}`; + const close = createIdempotentJourneyFixtureClose({ + closeSocketServer: () => io.close(), + closeHttpServer: () => new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }), + }); + return { + endpoint, + requests, + secrets: [deviceSecret, activationTicket, token], + close, + }; +}; + +const directoryContainsPlaintext = async (root, needles) => { + const visit = async path => { + const entries = await readdir(path, { withFileTypes: true }); + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory()) { + if (await visit(child)) return true; + } else if (entry.isFile()) { + const contents = await readFile(child); + if (needles.some(needle => contents.includes(Buffer.from(needle)))) return true; + } + } + return false; + }; + return visit(root); +}; + +if (process.platform === 'win32') { + try { + packagedConnectPhase = 'staged-contract'; + [windowsStagedHandoff] = process.argv.slice(2); + windowsStagedContract = parseWindowsStagedPackageHandoff(process.argv.slice(2)); + const staged = await validateWindowsStagedPackage({ + environment: { + RUNNER_TEMP: windowsStagedContract.runnerTemp, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: windowsStagedContract.parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: windowsStagedContract.leaf, + }, + expectedArchitecture: process.arch, + }); + artifactRoot = staged.root; + binaryPath = staged.executable; + resourcesPath = staged.resources; + unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); + } catch (error) { + const failure = describeWindowsArtifactFailure(error, packagedConnectPhase); + process.stderr.write(`${JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...failure, + })}\n`); + process.exit(1); + } +} + +const authorityMechanism = () => { + if (process.platform === 'darwin') return 'packaged-broker'; + if (process.platform === 'linux') return 'in-process-native-addon'; + return 'inherited-standard-handle'; +}; + +const windowsTreeKiller = async () => { + if (process.platform !== 'win32') return undefined; + const powershell = windowsPowerShell51Path(); + const candidate = join(dirname(dirname(dirname(powershell))), 'taskkill.exe'); + const stats = await lstat(candidate); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('Windows tree termination tool failed validation'); + } + const canonical = await realpath(candidate); + if (canonical.toLocaleLowerCase('en-US') !== candidate.toLocaleLowerCase('en-US')) { + throw new Error('Windows tree termination tool failed validation'); + } + return canonical; +}; + +const assertCanonicalParents = async candidate => { + let parent = dirname(candidate); + while (true) { + const named = await lstat(parent); + if (!named.isDirectory() || named.isSymbolicLink() || await realpath(parent) !== parent) { + throw new Error('Packaged native candidate has noncanonical parent ancestry'); + } + const next = dirname(parent); + if (next === parent) return; + parent = next; + } +}; + +const assertPackageAuthority = async () => { + if (process.platform === 'win32') { + try { + await lstat(unpackedNative); + throw new Error('Windows package unexpectedly contains an unused native authority helper'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + return; + } + const selected = join(unpackedNative, `${process.platform}-${process.arch}`); + for (const [name, expected] of Object.entries(nativeHashes[process.platform][process.arch])) { + const candidate = join(selected, name); + await assertCanonicalParents(candidate); + const named = await lstat(candidate); + if (!named.isFile() + || named.isSymbolicLink() + || (named.mode & 0o022) !== 0 + || (name === 'connect-authority-broker' && (named.mode & 0o111) === 0)) { + throw new Error('Packaged native authority artifact failed type or mode verification'); + } + const digest = createHash('sha256').update(await readFile(candidate)).digest('hex'); + if (digest !== expected) throw new Error('Packaged native authority artifact failed integrity verification'); + } + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + try { + await lstat(join(unpackedNative, `${process.platform}-${otherArch}`)); + throw new Error('Package contains unselected architecture authority artifacts'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +}; + +const windowsFixtureFailure = (phase, category) => { + const error = new Error(`Could not prepare the ordinary-user Windows authority fixture [phase=${phase} category=${category}]`); + error.stack = error.message; + throw error; +}; + +const protectWindowsEntries = entries => { + const powershell = windowsPowerShell51Path(); + const membership = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Console]::Out.Write(([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))', + ], { shell: false, windowsHide: true, encoding: 'utf8', timeout: 10_000 }); + if (membership.error || membership.signal || membership.status !== 0 || membership.stderr) { + windowsFixtureFailure('membership', 'process-failed'); + } + if (membership.stdout !== 'False') windowsFixtureFailure('membership', 'administrator'); + for (const entry of entries) { + const canonicalEntry = canonicalizeWindowsFixtureEntry({ + entryKind: entry.kind, + entryPath: entry.path, + powershellPath: powershell, + }); + const result = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedWindowsFixtureAcl, + ], { + shell: false, + windowsHide: true, + timeout: 30_000, + env: { + ...process.env, + PROPR_FIXTURE_ACL_KIND: entry.kind, + PROPR_FIXTURE_ACL_PATH: canonicalEntry.path, + }, + }); + if (result.error || result.signal) windowsFixtureFailure('powershell-invocation', 'process-failed'); + if (result.stdout.length !== 0) windowsFixtureFailure('powershell-invocation', 'powershell-stdout'); + if (result.stderr.length !== 0) windowsFixtureFailure('powershell-invocation', 'powershell-stderr'); + const failurePhase = new Map([ + [40, 'rooted-path'], + [41, 'item-type'], + [42, 'current-sid-lookup'], + [43, 'sid-construction'], + [44, 'access-control-read'], + [45, 'dacl-protection'], + [46, 'rule-create'], + [47, 'rule-apply'], + [48, 'full-path'], + [49, 'canonical-equality'], + [50, 'outer-invocation'], + ]).get(result.status); + if (failurePhase) windowsFixtureFailure(failurePhase, 'operation-failed'); + if (result.status !== 0) windowsFixtureFailure('powershell-invocation', 'unexpected-exit'); + } +}; + +let canonicalTemp; +let fixture; +let generatedFixtureLeaf; +let journeyFixture; +let outcome = { ok: false, category: 'fixture-setup', capture: 'complete', records: [] }; +let failurePhase = 'fixture-setup'; +try { + canonicalTemp = await realpath(tmpdir()); + fixture = await mkdtemp(join(canonicalTemp, 'propr-desktop-connect-smoke-')); + generatedFixtureLeaf = basename(fixture); + const configRoot = join(fixture, 'config'); + const stackRoot = join(fixture, 'stack-private-path-SENTINEL'); + const dataRoot = join(stackRoot, 'data'); + const identityPath = join(dataRoot, 'public-instance-identity.json'); + const envPath = join(stackRoot, '.env'); + const configPath = join(configRoot, 'config.json'); + const userDataPath = join(fixture, 'desktop-user-data'); + await mkdir(configRoot, { recursive: true, mode: 0o700 }); + await mkdir(dataRoot, { recursive: true, mode: 0o700 }); + await mkdir(userDataPath, { recursive: true, mode: 0o700 }); + await writeFile(configPath, `${JSON.stringify({ stackRoot })}\n`, { mode: 0o600 }); + await writeFile(envPath, [ + 'PROPR_STACK=packaged-connect-smoke', + 'PROPR_INSTANCE_ID=packaged123', + `PROPR_UI_PUBLIC_API_URL=${endpoint}`, + 'PROPR_UI_TUNNEL_ENABLED=true', + `PROPR_UI_TUNNEL_TOKEN=${secrets[0]}`, + '', + ].join('\n'), { mode: 0o600 }); + await writeFile(identityPath, `${JSON.stringify({ schemaVersion: 1, publicInstanceIdentity: identity })}\n`, { mode: 0o644 }); + if (process.platform !== 'win32') { + await Promise.all([ + chmod(fixture, 0o700), chmod(configRoot, 0o700), chmod(stackRoot, 0o700), + chmod(dataRoot, 0o700), chmod(userDataPath, 0o700), chmod(configPath, 0o600), + chmod(envPath, 0o600), chmod(identityPath, 0o644), + ]); + } else { + protectWindowsEntries([ + { path: stackRoot, kind: 'directory' }, + { path: dataRoot, kind: 'directory' }, + { path: envPath, kind: 'file' }, + { path: identityPath, kind: 'file' }, + ]); + } + if (relative(canonicalTemp, fixture) !== generatedFixtureLeaf + || relative(canonicalTemp, configRoot) !== join(generatedFixtureLeaf, 'config')) { + throw new Error('Connect smoke fixture escaped its fixed root'); + } + if (process.platform !== 'win32') journeyFixture = await createPackagedJourneyFixture(); + failurePhase = 'package-validation'; + await assertPackageAuthority(); + const treeKillerPath = await windowsTreeKiller(); + const sensitiveNeedles = [ + ...secrets, fixture, configRoot, stackRoot, identity, + ...(journeyFixture?.secrets ?? []), + ...packagedConnectArtifactSensitiveNeedles({ + platform: process.platform, + artifactRoot, + binaryPath, + stagedContract: windowsStagedContract, + stagedHandoff: windowsStagedHandoff, + }), + 'S-1-5-', 'volumeSerialNumber', 'fileId', 'authorityDiagnostic', + ]; + const childEnvironment = { + ...process.env, + PROPR_DESKTOP_CONNECT_SMOKE_TEST: '1', + PROPR_DESKTOP_CONNECT_SMOKE_CONFIG_ROOT: configRoot, + PROPR_CONNECTOR_TOKEN: secrets[1], + PROPR_RELAY_TOKEN: secrets[2], + GITHUB_TOKEN: secrets[3], + ...(journeyFixture ? { + PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT: journeyFixture.endpoint, + } : {}), + }; + delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_PARENT; + delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_LEAF; + const launchArguments = createPackagedConnectLaunchArguments({ + platform: process.platform, + userDataPath, + }); + const spawnLifecycleProcess = (executable, args, options) => { + if (executable !== binaryPath) return spawn(executable, args, options); + const child = spawnPackagedConnectBinary({ + binaryPath, + launchArguments: args, + options: { + ...options, + env: options.env, + }, + spawn, + }); + return child; + }; + failurePhase = 'lifecycle-internal'; + const runPhase = async phase => await runPackagedConnectLifecycle({ + binaryPath, + args: launchArguments, + platform: process.platform, + arch: process.arch, + authorityMechanism: authorityMechanism(), + ...(process.platform === 'darwin' ? { expectedStorageBackend: 'os-protected' } : {}), + sensitiveNeedles, + treeKillerPath, + env: { + ...childEnvironment, + ...(journeyFixture ? { PROPR_DESKTOP_CONNECT_JOURNEY_PHASE: phase } : {}), + }, + spawn: spawnLifecycleProcess, + }); + outcome = await runPhase('pair'); + if (outcome.ok && journeyFixture) { + const pairingRequestCountAtPairTerminal = journeyFixture.requests.filter(request => + request.method !== 'OPTIONS' + && (request.url === '/api/desktop/pairings' + || /^\/api\/desktop\/pairings\/[^/]+\/(?:browser|poll|activate)$/u.test(request.url ?? ''))).length; + outcome = await runPhase('reprobe'); + if (outcome.ok) { + const applicationRequests = journeyFixture.requests.filter(request => request.method !== 'OPTIONS'); + const discoveries = applicationRequests.filter(request => request.url === '/api/desktop/discovery'); + const bootstrap = applicationRequests.filter(request => + request.url === '/api/desktop/pairings' + || /^\/api\/desktop\/pairings\/[^/]+\/(?:poll|activate)$/u.test(request.url ?? '') + || /\/browser$/u.test(request.url ?? '')); + const pairingStarts = bootstrap.filter(request => request.method === 'POST' + && request.url === '/api/desktop/pairings'); + const pairingBrowsers = bootstrap.filter(request => request.method === 'GET' + && /\/browser$/u.test(request.url ?? '')); + const pairingPolls = bootstrap.filter(request => request.method === 'POST' + && /\/poll$/u.test(request.url ?? '')); + const pairingActivations = bootstrap.filter(request => request.method === 'POST' + && /\/activate$/u.test(request.url ?? '')); + const intendedPairingModes = ['expiry', 'cancel', 'success']; + const hasExactModes = requests => requests.length === intendedPairingModes.length + && requests.every((request, index) => request.fixtureMode === intendedPairingModes[index]); + const authenticatedRest = applicationRequests.filter(request => + request.socketIo === false + && request.url === '/api/auth/user' + && request.authorization === `Bearer ${journeyFixture.secrets[2]}`); + const socketEvidence = collectAcceptedSocketEvidence({ + requests: applicationRequests, + authorization: `Bearer ${journeyFixture.secrets[2]}`, + }); + const restScopes = new Set(authenticatedRest.map(request => request.transportScope)); + const firstBearer = applicationRequests.findIndex(request => request.authorization !== null); + const firstIdentity = applicationRequests.findIndex(request => request.url === '/api/desktop/discovery'); + const plaintextPersisted = await directoryContainsPlaintext(userDataPath, journeyFixture.secrets); + const evidenceFailure = evaluatePackagedConnectEvidence({ + discoveryCount: discoveries.length, + discoveryAuthorizationPresent: discoveries.some(request => request.authorization !== null), + pairingStartCount: pairingStarts.length, + pairingBrowserCount: pairingBrowsers.length, + pairingPollCount: pairingPolls.length, + pairingActivationCount: pairingActivations.length, + pairingMethodBoundaryValid: bootstrap.length === pairingStarts.length + + pairingBrowsers.length + pairingPolls.length + pairingActivations.length, + pairingBrowserCredentialPresent: pairingBrowsers.some(request => + request.credentialHeadersPresent === true), + pairingIntentSequenceValid: hasExactModes(pairingStarts) && hasExactModes(pairingBrowsers), + pairingLifecycleIsolated: pairingBrowsers.every(request => request.fixtureModeStable === true) + && pairingPolls.every(request => request.fixtureMode === 'success') + && pairingActivations.every(request => request.fixtureMode === 'success'), + pairingRequestAfterTerminal: bootstrap.length !== pairingRequestCountAtPairTerminal, + delayedApprovalReadinessProven: process.platform !== 'darwin' + || pairingBrowsers.every(request => request.approvalReadinessDelayed === true), + bootstrapAuthorizationPresent: bootstrap.some(request => request.authorization !== null), + authenticatedRestCount: authenticatedRest.length, + authenticatedSocketCount: socketEvidence.authenticatedSocketCount, + restScopeCount: restScopes.size, + restHasOnlyNullScope: restScopes.has(null), + socketHasNullScope: socketEvidence.socketHasNullScope, + socketScopeBindingMismatch: socketEvidence.socketScopeBindingMismatch, + socketScopeCount: socketEvidence.socketScopeCount, + plaintextCredentialPersisted: plaintextPersisted, + firstIdentityIndex: firstIdentity, + firstBearerIndex: firstBearer, + }); + if (evidenceFailure) { + outcome = { + ok: false, + category: 'journey-evidence', + capture: 'complete', + records: [evidenceFailure], + }; + } + } + } +} catch { + outcome = { ok: false, category: failurePhase, capture: 'complete', records: [] }; +} finally { + let cleanup = { ok: true }; + if (journeyFixture) { + try { await journeyFixture.close(); } + catch { cleanup = { ok: false, category: 'fixture-cleanup-failed' }; } + } + if (fixture && canonicalTemp && generatedFixtureLeaf) { + const directoryCleanup = await removeAuthorizedConnectFixture({ + fixture, + canonicalTemporaryParent: canonicalTemp, + generatedLeaf: generatedFixtureLeaf, + }); + if (!directoryCleanup.ok) cleanup = directoryCleanup; + } + if (!cleanup.ok) { + outcome = preservePrimaryWithCleanup(outcome, cleanup); + } + if (outcome.ok && cleanup.ok) { + process.stdout.write(`Packaged Connect discovery passed for ${process.platform}-${process.arch}: ${authorityMechanism()}.\n`); + } else { + process.stderr.write(`${JSON.stringify({ + event: 'packaged_connect.smoke_failed', + category: outcome.category, + capture: outcome.capture, + records: outcome.records, + ...(outcome.secondary?.length ? { secondary: outcome.secondary } : {}), + })}\n`); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs new file mode 100644 index 000000000..40880661b --- /dev/null +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -0,0 +1,497 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { access, readFile, readdir } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join, resolve } from 'node:path'; +import { Server as SocketIOServer } from 'socket.io'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_QUERY, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { + FuseState, + FuseV1Options, + FuseVersion, + getCurrentFuseWire, +} from '@electron/fuses'; +import { assertPackagedLayout, parseEventLayout, parseEventRecord } from './packaged-layout.mjs'; +import { + createPackagedSmokeLaunch, + LAYOUT_READY_EVENT, + MVP_FLOWS_PROOF, + PACKAGED_SMOKE_LAUNCH_MODES, + REDUCED_NATIVE_WINDOW_READY_EVENT, + TRANSPORT_PROOF, + TRANSPORT_SMOKE_ENVIRONMENT_NAMES, +} from './packaged-smoke-plan.mjs'; +import { + assertPackagedNativeWindowSizing, + createPrivateSmokeProfile, + createSmokeChildEnvironment, + removePrivateSmokeProfile, +} from './packaged-smoke-support.mjs'; + +const MAIN_PROCESS_ERROR_MARKERS = [ + 'desktop.main_process.uncaught_exception', + 'A JavaScript error occurred in the main process', + 'Uncaught Exception:', +]; +const TIMEOUT_MS = 45_000; +const RELEASE_GUARD_TIMEOUT_MS = 30_000; +const INVALID_INSTANCE_TOKEN = 'INVALID_INSTANCE_TOKEN'; +const binaryPath = process.platform === 'darwin' + ? resolve('out', `propr-desktop-darwin-${process.arch}`, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop') + : resolve( + 'out', + `propr-desktop-${process.platform}-${process.arch}`, + `propr-desktop${process.platform === 'win32' ? '.exe' : ''}`, + ); +const inspectOnly = process.argv.includes('--inspect-only'); + +if (process.platform === 'win32') { + const resources = resolve('out', `propr-desktop-win32-${process.arch}`, 'resources'); + const entries = (await readdir(resources)).map(name => name.toLocaleLowerCase('en-US')); + if (entries.some(name => name.includes('windows-authority') || name.includes('windows-update-authority'))) { + throw new Error('Packaged Windows MVP contains a deferred update authority resource'); + } +} + +await access(binaryPath); + +const expectedFuses = new Map([ + [FuseV1Options.RunAsNode, FuseState.DISABLE], + [FuseV1Options.EnableCookieEncryption, FuseState.ENABLE], + [FuseV1Options.EnableNodeOptionsEnvironmentVariable, FuseState.DISABLE], + [FuseV1Options.EnableNodeCliInspectArguments, FuseState.DISABLE], + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FuseState.ENABLE], + [FuseV1Options.OnlyLoadAppFromAsar, FuseState.ENABLE], + [FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FuseState.DISABLE], + [FuseV1Options.GrantFileProtocolExtraPrivileges, FuseState.DISABLE], + [FuseV1Options.WasmTrapHandlers, FuseState.ENABLE], +]); +const actualFuses = await getCurrentFuseWire(binaryPath); +if (actualFuses.version !== FuseVersion.V1) { + throw new Error(`Expected fuse wire version ${FuseVersion.V1}, received ${actualFuses.version}`); +} +for (const [fuse, expectedState] of expectedFuses) { + const actualState = actualFuses[fuse]; + if (actualState !== expectedState) { + throw new Error( + `Unexpected ${FuseV1Options[fuse]} fuse state: expected ${FuseState[expectedState]}, received ${FuseState[actualState] ?? actualState}`, + ); + } +} + +if (inspectOnly) { + console.log(`Packaged ${process.platform}-${process.arch} desktop artifact passed executable and fuse inspection.`); + process.exit(0); +} +if (process.platform !== 'linux' && process.platform !== 'win32') { + throw new Error('The packaged transport smoke requires Linux or Windows'); +} + +const requests = []; +const fixtures = []; +const corsHeaders = { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'Content-Type, X-ProPR-Desktop-Transport-Scope', + 'Access-Control-Allow-Methods': 'GET, DELETE, OPTIONS', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Allow-Private-Network': 'true', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', +}; +const discovery = publicInstanceIdentity => JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}); + +const profileApiRequests = []; +const profileApiServer = createServer((request, response) => { + const record = { + method: request.method, + url: request.url, + origin: request.headers.origin ?? null, + }; + profileApiRequests.push(record); + if ( + record.method !== 'GET' + || !['/api/compatibility', '/api/desktop/discovery'].includes(record.url ?? '') + || record.origin !== DESKTOP_RENDERER_ORIGIN + ) { + response.writeHead(403, { 'Content-Type': 'application/json' }); + response.end('{"error":"CORS origin rejected"}'); + return; + } + response.writeHead(200, { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Content-Type': 'application/json', + }); + response.end(record.url === '/api/desktop/discovery' + ? '{"product":"ProPR","desktopAuthentication":{"protocolVersion":1}}' + : '{"profileEndpoint":true}'); +}); + +const listenProfileApiFixture = async () => { + profileApiServer.listen(0, '127.0.0.1'); + await once(profileApiServer, 'listening'); + const address = profileApiServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Packaged desktop release-guard profile API did not bind to a TCP port'); + } + return `http://127.0.0.1:${address.port}`; +}; + +const listenFixture = async name => { + const server = createServer((request, response) => { + const record = { + fixture: name, + method: request.method, + url: request.url, + authorization: request.headers.authorization ?? null, + cookie: request.headers.cookie ?? null, + origin: request.headers.origin ?? null, + socketIo: false, + }; + requests.push(record); + if (request.method === 'OPTIONS') { + response.writeHead(204, corsHeaders); + response.end(); + return; + } + if (request.url === '/smoke-storage') { + response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + response.end('storage fixture'); + return; + } + if (request.url === '/smoke-sw.js') { + response.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store', 'Service-Worker-Allowed': '/' }); + response.end("self.addEventListener('fetch', () => undefined);"); + return; + } + if (request.url === '/api/desktop/discovery') { + response.writeHead(200, { ...corsHeaders, 'Set-Cookie': 'discovery=must-not-persist; HttpOnly; SameSite=None' }); + response.end(discovery(name === 'first' + ? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + : 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')); + return; + } + if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') { + response.writeHead(204, corsHeaders); + response.end(); + return; + } + if ((request.url === '/api/auth/user' || request.url === '/api/smoke/rest') + && /^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '')) { + response.writeHead(200, { ...corsHeaders, 'Set-Cookie': 'remote=must-not-persist; HttpOnly; SameSite=None' }); + response.end(request.url === '/api/auth/user' ? '{"username":"packaged-smoke"}' : '{"ok":true}'); + return; + } + response.writeHead(401, corsHeaders); + response.end('{"code":"INVALID_INSTANCE_TOKEN"}'); + }); + const io = new SocketIOServer(server, { + path: '/socket.io/', + transports: ['websocket'], + cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, + }); + io.of('/').use((socket, next) => { + const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid') + .searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + const activationScope = socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY]; + const record = { + fixture: name, + method: 'SOCKET.IO', + url: socket.handshake.url, + authorization: socket.handshake.headers.authorization ?? null, + cookie: socket.handshake.headers.cookie ?? null, + origin: socket.handshake.headers.origin ?? null, + socketIo: true, + namespace: socket.nsp.name, + engineProtocol: socket.conn.protocol, + }; + requests.push(record); + if (!/^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '') + || queryScopes.length !== 1 || typeof activationScope !== 'string' + || activationScope !== queryScopes[0]) { + const error = new Error(INVALID_INSTANCE_TOKEN); + error.data = { code: INVALID_INSTANCE_TOKEN }; + next(error); + return; + } + next(); + }); + io.of('/').on('connection', socket => socket.emit('packaged-smoke:connected', { ok: true })); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error(`Packaged ${name} fixture did not bind`); + const fixture = { server, io, origin: `http://127.0.0.1:${address.port}` }; + fixtures.push(fixture); + return fixture; +}; + +const scanPathsForSecrets = async (paths, secrets) => { + const visit = async path => { + let entries; + try { entries = await readdir(path, { withFileTypes: true }); } + catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory()) { + if (await visit(child)) return true; + } else { + const bytes = await readFile(child); + if (secrets.some(secret => bytes.includes(Buffer.from(secret)))) return true; + } + } + return false; + }; + for (const path of paths) if (await visit(path)) return true; + return false; +}; + +let first; +let second; +let profileApiOrigin; +const runs = []; +const smokeProfiles = []; +const shutdownSteps = [ + 'admission-closed', + 'ipc-closed', + 'session-closed', + 'protocol-disposed', + 'credentials-dispose-started', + 'authentication-cleared', + 'lifecycle-drain-started', + 'ipc-drain-started', + 'service-drain-finished', + 'profiles-close-started', + 'profiles-close-finished', + 'session-disposed', + 'ipc-disposed', + 'window-destroyed', + 'final-quit', +]; + +const launch = async mode => { + const smokeProfile = await createPrivateSmokeProfile(); + smokeProfiles.push(smokeProfile); + const userDataPath = smokeProfile.userData; + const transport = mode !== 'release-guard'; + const baseChildEnvironment = await createSmokeChildEnvironment({ + profile: smokeProfile, + profileApiUrl: transport ? first.origin : profileApiOrigin, + }); + const dbusSessionAddress = process.env.DBUS_SESSION_BUS_ADDRESS; + if (process.platform === 'linux' && ( + typeof dbusSessionAddress !== 'string' + || dbusSessionAddress.length > 4096 + || !/^unix:path=\/[^\0\r\n,]+(?:,guid=[0-9a-f]{32})?$/.test(dbusSessionAddress) + )) { + throw new Error('Packaged Linux smoke requires one validated D-Bus session address'); + } + const launchPlan = createPackagedSmokeLaunch({ + mode, + platform: process.platform, + userDataPath, + baseChildEnvironment, + firstOrigin: first.origin, + secondOrigin: second.origin, + dbusSessionAddress, + }); + const { childEnvironment, launchArguments, requiredMarkers } = launchPlan; + if (launchArguments.some(argument => argument === '--no-sandbox' || argument === '--disable-sandbox')) { + throw new Error('The packaged-binary smoke test must not disable Electron sandboxing'); + } + if (!transport && TRANSPORT_SMOKE_ENVIRONMENT_NAMES.some(name => Object.hasOwn(childEnvironment, name))) { + throw new Error('Packaged desktop release-guard launch inherited a transport-smoke environment variable'); + } + const requestStart = requests.length; + const profileApiRequestStart = profileApiRequests.length; + let output = ''; + const child = spawn(binaryPath, launchArguments, { + cwd: smokeProfile.root, + env: childEnvironment, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const capture = chunk => { + const value = chunk.toString(); + output += value; + process.stdout.write(value); + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + const result = await new Promise((resolveResult, reject) => { + const timeoutMs = transport ? TIMEOUT_MS : RELEASE_GUARD_TIMEOUT_MS; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Packaged desktop ${mode} smoke exceeded ${timeoutMs / 1000} seconds`)); + }, timeoutMs); + child.once('error', error => { clearTimeout(timeout); reject(error); }); + child.once('close', (code, signal) => { + clearTimeout(timeout); + resolveResult({ code, signal }); + }); + }); + + const mainProcessError = MAIN_PROCESS_ERROR_MARKERS.find(marker => output.includes(marker)); + if (mainProcessError) throw new Error(`Packaged desktop reported an uncaught exception (${mainProcessError})`); + if (result.code !== 0) { + throw new Error(`Packaged desktop exited with code ${result.code ?? 'null'} (signal ${result.signal ?? 'none'})`); + } + const missingMarkers = requiredMarkers.filter(marker => !output.includes(marker)); + if (missingMarkers.length !== 0) { + throw new Error(`Packaged desktop ${mode} smoke missed required markers: ${missingMarkers.join(', ')}`); + } + const runRequests = requests.slice(requestStart); + if (!transport) { + const releaseGuardRequests = profileApiRequests.slice(profileApiRequestStart); + const expectedProfileApiPaths = ['/api/compatibility', '/api/desktop/discovery']; + if (releaseGuardRequests.length !== expectedProfileApiPaths.length + || expectedProfileApiPaths.some(path => !releaseGuardRequests.some(request => request.url === path)) + || releaseGuardRequests.some(request => ( + request.method !== 'GET' || request.origin !== DESKTOP_RENDERER_ORIGIN + ))) { + throw new Error('Packaged desktop release guard did not make both profile API requests from its exact renderer origin'); + } + const mvpProof = parseEventRecord(output, MVP_FLOWS_PROOF); + if (mvpProof?.localProfile !== true + || mvpProof?.remoteActiveProfile !== true + || mvpProof?.lifecycleBoundary !== true + || mvpProof?.connectUiPopulated !== true) { + throw new Error('Packaged desktop release guard did not prove local/remote profiles, lifecycle, and Connect UI population'); + } + if (runRequests.length !== 0 || output.includes(TRANSPORT_PROOF)) { + throw new Error('Packaged desktop release guard unexpectedly entered the transport-smoke branch'); + } + } else { + const socketShutdownDeadline = Date.now() + 2_000; + while (fixtures.some(fixture => fixture.io.of('/').sockets.size !== 0) + && Date.now() < socketShutdownDeadline) { + await new Promise(resolveWait => setTimeout(resolveWait, 20)); + } + if (fixtures.some(fixture => fixture.io.of('/').sockets.size !== 0)) { + throw new Error(`Packaged ${mode} shutdown left late authenticated Socket.IO work alive`); + } + const expectedBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!output.includes(`"storageBackend":"${expectedBackend}"`)) { + throw new Error(`Packaged desktop did not use ${expectedBackend} production credential protection`); + } + let previousStep = -1; + for (const step of shutdownSteps) { + const marker = `"step":"${step}"`; + if (output.split(marker).length - 1 !== 1 || output.indexOf(marker) <= previousStep) { + throw new Error(`Packaged ${mode} shutdown did not run ${step} exactly once in order`); + } + previousStep = output.indexOf(marker); + } + const forced = output.includes('desktop.app.shutdown_forced'); + if (forced !== (mode === 'forced-timeout')) { + throw new Error(`Packaged ${mode} forced-timeout evidence was incorrect`); + } + if (mode === 'retry' && (!output.includes('desktop.app.shutdown_retry_requested') + || !output.includes('desktop.app.shutdown_retry'))) { + throw new Error('Packaged retry did not exercise a repeated prevented before-quit event'); + } + } + const packagedLayout = parseEventLayout(output, LAYOUT_READY_EVENT); + assertPackagedLayout(packagedLayout); + assertPackagedNativeWindowSizing(packagedLayout); + assertPackagedNativeWindowSizing( + parseEventLayout(output, REDUCED_NATIVE_WINDOW_READY_EVENT), + { requireReducedWorkArea: true }, + ); + + if (!transport) { + runs.push({ mode, userDataPath, output, launchArguments, secrets: [] }); + return; + } + const authenticated = runRequests.filter(request => request.authorization?.startsWith('Bearer propr_it_')); + const secrets = [...new Set(authenticated.map(request => request.authorization.slice('Bearer '.length)))]; + if (secrets.length !== 2) throw new Error(`Expected two ${mode} activation credentials, observed ${secrets.length}`); + for (const name of ['first', 'second']) { + const fixtureRequests = authenticated.filter(request => request.fixture === name); + const namespaceConnections = fixtureRequests.filter(request => request.socketIo); + if (!fixtureRequests.some(request => request.url === '/api/auth/user') + || !fixtureRequests.some(request => request.url === '/api/smoke/rest') + || namespaceConnections.length < (name === 'second' ? 2 : 1) + || namespaceConnections.some(request => request.namespace !== '/' || request.engineProtocol !== 4)) { + throw new Error(`Packaged ${mode} ${name} fixture missed REST, Engine.IO, namespace auth, or reconnect proof`); + } + if (new Set(fixtureRequests.map(request => request.authorization)).size !== 1) { + throw new Error(`Packaged ${mode} ${name} fixture observed cross-generation bearer use`); + } + } + if (runRequests.some(request => request.cookie !== null) + || runRequests.some(request => secrets.some(secret => request.url?.includes(secret)))) { + throw new Error('Packaged renderer transport sent cookies or placed a credential in a URL'); + } + if (secrets.some(secret => output.includes(secret) || launchArguments.some(argument => argument.includes(secret)))) { + throw new Error('Packaged credential entered stdout, stderr, or argv'); + } + const credentialFiles = await readdir(join(userDataPath, 'desktop', 'credentials')); + if (credentialFiles.length === 0 || await scanPathsForSecrets([userDataPath], secrets)) { + throw new Error('Packaged credential material was missing or plaintext anywhere under isolated userData'); + } + runs.push({ mode, userDataPath, output, launchArguments, secrets }); +}; + +try { + profileApiOrigin = await listenProfileApiFixture(); + first = await listenFixture('first'); + second = await listenFixture('second'); + for (const mode of PACKAGED_SMOKE_LAUNCH_MODES) await launch(mode); + const allSecrets = runs.flatMap(run => run.secrets); + const scanRoots = [ + ...runs.map(run => run.userDataPath), + ...(process.env.PROPR_DESKTOP_SMOKE_KEYRING_ROOT ? [resolve(process.env.PROPR_DESKTOP_SMOKE_KEYRING_ROOT)] : []), + ]; + if (await scanPathsForSecrets(scanRoots, allSecrets)) { + throw new Error('A packaged credential entered the isolated userData or OS keyring scan roots'); + } + console.log( + `Packaged ${process.platform} desktop smoke passed (4/4 isolated launches): release-guard protocol-1 profile ` + + 'and Connect UI proof; 3/3 protocol-2 transport shutdown modes with production OS credentials, real ' + + 'Socket.IO/Engine.IO namespace auth, scope rotation/reconnect/error handling, five-type both-origin ' + + `rollback cleanup, compiled welcome-card layout, no cookies, and byte scans of ${scanRoots.join(', ')}.`, + ); +} finally { + try { + for (const { io, server } of fixtures) { + await new Promise(resolveClose => io.close(resolveClose)); + if (server.listening) await new Promise(resolveClose => server.close(resolveClose)); + } + } finally { + try { + if (profileApiServer.listening) { + profileApiServer.closeAllConnections(); + await new Promise((resolveClose, rejectClose) => profileApiServer.close(error => { + if (error) rejectClose(error); + else resolveClose(); + })); + } + } finally { + for (const smokeProfile of smokeProfiles) await removePrivateSmokeProfile(smokeProfile); + } + } +} diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 new file mode 100644 index 000000000..ee1de9bb9 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -0,0 +1,1016 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest +) + +$ErrorActionPreference = 'Stop' + +function Initialize-FixtureDirectoryIdentity { + if ('ProPRFixtureDirectoryIdentity' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRFixtureDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || + isDirectory != expectDirectory) + throw new InvalidOperationException("fixture entry identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } + public static string Read(string path) { return ReadEntry(path, true); } +} +'@ +} +$scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO +$stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY +if ($scenario -notin @( + 'NO_MARKER', + 'NO_MARKER_WINDOWS_POWERSHELL', + 'VALID_THEN_DEADLINE', + 'MALFORMED_MARKER', + 'TORN_MARKER', + 'STALE_MARKER', + 'INACCESSIBLE_MARKER', + 'NEGATIVE_EXIT', + 'CANCELLATION', + 'DURING_MSI', + 'DURING_OWNERSHIP_CAPTURE', + 'OWNED_RESOURCES_NORMAL_SUCCESS', + 'OWNED_RESOURCES_FOR_INTERRUPTION', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_RESOURCES_THEN_DEADLINE' + )) { + throw 'fixture scenario is invalid' +} +if (!$stateDirectory -or !(Test-Path -LiteralPath $stateDirectory -PathType Container)) { + throw 'fixture state directory is invalid' +} + +function Write-FixtureMarker([string]$Record) { + $temporaryMarker = "$WatchdogMarker.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($Record) + $stream = [IO.FileStream]::new( + $temporaryMarker, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryMarker, $WatchdogMarker, $true) +} + +function Write-FixtureOwnershipManifest($Manifest) { + $temporaryManifest = "$OwnershipManifest.new" + $bytes = [Text.Encoding]::UTF8.GetBytes(($Manifest | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryManifest, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryManifest, $OwnershipManifest, $true) +} + +function Write-FixtureCriticalGate([string]$Name) { + [IO.File]::WriteAllText( + (Join-Path $stateDirectory 'critical-gate.txt'), + $Name, + [Text.Encoding]::ASCII + ) +} + +function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Get-FixtureFileIdentity([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-FixtureEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture file-system object identity is invalid' + } + return [ProPRFixtureDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FixtureTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FixtureEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FixtureEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { $sha256.Dispose() } +} + +function Set-FixtureSmokeAcl([string]$Path, [string]$UserSid) { + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administratorsSid) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + foreach ($sid in @( + [Security.Principal.SecurityIdentifier]::new($UserSid), + $systemSid, + $administratorsSid + )) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop +} + +function New-FixtureSmokeArtifacts([string]$Path) { + $electronData = Join-Path $Path 'profile\AppData\Local\ProPR' + [void](New-Item -ItemType Directory -Path $electronData -Force -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.stdout.log'), 'owned-log', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.smoke-evidence.jsonl'), + '{"event":"desktop.smoke.authorized"}', [Text.Encoding]::UTF8) + [IO.File]::WriteAllText( + (Join-Path $electronData 'electron-data.json'), 'owned-electron-data', [Text.Encoding]::ASCII) +} + +function New-OwnedFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS', + [bool]$PublishCommittedReceipt = $true +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + $manifest.State -cne 'ACTIVE') { + throw 'fixture ownership manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $ownedRoot = Join-Path $stateDirectory 'owned' + $installRoot = Join-Path $ownedRoot 'install-tree' + $executable = Join-Path $installRoot 'propr-desktop.exe' + $shortcutFolder = Join-Path $ownedRoot 'shortcut-folder' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + $smokeDirectory = Join-Path $ownedRoot 'smoke-data' + [void](New-Item -ItemType Directory -Path $ownedRoot -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $ownedRoot '.propr-installed-app-owner') $token + foreach ($directory in @($installRoot, $shortcutFolder)) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token + } + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) + + $registryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\owned" + [void](New-Item -Path $registryPath -Force -ErrorAction Stop) + Set-ItemProperty -LiteralPath $registryPath -Name 'ProPRInstalledAppOwner' -Value $token + Set-ItemProperty -LiteralPath $registryPath -Name 'Payload' -Value 'owned' + + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText) { + throw 'fixture owned-user identity is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) { + throw 'fixture owned-user baseline was not clean' + } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $provisionalUserRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userOwnershipMarker + } + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest + New-LocalUser -Name $userName -Password $password ` + -Description $userOwnershipMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $provisionalUserRecord.Sid = $userSid + $provisionalUserRecord.Provisional = $false + + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($SmokeCheckpoint -ne 'BEFORE_PROMOTION') { + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($SmokeCheckpoint -eq 'AFTER_ARTIFACTS') { + New-FixtureSmokeArtifacts $smokeDirectory + } + } + + $ownedDirectories = @( + [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $installRoot $true) + TreeIdentity = (Get-FixtureTreeIdentity $installRoot); Provisional = $false + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $shortcutFolder $true) + TreeIdentity = (Get-FixtureTreeIdentity $shortcutFolder); Provisional = $false + }, + $smokeRecord + ) + $conflictingDirectories = @( + $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES -split '\|' | Where-Object { $_ } + ) | ForEach-Object { + [ordered]@{ Kind = 'CONFLICT'; Path = $_; Owned = $false; Token = $null } + } + $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) + $manifest.Files = @( + [ordered]@{ + Kind = 'FIXTURE_FILE'; Path = $executable + Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) + Provisional = $false + }, + [ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token + Identity = (Get-FixtureFileIdentity $shortcut) + EntryIdentity = (Get-FixtureEntryIdentity $shortcut $false) + Provisional = $false + } + ) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { + $manifest.Files += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT + Owned = $false; Token = $null + } + } + $manifest.RegistryKeys = @( + [ordered]@{ Kind = 'PROTOCOL'; Path = $registryPath; Owned = $true; Token = $token } + ) + $manifest.RegistryValues = @() + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY) { + $manifest.RegistryKeys += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY + Owned = $false; Token = $null + } + } + $manifest.Users = @($provisionalUserRecord) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER) { + $manifest.Users += [ordered]@{ + Name = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID + Owned = $false + } + } + $manifest.Profiles = @() + $manifest.InstallAttempted = $true + if ($PublishCommittedReceipt) { $manifest.MsiTransactionState = 'COMMITTED' } + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { + $manifest.Profiles += [ordered]@{ + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID + LocalPath = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH + Owned = $false + } + } + Write-FixtureOwnershipManifest $manifest + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.UserName = $userName + $startInfo.Domain = $env:COMPUTERNAME + $startInfo.Password = $password + $startInfo.LoadUserProfile = $true + $startInfo.WorkingDirectory = $env:SystemRoot + foreach ($argument in @('-NoLogo','-NoProfile','-NonInteractive','-Command','exit 0')) { + $startInfo.ArgumentList.Add($argument) + } + $profileProcess = [Diagnostics.Process]::new() + $profileProcess.StartInfo = $startInfo + $profileProcessStarted = $false + try { + $profileProcessStarted = $profileProcess.Start() + if (!$profileProcessStarted -or !$profileProcess.WaitForExit(30000) -or + $profileProcess.ExitCode -ne 0) { + throw 'fixture owned profile creation failed' + } + } finally { + if ($profileProcessStarted -and !$profileProcess.HasExited) { + try { $profileProcess.Kill($true) } catch {} + } + $profileProcess.Dispose() + } + $profiles = @() + $profileLookupStopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $userSid + }) + if ($profiles.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 + } while ($profileLookupStopwatch.ElapsedMilliseconds -lt 10000) + if ($profiles.Count -ne 1) { throw 'fixture owned profile was not created' } + $canonicalProfilePath = (Resolve-Path -LiteralPath ([string]$profiles[0].LocalPath) ` + -ErrorAction Stop).ProviderPath.TrimEnd('\') + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.Profiles = @($manifest.Profiles) + @([ordered]@{ + Sid = $userSid + LocalPath = $canonicalProfilePath + Owned = $true + }) + Write-FixtureOwnershipManifest $manifest + $resourceState = [ordered]@{ + OwnedRoot = $ownedRoot + InstallRoot = $installRoot + Executable = $executable + ShortcutFolder = $shortcutFolder + Shortcut = $shortcut + SmokeDirectory = $smokeDirectory + RegistryPath = $registryPath + RegistryRoot = Split-Path -Parent $registryPath + UserName = $userName + UserSid = $userSid + ProfilePath = $canonicalProfilePath + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function New-ByteIdenticalOwnedFileFixture { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $root = Join-Path $stateDirectory 'byte-identical-file-root' + $executable = Join-Path $root 'owned-file.exe' + [void](New-Item -ItemType Directory -Path $root -ErrorAction Stop) + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) + $manifest.BaselineClean = $false + $manifest.InstallAttempted = $false + $manifest.MsiTransactionState = 'NONE' + $manifest.Directories = @() + $manifest.Files = @([ordered]@{ + Kind = 'FIXTURE_FILE'; Path = $executable; Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) + Provisional = $false + }) + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @() + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + [ordered]@{ + Executable = $executable + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + ByteIdenticalReplacement = $true + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function New-SmokeCheckpointFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$Checkpoint +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $manifest.State -cne 'ACTIVE') { + throw 'smoke checkpoint manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText -or + (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) { + throw 'smoke checkpoint user baseline is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + $userMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $userRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userMarker + } + $manifest.Users = @($userRecord) + Write-FixtureOwnershipManifest $manifest + New-LocalUser -Name $userName -Password $password -Description $userMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $userRecord.Sid = $userSid + $userRecord.Provisional = $false + + $smokeDirectory = Join-Path $stateDirectory 'smoke-data' + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @($userRecord) + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + + $resourceState = [ordered]@{ + OwnedRoot = $smokeDirectory + InstallRoot = Join-Path $stateDirectory 'absent-install-root' + ShortcutFolder = Join-Path $stateDirectory 'absent-shortcut-folder' + Shortcut = Join-Path $stateDirectory 'absent-shortcut.lnk' + SmokeDirectory = $smokeDirectory + RegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\absent" + RegistryRoot = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)" + UserName = $userName + UserSid = $userSid + ProfilePath = '' + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII + + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($Checkpoint -eq 'BEFORE_PROMOTION') { return } + + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($Checkpoint -eq 'AFTER_PROMOTION') { return } + + New-FixtureSmokeArtifacts $smokeDirectory +} + +function Replace-FixtureOwnedResources { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + foreach ($directory in @($state.OwnedRoot, $state.ShortcutFolder)) { + [IO.File]::WriteAllText( + (Join-Path $directory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + } + $installRootBackup = Join-Path $stateDirectory 'original-install-tree' + $shortcutBackup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.InstallRoot -Destination $installRootBackup -ErrorAction Stop + [void](New-Item -ItemType Directory -Path $state.InstallRoot -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign.txt'), + 'foreign-install-tree', + [Text.Encoding]::ASCII + ) + Move-Item -LiteralPath $state.Shortcut -Destination $shortcutBackup -ErrorAction Stop + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + Set-ItemProperty -LiteralPath $state.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $state | Add-Member -NotePropertyName InstallRootBackup -NotePropertyValue $installRootBackup + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $shortcutBackup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureExecutable { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-executable.exe' + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Executable, 'foreign-executable', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureExecutableByteIdenticallyViaMove { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-byte-identical-executable.exe' + $replacement = Join-Path $stateDirectory 'foreign-byte-identical-executable.exe' + [IO.File]::Copy($state.Executable, $replacement, $false) + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + Move-Item -LiteralPath $replacement -Destination $state.Executable -ErrorAction Stop + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | Add-Member -NotePropertyName ByteIdenticalReplacement ` + -NotePropertyValue $true + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureShortcut { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.Shortcut -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureProfilePath { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $mismatchedPath = Join-Path $stateDirectory 'mismatched-profile-path' + [void](New-Item -ItemType Directory -Path $mismatchedPath -ErrorAction Stop) + $canonicalMismatch = (Resolve-Path -LiteralPath $mismatchedPath -ErrorAction Stop).ProviderPath + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $ownedProfile = @($manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$state.UserSid + }) + if ($ownedProfile.Count -ne 1) { + throw 'fixture durable profile ownership record is missing' + } + $ownedProfile[0].LocalPath = $canonicalMismatch + Write-FixtureOwnershipManifest $manifest + $state | Add-Member -NotePropertyName MismatchedProfilePath ` + -NotePropertyValue $canonicalMismatch + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Add-FixtureForeignChild { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign-in-place.txt'), + 'foreign-in-place', + [Text.Encoding]::ASCII + ) +} + +function Add-FixtureForeignSmokeDescendant { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $foreignPath = Join-Path $state.SmokeDirectory 'foreign-in-place.txt' + [IO.File]::WriteAllText($foreignPath, 'foreign-smoke-in-place', [Text.Encoding]::ASCII) + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [Security.AccessControl.FileSecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($currentSid) + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + Set-Acl -LiteralPath $foreignPath -AclObject $acl -ErrorAction Stop + $state | Add-Member -NotePropertyName ForeignSmokePath -NotePropertyValue $foreignPath + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Test-PrimaryFallbackForeignDescendants { + $installRoot = Join-Path $stateDirectory 'primary-install-root' + $shortcutFolder = Join-Path $stateDirectory 'primary-shortcut-folder' + [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) + [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) + $installForeign = Join-Path $installRoot 'foreign-in-place.txt' + $shortcutForeign = Join-Path $shortcutFolder 'foreign-in-place.txt' + [IO.File]::WriteAllText($installForeign, 'foreign-install', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcutForeign, 'foreign-shortcut', [Text.Encoding]::ASCII) + foreach ($directory in @($installRoot, $shortcutFolder)) { + $item = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'primary fallback fixture directory is invalid' + } + if (@(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + throw 'primary fallback fixture did not contain a foreign descendant' + } + if (!(Test-Path -LiteralPath $directory -PathType Container)) { + throw 'primary fallback removed a nonempty owned directory' + } + } + [ordered]@{ + InstallForeign = $installForeign + ShortcutForeign = $shortcutForeign + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'primary-fallback.json') -Encoding ASCII +} + +function Start-FixtureDescendant { + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Start-Sleep -Seconds 300' + )) { + $startInfo.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (!$process.Start()) { throw 'fixture descendant did not start' } + return $process +} + +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne(5000)) { throw 'fixture ownership was not established' } +} finally { + $ownershipReady.Dispose() +} + +$descendant = Start-FixtureDescendant +$state = [ordered]@{ WorkerPid = $PID; DescendantPid = $descendant.Id } +$processStatePath = Join-Path $stateDirectory 'processes.json' +$processStateTemporaryPath = "$processStatePath.$PID.new" +$processStateBytes = [Text.Encoding]::ASCII.GetBytes(($state | ConvertTo-Json -Compress)) +$processStateStream = [IO.FileStream]::new( + $processStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $processStateStream.Write($processStateBytes, 0, $processStateBytes.Length) + $processStateStream.Flush($true) +} finally { + $processStateStream.Dispose() +} +[IO.File]::Move($processStateTemporaryPath, $processStatePath) + +switch ($scenario) { + 'NO_MARKER' { + Start-Sleep -Seconds 300 + } + 'NO_MARKER_WINDOWS_POWERSHELL' { + Start-Sleep -Seconds 300 + } + 'VALID_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 500 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).Ticks) + Start-Sleep -Seconds 300 + } + 'MALFORMED_MARKER' { + Write-FixtureMarker 'not-a-watchdog-record' + Start-Sleep -Seconds 300 + } + 'TORN_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } + 'STALE_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(-1).Ticks) + Start-Sleep -Seconds 300 + } + 'INACCESSIBLE_MARKER' { + $record = '{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = [IO.FileStream]::new( + $WatchdogMarker, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + Start-Sleep -Seconds 300 + } finally { + $stream.Dispose() + } + } + 'CANCELLATION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 300 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } + 'DURING_MSI' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_MSI' + Start-Sleep -Milliseconds 750 + $manifest.Directories = @() + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'DURING_OWNERSHIP_CAPTURE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_OWNERSHIP_CAPTURE' + Start-Sleep -Milliseconds 750 + New-OwnedFixtureResources -PublishCommittedReceipt $false + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.MsiTransactionState = 'COMMITTED' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'NEGATIVE_EXIT' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 500 + exit -1 + } + 'OWNED_RESOURCES_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_FOR_INTERRUPTION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|COMPLETE' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Write-FixtureMarker ('{0}|APP_EXIT|EVIDENCE_INSPECTION|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Add-FixtureForeignSmokeDescendant + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + $owned = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $owned.SmokeDirectory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Test-PrimaryFallbackForeignDescendants + Write-FixtureMarker ('{0}|CLEANUP|SHORTCUT_FALLBACK|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureOwnedResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureExecutable + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-ByteIdenticalOwnedFileFixture + Replace-FixtureExecutableByteIdenticallyViaMove + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureShortcut + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureProfilePath + Write-FixtureMarker ('{0}|CLEANUP|PROFILE_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Add-FixtureForeignChild + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_NORMAL_SUCCESS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } +} + +$descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 new file mode 100644 index 000000000..e76a10ecb --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -0,0 +1,2393 @@ +param( + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) + +$ErrorActionPreference = 'Stop' +$supervisorPath = Join-Path $PSScriptRoot 'run-installed-windows-app-harness.ps1' +$workflowCleanupPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup.ps1' +$fixtureWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app-supervisor-fixture.ps1' +$hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-supervisor-tests-$([Guid]::NewGuid().ToString('N'))" +$dummyInstaller = Join-Path $testRoot 'fixture.msi' +$secretNeedle = 'C:\Users\fixture-user\token=fixture-credential' +$ownedFixtureUserName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" +$ownedFixturePassword = "P!$([Guid]::NewGuid().ToString('N'))x7" +$conflictingFixtureUserName = $null +$conflictingFixtureUserSid = $null +$conflictingFixtureProfileSid = $null +$conflictingFixtureProfilePath = $null +$conflictingFixtureDirectories = $null +$conflictingFixtureShortcut = $null +$conflictingFixtureRegistryPath = $null +$dummyInstallerProductCode = ('{' + [Guid]::NewGuid().ToString().ToUpperInvariant() + '}') +$dummyInstallerEntryIdentity = $null +$dummyInstallerSha256 = $null + +function Assert-True([bool]$Condition, [string]$Message) { + if (!$Condition) { throw $Message } +} + +function Assert-Contains([string]$Text, [string]$Expected, [string]$Message) { + Assert-True ($Text.Contains($Expected, [StringComparison]::Ordinal)) $Message +} + +function Assert-NotContains([string]$Text, [string]$Forbidden, [string]$Message) { + Assert-True (!$Text.Contains($Forbidden, [StringComparison]::OrdinalIgnoreCase)) $Message +} + +function Test-WorkflowCleanupBodyParserRegression { + $cleanupBodyPath = Join-Path $PSScriptRoot ` + 'run-installed-windows-app-workflow-cleanup-body.ps1' + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $cleanupBodyPath, + [ref]$tokens, + [ref]$parseErrors + ) + Assert-True ($parseErrors.Count -eq 0) ` + 'workflow cleanup production body failed whole-file parser regression' +} + +function New-StateDirectory([string]$Name) { + $path = Join-Path $testRoot $Name + [void](New-Item -ItemType Directory -Path $path -ErrorAction Stop) + return $path +} + +function Write-TestOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.test.new" + $bytes = [Text.Encoding]::UTF8.GetBytes( + ($Manifest | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryPath, $Path, $true) +} + +function Initialize-TestInstaller { + $installerCom = $null + $database = $null + $view = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($dummyInstaller, 3) + $view = $database.OpenView( + 'CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, ' + + '`Value` CHAR(0) LOCALIZABLE PRIMARY KEY `Property`)') + $view.Execute() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + $view = $null + $view = $database.OpenView( + "INSERT INTO ``Property`` (``Property``, ``Value``) VALUES ('ProductCode', '$dummyInstallerProductCode')") + $view.Execute() + $database.Commit() + } finally { + foreach ($resource in @($view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } + + if (-not ('ProPRSupervisorInstallerIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class ProPRSupervisorInstallerIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile(string path, uint access, uint share, + IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ + } + $script:dummyInstallerEntryIdentity = + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) + $script:dummyInstallerSha256 = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() +} + +function New-SupervisorStartInfo( + [string]$Scenario, + [string]$StateDirectory, + [string]$CancellationEventName, + [bool]$UseProductionWorker, + [string]$WorkflowManifest = '', + [string]$ExpectedRunId = '', + [bool]$InjectTerminationFailure = $false +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $supervisorPath, + '-Installer', $dummyInstaller, + '-Architecture', $Architecture, + '-BootstrapTimeoutMilliseconds', '10000', + '-WatchdogPollMilliseconds', '25', + '-WatchdogTerminationMilliseconds', '3000', + '-PostTerminationCleanupMilliseconds', '30000', + '-MarkerReadTimeoutMilliseconds', '200' + )) { + $startInfo.ArgumentList.Add([string]$argument) + } + if (!$UseProductionWorker) { + $startInfo.ArgumentList.Add('-WorkerPath') + $startInfo.ArgumentList.Add($fixtureWorkerPath) + $startInfo.ArgumentList.Add('-FixtureCleanupRoot') + $startInfo.ArgumentList.Add($StateDirectory) + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SCENARIO'] = $Scenario + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY'] = $StateDirectory + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SECRET'] = $secretNeedle + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_USER'] = $ownedFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD'] = $ownedFixturePassword + if ($conflictingFixtureUserName) { + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER'] = + $conflictingFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID'] = + $conflictingFixtureUserSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID'] = + $conflictingFixtureProfileSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH'] = + $conflictingFixtureProfilePath + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES'] = + $conflictingFixtureDirectories + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT'] = + $conflictingFixtureShortcut + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY'] = + $conflictingFixtureRegistryPath + } + } + if ($InjectTerminationFailure) { + $startInfo.ArgumentList.Add('-InjectTerminationFailure') + } + if ($CancellationEventName) { + $startInfo.ArgumentList.Add('-CancellationEventName') + $startInfo.ArgumentList.Add($CancellationEventName) + } + if ($WorkflowManifest) { + $startInfo.ArgumentList.Add('-OwnershipManifest') + $startInfo.ArgumentList.Add($WorkflowManifest) + $startInfo.ArgumentList.Add('-ExpectedRunId') + $startInfo.ArgumentList.Add($ExpectedRunId) + } + return $startInfo +} + +function Read-FixtureProcessState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'processes.json' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 15000) { + throw 'fixture did not publish process state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Read-FixtureResourceState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'resources.json' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 45000) { + throw 'fixture did not publish owned resource state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Assert-ProcessTreeGone($State) { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $worker = Get-Process -Id ([int]$State.WorkerPid) -ErrorAction SilentlyContinue + $descendant = Get-Process -Id ([int]$State.DescendantPid) -ErrorAction SilentlyContinue + if ($null -eq $worker -and $null -eq $descendant) { return } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt 3000) + throw 'owned worker process tree survived supervisor completion' +} + +function Get-SanitizedSupervisorMarkerDiagnostic($Result) { + $bootstrapTimedOutPresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT\r?$' + ) + $lastValidNonePresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE\r?$' + ) + $postTerminationMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)\r?$' + ) + $postTerminationOutcome = if ($postTerminationMatch.Success) { + $postTerminationMatch.Groups[1].Value + } else { 'NONE' } + $workerTreeMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:(COMPLETE|FAILED)\r?$' + ) + $cleanupChildMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:(0|20|21|OTHER)\r?$' + ) + $subphase = if ($workerTreeMatch.Success -and + $workerTreeMatch.Groups[1].Value -ceq 'FAILED') { + 'WORKER_TREE_TERMINATION' + } elseif ($cleanupChildMatch.Success) { + 'CLEANUP_CHILD_EXIT' + } else { 'NONE' } + $cleanupChildExit = if ($cleanupChildMatch.Success) { + $cleanupChildMatch.Groups[1].Value + } else { 'OTHER' } + $cleanupValidationPhaseMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|RUN_ID_FORMAT|' + + 'INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|INSTALLER_PRODUCT_CODE_FORMAT|' + + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH|' + + 'INITIAL_INSTALLER_AUTHORITY_RECHECK|EMPTY_RECEIPT_WRITE)\r?$' + ) + $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { + $cleanupValidationPhaseMatch.Groups[1].Value + } else { 'NONE' } + $signedExit = ([int]$Result.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + return ('SUPERVISOR_EXIT:{0}:BOOTSTRAP_TIMED_OUT:{1}:LAST_VALID_NONE:{2}:' + + 'POST_TERMINATION_CLEANUP:{3}:SUBPHASE:{4}:CLEANUP_CHILD_EXIT:{5}:' + + 'CLEANUP_VALIDATION_PHASE:{6}') -f ` + $signedExit, ([int]$bootstrapTimedOutPresent), ([int]$lastValidNonePresent), + $postTerminationOutcome, $subphase, $cleanupChildExit, $cleanupValidationPhase +} + +function Get-SanitizedCriticalCancellationDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + + $msiTransaction = 'INVALID' + $postTerminationCleanup = 'INVALID' + $authorityState = 'INVALID' + $output = [string]$Result.Output + $outputByteLimit = 4096 + $outputLineLimit = 32 + $outputLineByteLimit = 192 + $protocolValid = [Text.Encoding]::UTF8.GetByteCount($output) -le $outputByteLimit + $lines = [Collections.Generic.List[string]]::new() + if ($protocolValid) { + $rawLines = @([regex]::Split($output, '\r?\n')) + $lineCount = $rawLines.Count + if ($lineCount -gt 0 -and $rawLines[$lineCount - 1] -ceq '') { + $lineCount-- + } + if ($lineCount -gt $outputLineLimit) { + $protocolValid = $false + } else { + for ($index = 0; $index -lt $lineCount; $index++) { + $line = [string]$rawLines[$index] + if ([string]::IsNullOrEmpty($line) -or + $line.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($line) -gt $outputLineByteLimit -or + [regex]::IsMatch($line, '[^\x20-\x7e]')) { + $protocolValid = $false + break + } + $lines.Add($line) + } + } + } + + if ($protocolValid) { + $msiEvents = [Collections.Generic.List[string]]::new() + $cleanupEvents = [Collections.Generic.List[string]]::new() + $authorityEvents = [Collections.Generic.List[string]]::new() + $msiPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + $cleanupPrefix = + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:' + $lastValidPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + $lastValidPattern = + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + + '(INITIALIZATION|INSTALL|VALIDATION|USER_SETUP|APP_LAUNCH|APP_EXIT|UNINSTALL|CLEANUP):' + + '(PATHS|BASELINE|MSI_INSTALL|OWNERSHIP_CAPTURE|INSTALL_TREE_SCAN|' + + 'APPLICATION_IMAGE|PROTOCOL_ASSERTION|APP_PATH_ASSERTION|' + + 'HKCU_INSTALLED_ASSERTION|SHORTCUT_ASSERTION|USER_CREATE|USER_SID|' + + 'SMOKE_DATA_CREATE|SHORTCUT_PRESENT_PROBE|ALTERNATE_USER_START|' + + 'APPLICATION_WAIT|STREAM_DRAIN|EVIDENCE_INSPECTION|MSI_UNINSTALL|' + + 'INSTALL_TREE_ASSERTION|PROTOCOL_ABSENCE_ASSERTION|' + + 'APP_PATH_ABSENCE_ASSERTION|HKCU_INSTALLED_ABSENCE_ASSERTION|' + + 'SHORTCUT_FILE_ASSERTION|SHORTCUT_FOLDER_ASSERTION|' + + 'SHORTCUT_ABSENCE_PROBE|SMOKE_DATA_REMOVE|PROFILE_LOOKUP|' + + 'PROFILE_REMOVE|USER_LOOKUP|USER_REMOVE|INSTALL_ROOT_FALLBACK|' + + 'PROTOCOL_FALLBACK|APP_PATH_FALLBACK|HKCU_INSTALLED_FALLBACK|' + + 'SHORTCUT_FALLBACK):(BEGIN|COMPLETE|FAILED)$' + + foreach ($line in $lines) { + if ($line.StartsWith($msiPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + + '(GRACE|COMMITTED|ROLLED_BACK_CLEAN|UNPROVEN)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $msiEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($cleanupPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $cleanupEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($lastValidPrefix, [StringComparison]::Ordinal)) { + if ($line -ceq ($lastValidPrefix + 'NONE')) { + $authorityEvents.Add('NONE') + continue + } + $match = [regex]::Match($line, $lastValidPattern) + if (!$match.Success) { $protocolValid = $false; break } + if ($match.Groups[1].Value -ceq 'INSTALL' -and + $match.Groups[2].Value -ceq 'OWNERSHIP_CAPTURE') { + $authorityEvent = @(switch ($match.Groups[3].Value) { + 'BEGIN' { 'PROVISIONAL' } + 'COMPLETE' { 'NONPROVISIONAL' } + 'FAILED' { 'FAILED' } + }) + if ($authorityEvent.Count -ne 1 -or + $authorityEvent[0] -cnotin @('PROVISIONAL','NONPROVISIONAL','FAILED')) { + $protocolValid = $false + break + } + $authorityEvents.Add([string]$authorityEvent[0]) + } else { + $authorityEvents.Add('OTHER') + } + } + } + + if ($protocolValid) { + if ($msiEvents.Count -eq 0) { + $msiTransaction = 'NONE' + } elseif ($msiEvents.Count -eq 1 -and $msiEvents[0] -ceq 'GRACE') { + $msiTransaction = 'GRACE' + } elseif ($msiEvents.Count -eq 2 -and $msiEvents[0] -ceq 'GRACE' -and + $msiEvents[1] -cin @('COMMITTED','ROLLED_BACK_CLEAN','UNPROVEN')) { + $msiTransaction = $msiEvents[1] + } + if ($cleanupEvents.Count -eq 0) { + $postTerminationCleanup = 'NONE' + } elseif ($cleanupEvents.Count -eq 1) { + $postTerminationCleanup = $cleanupEvents[0] + } + if ($authorityEvents.Count -eq 0) { + $authorityState = 'ABSENT' + } elseif ($authorityEvents.Count -eq 1) { + $authorityState = $authorityEvents[0] + } + } + } + + $diagnostic = ('PROCESS_EXIT:{0}:MSI_TRANSACTION:{1}:' + + 'POST_TERMINATION_CLEANUP:{2}:AUTHORITY_STATE:{3}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $msiTransaction, $postTerminationCleanup, $authorityState + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 192) { + return ('PROCESS_EXIT:{0}:MSI_TRANSACTION:INVALID:' + + 'POST_TERMINATION_CLEANUP:INVALID:AUTHORITY_STATE:INVALID') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + +function Get-SanitizedWorkflowCleanupResultDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + $reportedExitCode = 0 + if (![int]::TryParse( + [string]$Result.ReportedExitCode, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$reportedExitCode + ) -or $reportedExitCode -notin @(0,20,21,122,123,124,125)) { + $reportedExitCode = -1 + } + $resultName = if ([string]$Result.Result -cin @('COMPLETE','FAILED','TIMED_OUT')) { + [string]$Result.Result + } else { 'INVALID' } + $fixedStatuses = @( + 'CONTROLLER_FAILURE','TIMEOUT','TERMINATION_FAILURE', + 'ACTIVE_PROCESS_AFTER_ROOT_EXIT','EMPTY_OR_CLEANED', + 'MANIFEST_VALIDATION_FAILURE','OWNED_RESOURCE_CLEANUP_FAILURE', + 'PROCESS_FINALIZATION_TIMEOUT','PROCESS_FINALIZATION_FAILURE', + 'STREAM_DRAIN_TIMEOUT','CHILD_STDERR_LIMIT','CHILD_STDERR', + 'CHILD_STDOUT_LIMIT','CHILD_STDOUT','STREAM_DRAIN_FAILURE', + 'RESOURCE_FINALIZATION_FAILURE','AUTHORITY_FINALIZATION_FAILURE', + 'STARTUP_FAILURE' + ) + $controllerStatus = [string]$Result.ControllerStatus + if ($controllerStatus -cnotin $fixedStatuses -and + $controllerStatus -cnotmatch ( + '^CONTROLLER_(INITIALIZATION|PARAMETER_VALIDATION|PATH_VALIDATION|' + + 'PROCESS_START|PROCESS_WAIT|PROCESS_FINALIZATION|STREAM_FINALIZATION|' + + 'RESOURCE_FINALIZATION|AUTHORITY_FINALIZATION|RESULT_EMISSION)_' + + '(TYPE_LOAD|PARAMETERS|PATHS|START|WAIT|TERMINATE|DRAIN|DISPOSE|' + + 'AUTHORITY|EMIT)_(AUTHENTICATION|CLOSE|INVALID_ARGUMENT|INVALID_DATA|' + + 'INVALID_OPERATION|LIMIT|NOT_ENABLED|NOT_FOUND|OPEN|STOPPED|' + + 'PERMISSION|READ|BUSY|UNAVAILABLE|SECURITY|WRITE|UNCLASSIFIED)$')) { + $controllerStatus = 'INVALID' + } + $startupDiagnostic = '' + if ($controllerStatus -ceq 'STARTUP_FAILURE') { + $startupClass = [string]$Result.StartupClass + if ($startupClass -cnotin @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $startupClass = 'INVALID' + } + + $startupProcessExit = 'INVALID' + $startupProcessExitCandidate = [string]$Result.StartupProcessExit + $parsedStartupProcessExit = 0 + if ($startupProcessExitCandidate -cmatch '^(?:0|-?[1-9][0-9]*)$' -and + [int]::TryParse( + $startupProcessExitCandidate, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupProcessExit + )) { + $startupProcessExit = + $parsedStartupProcessExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupLine = 'INVALID' + $startupLineCandidate = [string]$Result.StartupLine + $parsedStartupLine = 0 + if ($startupLineCandidate -cmatch '^[1-9][0-9]{0,5}$' -and + [int]::TryParse( + $startupLineCandidate, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupLine + ) -and $parsedStartupLine -le 999999) { + $startupLine = + $parsedStartupLine.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupDiagnostic = (':STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f $startupClass, $startupProcessExit, $startupLine + } + $diagnostic = ('EXIT_CODE:{0}:RESULT:{1}:CONTROLLER_STATUS:{2}:' + + 'REPORTED_EXIT_CODE:{3}{4}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $resultName, $controllerStatus, + $reportedExitCode.ToString([Globalization.CultureInfo]::InvariantCulture), + $startupDiagnostic + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 256) { + return ('EXIT_CODE:{0}:RESULT:INVALID:CONTROLLER_STATUS:INVALID:' + + 'REPORTED_EXIT_CODE:-1') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + +function Get-WorkflowCleanupControllerStatusMatch([string]$StatusLine) { + return [regex]::Match( + $StatusLine, + ('^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):' + + 'EXIT_CODE:([0-9]+)(?::STARTUP_CLASS:' + + '(PARSER|PARAMETER_BINDING|TYPE_LOAD|OTHER):PROCESS_EXIT:(-?[0-9]+):' + + 'LINE:([0-9]+))?$') + ) +} + +function Assert-OwnedResourcesGone($Owned) { + foreach ($ownedPath in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'external cleanup left a run-owned file-system resource behind' + } + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryPath)) ` + 'external cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryRoot)) ` + 'external cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'external cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $Owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'external cleanup left the run-owned profile behind' +} + +function Restore-ReplacedFixtureAuthority($Owned) { + [IO.File]::WriteAllText( + (Join-Path $Owned.OwnedRoot '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + if ($Owned.PSObject.Properties['InstallRootBackup']) { + Remove-Item -LiteralPath $Owned.InstallRoot -Recurse -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.InstallRootBackup -Destination $Owned.InstallRoot ` + -ErrorAction Stop + } elseif ($Owned.PSObject.Properties['ExecutableBackup']) { + Remove-Item -LiteralPath $Owned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ExecutableBackup -Destination $Owned.Executable ` + -ErrorAction Stop + } + [IO.File]::WriteAllText( + (Join-Path $Owned.ShortcutFolder '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + if ($Owned.PSObject.Properties['ShortcutBackup']) { + Remove-Item -LiteralPath $Owned.Shortcut -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ShortcutBackup -Destination $Owned.Shortcut ` + -ErrorAction Stop + } + Set-ItemProperty -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$Owned.Token) +} + +function Assert-ReplacedFixtureResourcesSurvive($Owned) { + Assert-True ((Get-Content -LiteralPath (Join-Path $Owned.InstallRoot 'foreign.txt') -Raw).Trim() ` + -ceq 'foreign-install-tree') ` + 'replacement install tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq 'foreign-shortcut') ` + 'replacement shortcut was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'replacement registry authority was removed or changed' +} + +function Assert-ReplacedExecutableSurvives($Owned) { + $expected = if ($Owned.PSObject.Properties['ByteIdenticalReplacement']) { + 'owned-executable' + } else { 'foreign-executable' } + Assert-True ((Get-Content -LiteralPath $Owned.Executable -Raw).Trim() -ceq + $expected) 'replacement executable was removed or changed' +} + +function Assert-ReplacedShortcutSurvives($Owned) { + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq + 'foreign-shortcut') 'replacement shortcut was removed or changed' +} + +function Assert-MsiPreflightPreservedResources($Owned) { + foreach ($path in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory, $Owned.RegistryPath + )) { + Assert-True (Test-Path -LiteralPath $path) ` + 'MSI file-system preflight failure mutated a run resource' + } + Assert-True ($null -ne (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'MSI file-system preflight failure removed the run-owned user' +} + +function Get-SanitizedControllerStartupDiagnostic( + [string]$ErrorText, + [int]$ProcessExitCode +) { + $classification = if ($ErrorText -match + '(?im)\bParserError\b|\bMissingEndCurlyBrace\b|\bUnexpectedToken\b|\bParseException\b') { + 'PARSER' + } elseif ($ErrorText -match + '(?im)\bParameterBinding(?:Exception|ValidationException)?\b|cannot bind (?:argument|parameter)|parameter cannot be processed') { + 'PARAMETER_BINDING' + } elseif ($ErrorText -match + '(?im)\bAdd-Type\b|\bTypeNotFound\b|unable to find type|error CS[0-9]{4}') { + 'TYPE_LOAD' + } else { + 'OTHER' + } + $lineNumber = 0 + $lineMatch = [regex]::Match( + $ErrorText, + '(?im)^\s*at .+?:(\d+)\s+char:\d+\s*$' + ) + if (!$lineMatch.Success) { + $lineMatch = [regex]::Match($ErrorText, '(?im)\bline\s+(\d+)\b') + } + if ($lineMatch.Success) { + [void]([int]::TryParse( + $lineMatch.Groups[1].Value, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$lineNumber + )) + } + $signedExit = $ProcessExitCode.ToString([Globalization.CultureInfo]::InvariantCulture) + $numericLine = $lineNumber.ToString([Globalization.CultureInfo]::InvariantCulture) + return 'STARTUP_CLASS:{0}:PROCESS_EXIT:{1}:LINE:{2}' -f ` + $classification, $signedExit, $numericLine +} + +function Invoke-WorkflowCleanupController( + [string]$ManifestPath, + [string]$RunId, + [string]$FixtureRoot, + [object]$CleanupTimeoutMilliseconds = 30000, + [bool]$FixtureEarlyInitializationChild = $false, + [string]$StartupFailureClass = '' +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $workflowCleanupPath, + '-OwnershipManifest', $ManifestPath, + '-Installer', $dummyInstaller, + '-ExpectedRunId', $RunId, + '-CleanupTimeoutMilliseconds', [string]$CleanupTimeoutMilliseconds, + '-TerminationTimeoutMilliseconds', '3000' + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add($FixtureRoot) + } + if ($FixtureEarlyInitializationChild) { + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + if ($StartupFailureClass) { + $startInfo.ArgumentList.Add('-StartupFailureClass') + $startInfo.ArgumentList.Add($StartupFailureClass) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (!$process.Start()) { throw 'workflow cleanup fixture did not start' } + Assert-True ($process.WaitForExit(40000)) 'workflow cleanup fixture exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) + $lineCount = if ($outputLines.Count -ge 3) { '3+' } else { [string]$outputLines.Count } + $stderrCount = [Math]::Min(4096, $errorOutput.Length) + if ($output.Length -gt 512 -or $outputLines.Count -ne 2) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $resultMatch = [regex]::Match( + $outputLines[0], + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + if (!$resultMatch.Success) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $resultName = $resultMatch.Groups[1].Value + $statusMatch = Get-WorkflowCleanupControllerStatusMatch $outputLines[1] + if (!$statusMatch.Success) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $controllerStatus = $statusMatch.Groups[1].Value + $reportedExitCode = [int]$statusMatch.Groups[2].Value + if ($errorOutput.Length -ne 0) { + $stderrCode = if ($errorOutput.Length -gt 4096) { + 'CONTROLLER_STDERR_LIMIT' + } else { 'CONTROLLER_STDERR_PRESENT' } + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:{0}:STATUS:{1}:EXIT_CODE:{2}:' + + 'LINE_COUNT:{3}:STDERR_COUNT:{4}' -f ` + $stderrCode, $controllerStatus, $reportedExitCode, $lineCount, $stderrCount) + } + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Result = $resultName + ControllerStatus = $controllerStatus + ReportedExitCode = $reportedExitCode + StartupClass = [string]$statusMatch.Groups[3].Value + StartupProcessExit = [string]$statusMatch.Groups[4].Value + StartupLine = [string]$statusMatch.Groups[5].Value + Output = $output + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } +} + +function Test-WorkflowCleanupStartupProtocol { + foreach ($failureClass in @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $result = Invoke-WorkflowCleanupController ` + $dummyInstaller $([Guid]::NewGuid().ToString('N')) $testRoot 30000 $false ` + $failureClass + Assert-True ($result.ExitCode -eq 125 -and + $result.ReportedExitCode -eq 125 -and + $result.Result -ceq 'FAILED' -and + $result.ControllerStatus -ceq 'STARTUP_FAILURE' -and + $result.StartupClass -ceq $failureClass -and + $result.StartupProcessExit -match '^-?[0-9]+$' -and + $result.StartupLine -match '^[1-9][0-9]{0,5}$') ` + "native $failureClass startup fixture did not emit the fixed two-line protocol" + $startupDiagnostic = Get-SanitizedWorkflowCleanupResultDiagnostic $result + $expectedStartupDiagnostic = (( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f ` + $failureClass, $result.StartupProcessExit, $result.StartupLine) + Assert-True ($startupDiagnostic -ceq $expectedStartupDiagnostic) ` + "native $failureClass startup metadata was not preserved by the bounded diagnostic" + } + + foreach ($invalidStatusLine in @( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:INVALID:PROCESS_EXIT:125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:+125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:125:LINE:-1' + )) { + Assert-True (!(Get-WorkflowCleanupControllerStatusMatch $invalidStatusLine).Success) ` + 'workflow cleanup parser accepted malformed startup metadata' + } + + $validStartupMetadata = [PSCustomObject]@{ + ExitCode = 125 + Result = 'FAILED' + ControllerStatus = 'STARTUP_FAILURE' + ReportedExitCode = 125 + StartupClass = 'PARSER' + StartupProcessExit = '-2147483648' + StartupLine = '999999' + } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $validStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:PARSER:' + + 'STARTUP_PROCESS_EXIT:-2147483648:STARTUP_LINE:999999' + )) 'valid bounded startup metadata was not preserved' + + foreach ($invalidStartupMetadata in @( + [PSCustomObject]@{}, + [PSCustomObject]@{ + StartupClass = 'parser' + StartupProcessExit = '+125' + StartupLine = '0' + }, + [PSCustomObject]@{ + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = '2147483648' + StartupLine = '1000000' + } + )) { + $invalidStartupMetadata | Add-Member -NotePropertyName ExitCode -NotePropertyValue 125 + $invalidStartupMetadata | Add-Member -NotePropertyName Result -NotePropertyValue 'FAILED' + $invalidStartupMetadata | Add-Member ` + -NotePropertyName ControllerStatus -NotePropertyValue 'STARTUP_FAILURE' + $invalidStartupMetadata | Add-Member -NotePropertyName ReportedExitCode -NotePropertyValue 125 + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $invalidStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:INVALID:' + + 'STARTUP_PROCESS_EXIT:INVALID:STARTUP_LINE:INVALID' + )) 'invalid startup metadata did not fail closed to fixed sentinels' + } + + $nonStartupMetadata = [PSCustomObject]@{ + ExitCode = 21 + Result = 'FAILED' + ControllerStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + ReportedExitCode = 21 + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = 'not-an-exit' + StartupLine = 'not-a-line' + } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $nonStartupMetadata) -ceq ( + 'EXIT_CODE:21:RESULT:FAILED:' + + 'CONTROLLER_STATUS:OWNED_RESOURCE_CLEANUP_FAILURE:REPORTED_EXIT_CODE:21' + )) 'non-startup cleanup diagnostic included startup-only metadata' + Write-Host 'PROPR_WINDOWS_SUPERVISOR_CONTROLLER_STARTUP:FIXED_PROTOCOL:PASSED' + [Console]::Out.Flush() +} + +function Start-ExternallyInterruptibleSupervisor([string]$StateDirectory) { + $scriptText = @' +param($SupervisorPath, $Installer, $Architecture, $FixtureWorker, $Scenario, + $StateDirectory, $Secret, $OwnedUser, $OwnedPassword, + $ConflictUser, $ConflictUserSid, $ConflictProfileSid, $ConflictProfilePath, + $ConflictDirectories, $ConflictShortcut, $ConflictRegistry) +$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO = $Scenario +$env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY = $StateDirectory +$env:PROPR_SUPERVISOR_FIXTURE_SECRET = $Secret +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER = $OwnedUser +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD = $OwnedPassword +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER = $ConflictUser +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID = $ConflictUserSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID = $ConflictProfileSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH = $ConflictProfilePath +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES = $ConflictDirectories +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT = $ConflictShortcut +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY = $ConflictRegistry +& $SupervisorPath -Installer $Installer -Architecture $Architecture ` + -WorkerPath $FixtureWorker -FixtureCleanupRoot $StateDirectory ` + -BootstrapTimeoutMilliseconds 10000 -WatchdogPollMilliseconds 25 ` + -WatchdogTerminationMilliseconds 3000 -PostTerminationCleanupMilliseconds 30000 ` + -MarkerReadTimeoutMilliseconds 200 +'@ + $pipeline = [Management.Automation.PowerShell]::Create() + [void]$pipeline.AddScript($scriptText) + foreach ($argument in @( + $supervisorPath, + $dummyInstaller, + $Architecture, + $fixtureWorkerPath, + 'OWNED_RESOURCES_FOR_INTERRUPTION', + $StateDirectory, + $secretNeedle, + $ownedFixtureUserName, + $ownedFixturePassword, + $conflictingFixtureUserName, + $conflictingFixtureUserSid, + $conflictingFixtureProfileSid, + $conflictingFixtureProfilePath, + $conflictingFixtureDirectories, + $conflictingFixtureShortcut, + $conflictingFixtureRegistryPath + )) { + [void]$pipeline.AddArgument($argument) + } + $asyncResult = $pipeline.BeginInvoke() + return [PSCustomObject]@{ Pipeline = $pipeline; AsyncResult = $asyncResult } +} + +function Invoke-FixtureScenario( + [string]$Scenario, + [string]$ExistingStateDirectory = '', + [bool]$InjectTerminationFailure = $false +) { + $stateDirectory = if ($ExistingStateDirectory) { + $ExistingStateDirectory + } else { + New-StateDirectory $Scenario.ToLowerInvariant() + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory '' $false '' '' $InjectTerminationFailure + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + if (!$process.Start()) { throw 'supervisor test process did not start' } + try { + $completionBound = if ($Scenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + )) { + 60000 + } elseif ($Scenario -in @( + 'OWNED_RESOURCES_THEN_DEADLINE', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' + )) { 90000 } else { 20000 } + if (!$process.WaitForExit($completionBound)) { + try { $process.Kill($true) } catch {} + throw 'supervisor exceeded the executable test completion bound' + } + $stopwatch.Stop() + $standardOutput = $process.StandardOutput.ReadToEnd() + $standardError = $process.StandardError.ReadToEnd() + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds + Output = $standardOutput + Error = $standardError + StateDirectory = $stateDirectory + } + } finally { + $process.Dispose() + } +} + +function Invoke-CriticalCancellationScenario([string]$Scenario) { + $stateDirectory = New-StateDirectory $Scenario.ToLowerInvariant() + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellation = [Threading.EventWaitHandle]::new( + $false, [Threading.EventResetMode]::ManualReset, $eventName) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory $eventName $false + try { + if (!$process.Start()) { throw 'critical-cancellation supervisor did not start' } + $gatePath = Join-Path $stateDirectory 'critical-gate.txt' + $gateWait = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $gatePath -PathType Leaf)) { + if ($gateWait.ElapsedMilliseconds -ge 45000) { + throw 'critical-cancellation fixture did not reach its interruption gate' + } + Start-Sleep -Milliseconds 25 + } + Assert-True ((Get-Content -LiteralPath $gatePath -Raw -Encoding ASCII) -ceq $Scenario) ` + 'critical-cancellation fixture published the wrong interruption gate' + [void]$cancellation.Set() + Assert-True ($process.WaitForExit(90000)) ` + 'critical-cancellation supervisor exceeded its fixed completion bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-ProcessTreeGone (Read-FixtureProcessState $stateDirectory) + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Output = $output + Error = $errorOutput + StateDirectory = $stateDirectory + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellation.Dispose() + } +} + +function Test-MsiTransactionInterruptionGates { + $duringMsi = Invoke-CriticalCancellationScenario 'DURING_MSI' + Assert-True ($duringMsi.ExitCode -eq 125) ` + 'DURING_MSI cancellation did not preserve the supervisor cancellation status' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' ` + 'DURING_MSI cancellation did not enter the fixed transaction grace' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:ROLLED_BACK_CLEAN' ` + 'DURING_MSI cancellation did not prove the exact clean rollback receipt' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'DURING_MSI clean rollback did not complete bounded cleanup' + Assert-True (!(Test-Path -LiteralPath (Join-Path $duringMsi.StateDirectory 'owned'))) ` + 'DURING_MSI rollback did not retain the exact clean fixture baseline' + + $duringCapture = Invoke-CriticalCancellationScenario 'DURING_OWNERSHIP_CAPTURE' + $duringCaptureDiagnostic = Get-SanitizedCriticalCancellationDiagnostic $duringCapture + Assert-True ($duringCapture.ExitCode -eq 125) ` + "DURING_OWNERSHIP_CAPTURE cancellation did not preserve cancellation status:$duringCaptureDiagnostic" + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:COMMITTED' ` + "DURING_OWNERSHIP_CAPTURE did not publish durable nonprovisional authority:$duringCaptureDiagnostic" + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup:$duringCaptureDiagnostic" + $capturedOwned = Read-FixtureResourceState $duringCapture.StateDirectory + Assert-OwnedResourcesGone $capturedOwned +} + +function Test-BootstrapTimeout { + $result = Invoke-FixtureScenario 'NO_MARKER' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'missing-marker native pwsh fixture emitted stderr' + Assert-True ($result.ExitCode -eq 124) ` + "missing-marker bootstrap did not fail with the watchdog code:$diagnostic" + Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' + Assert-True ($result.ElapsedMilliseconds -lt 60000) 'missing-marker bootstrap completion was not bounded' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' ` + 'missing-marker bootstrap did not emit the fixed timeout line' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' ` + 'missing-marker bootstrap did not emit the fixed empty last-stage line' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:COMPLETE') ` + 'missing-marker bootstrap did not verify worker-tree termination' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'missing-marker bootstrap cleanup child did not consume the empty authority' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'missing-marker bootstrap did not complete bounded cleanup' +} + +function Test-WindowsPowerShellCleanupCompatibility { + # This separate scenario runs the same supervisor-written initial ACTIVE + # receipt through the Windows PowerShell 5.1 cleanup reader/finalizer. + $result = Invoke-FixtureScenario 'NO_MARKER_WINDOWS_POWERSHELL' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'Windows PowerShell cleanup compatibility fixture emitted stderr' + Assert-True ($result.ExitCode -eq 124) ` + "Windows PowerShell cleanup compatibility did not preserve watchdog exit:$diagnostic" + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'Windows PowerShell cleanup compatibility did not consume exact identifiers' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'Windows PowerShell cleanup compatibility did not complete' +} + +function Test-OperationDeadlineAndTreeTermination { + $result = Invoke-FixtureScenario 'VALID_THEN_DEADLINE' + Assert-True ($result.ExitCode -eq 124) 'operation deadline did not fail with the watchdog code' + Assert-True ($result.ElapsedMilliseconds -ge 2200) ` + 'operation deadline did not retain the injected observable interval' + Assert-True ($result.ElapsedMilliseconds -lt 10000) ` + 'operation deadline completion was not bounded' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:VALIDATION:INSTALL_TREE_SCAN:BEGIN' ` + 'operation transition was not accepted and flushed by the supervisor' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:VALIDATION:INSTALL_TREE_SCAN:BEGIN:TIMED_OUT' ` + 'operation deadline did not emit the fixed redacted timeout line' +} + +function Test-NegativeWorkerExitFinalization { + $result = Invoke-FixtureScenario 'NEGATIVE_EXIT' + Assert-True ($result.ExitCode -eq -1) ` + 'negative worker exit status was not preserved after bounded finalization' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN' ` + 'negative-exit fixture did not publish a valid marker before crashing' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'negative worker exit did not enter bounded tree termination and cleanup' +} + +function Test-FailClosedMarkers { + foreach ($testCase in @( + @{ Scenario = 'MALFORMED_MARKER'; Label = 'malformed' }, + @{ Scenario = 'TORN_MARKER'; Label = 'torn' }, + @{ Scenario = 'STALE_MARKER'; Label = 'stale' }, + @{ Scenario = 'INACCESSIBLE_MARKER'; Label = 'inaccessible' } + )) { + $result = Invoke-FixtureScenario $testCase.Scenario + Assert-True ($result.ExitCode -eq 124) "$($testCase.Label) marker did not fail closed" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' ` + "$($testCase.Label) marker did not emit the fixed bootstrap failure line" + Assert-NotContains $result.Output $secretNeedle ` + "$($testCase.Label) marker diagnostics exposed fixture-sensitive data" + } +} + +function Test-LiveCancellationAndRedaction { + $stateDirectory = New-StateDirectory 'cancellation' + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellationEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $eventName + ) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo 'CANCELLATION' $stateDirectory $eventName $false + $lines = [Collections.Generic.List[string]]::new() + try { + if (!$process.Start()) { throw 'cancellation supervisor did not start' } + $liveAccepted = $false + $readStopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!$liveAccepted -and $readStopwatch.ElapsedMilliseconds -lt 8000) { + $lineTask = $process.StandardOutput.ReadLineAsync() + if (!$lineTask.Wait(8000 - [int]$readStopwatch.ElapsedMilliseconds)) { break } + $line = $lineTask.Result + if ($null -eq $line) { break } + $lines.Add($line) + if ($line -ceq 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN') { + $liveAccepted = $true + } + } + Assert-True $liveAccepted 'accepted transition was not observable live before cancellation' + Assert-True (!$process.HasExited) 'supervisor exited before simulated cancellation' + [void]$cancellationEvent.Set() + Assert-True ($process.WaitForExit(8000)) 'cancelled supervisor did not complete within the bound' + $remainingOutput = $process.StandardOutput.ReadToEnd() + if ($remainingOutput) { $lines.Add($remainingOutput) } + $standardError = $process.StandardError.ReadToEnd() + $output = $lines -join "`n" + Assert-True ($process.ExitCode -eq 125) 'simulated cancellation did not use the supervisor failure code' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' ` + 'simulated cancellation did not emit the fixed cancellation line' + Assert-True ($output -match ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:(?:INITIALIZATION:PATHS|VALIDATION:INSTALL_TREE_SCAN):BEGIN') ` + 'simulated cancellation did not emit a fixed last-valid-marker line' + foreach ($forbidden in @($secretNeedle, $stateDirectory, $testRoot, 'fixture-user', 'credential')) { + Assert-NotContains $output $forbidden 'live supervisor diagnostics were not redacted' + } + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + Assert-True ([string]::IsNullOrEmpty($standardError)) 'fixture cancellation wrote unexpected stderr' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellationEvent.Dispose() + } +} + +function Get-RunnerProfileSnapshot { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + Assert-True ($null -ne $identity -and $null -ne $identity.User) ` + 'runner profile authority validation failed' + $identitySid = $identity.User.Value + Assert-True (![string]::IsNullOrWhiteSpace($identitySid)) ` + 'runner profile authority validation failed' + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $identitySid + }) + Assert-True ($profiles.Count -eq 1) 'runner profile authority validation failed' + $profile = $profiles[0] + Assert-True (!$profile.Special -and $profile.Loaded) ` + 'runner profile authority validation failed' + Assert-True (![string]::IsNullOrWhiteSpace([string]$profile.LocalPath) -and + [IO.Path]::IsPathRooted([string]$profile.LocalPath)) ` + 'runner profile authority validation failed' + + $rawCimLocalPath = [string]$profile.LocalPath + $cimLocalPath = $rawCimLocalPath.TrimEnd('\') + Assert-True ($rawCimLocalPath -ceq $cimLocalPath) ` + 'runner profile authority validation failed' + $canonicalLocalPath = [IO.Path]::GetFullPath($cimLocalPath).TrimEnd('\') + Assert-True ([string]::Equals( + $cimLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + $resolvedProfilePath = Resolve-Path -LiteralPath $canonicalLocalPath -ErrorAction Stop + $resolvedLocalPath = $resolvedProfilePath.ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $resolvedLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + + $profileDirectory = Get-Item -LiteralPath $canonicalLocalPath -Force -ErrorAction Stop + Assert-True ($profileDirectory.PSIsContainer) 'runner profile authority validation failed' + $pathCursor = $profileDirectory + while ($null -ne $pathCursor) { + Assert-True (($pathCursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) ` + 'runner profile authority validation failed' + $parentPath = Split-Path -Parent $pathCursor.FullName + if ([string]::IsNullOrEmpty($parentPath) -or + [string]::Equals($parentPath, $pathCursor.FullName, [StringComparison]::OrdinalIgnoreCase)) { + break + } + $pathCursor = Get-Item -LiteralPath $parentPath -Force -ErrorAction Stop + } + + $profileOwner = (Get-Acl -LiteralPath $canonicalLocalPath -ErrorAction Stop).Owner + Assert-True (![string]::IsNullOrWhiteSpace($profileOwner)) ` + 'runner profile authority validation failed' + $profileOwnerSid = if ($profileOwner -match '^S-\d+(?:-\d+)+$') { + [Security.Principal.SecurityIdentifier]::new($profileOwner).Value + } else { + $profileOwnerAccount = [Security.Principal.NTAccount]::new($profileOwner) + $profileOwnerAccount.Translate([Security.Principal.SecurityIdentifier]).Value + } + + return [PSCustomObject]@{ + ProfileExists = $true + DirectoryExists = $true + IdentitySid = $identitySid + ProfileSid = [string]$profile.SID + CimLocalPath = $cimLocalPath + CanonicalLocalPath = $canonicalLocalPath + DirectoryOwnerSid = $profileOwnerSid + DirectoryAttributes = [int64]$profileDirectory.Attributes + Loaded = [bool]$profile.Loaded + Special = [bool]$profile.Special + Status = [uint32]$profile.Status + HealthStatus = [uint32]$profile.HealthStatus + RoamingConfigured = [bool]$profile.RoamingConfigured + RoamingPath = [string]$profile.RoamingPath + RoamingPreference = [bool]$profile.RoamingPreference + } + } catch { + throw 'runner profile authority validation failed' + } finally { + if ($null -ne $identity) { $identity.Dispose() } + } +} + +function Assert-RunnerProfileUnchanged($Before) { + $after = Get-RunnerProfileSnapshot + $unchanged = $after.ProfileExists -and $Before.ProfileExists -and + $after.DirectoryExists -and $Before.DirectoryExists -and + $after.IdentitySid -ceq $Before.IdentitySid -and + $after.ProfileSid -ceq $Before.ProfileSid -and + $after.CimLocalPath -ceq $Before.CimLocalPath -and + $after.CanonicalLocalPath -ceq $Before.CanonicalLocalPath -and + $after.DirectoryOwnerSid -ceq $Before.DirectoryOwnerSid -and + $after.DirectoryAttributes -eq $Before.DirectoryAttributes -and + $after.Loaded -eq $Before.Loaded -and + $after.Special -eq $Before.Special -and + $after.Status -eq $Before.Status -and + $after.HealthStatus -eq $Before.HealthStatus -and + $after.RoamingConfigured -eq $Before.RoamingConfigured -and + $after.RoamingPath -ceq $Before.RoamingPath -and + $after.RoamingPreference -eq $Before.RoamingPreference + Assert-True $unchanged 'runner profile authority changed during ownership test' +} + +function Test-PreExistingCleanupOwnership { + $runnerProfileBefore = Get-RunnerProfileSnapshot + $stateDirectory = New-StateDirectory 'ownership' + $conflictRoot = Join-Path $stateDirectory 'pre-existing' + $conflictInstallRoot = Join-Path $conflictRoot 'install-tree' + $conflictShortcutFolder = Join-Path $conflictRoot 'shortcut-folder' + $conflictShortcut = Join-Path $conflictShortcutFolder 'ProPR Desktop.lnk' + $conflictSmokeDirectory = Join-Path $conflictRoot 'smoke-data' + $conflictRegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\conflict-$([Guid]::NewGuid().ToString('N'))" + $userName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))z9" -AsPlainText -Force + $userCreated = $false + $registryCreated = $false + $userSid = $null + try { + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'pre-existing local user fixture baseline was not clean' + $createdUser = New-LocalUser -Name $userName -Password $password ` + -AccountNeverExpires -PasswordNeverExpires + $userCreated = $true + $userSid = $createdUser.SID + Assert-True ($null -ne $userSid) 'pre-existing local user fixture ownership capture failed' + $capturedUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($capturedUser.SID.Equals($userSid)) ` + 'pre-existing local user fixture ownership capture failed' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' + + foreach ($directory in @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + )) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Set-Content -LiteralPath (Join-Path $directory 'pre-existing.txt') -Value 'owned-before-run' + } + Set-Content -LiteralPath $conflictShortcut -Value 'owned-before-run' + [void](New-Item -Path $conflictRegistryPath -Force -ErrorAction Stop) + $registryCreated = $true + Set-ItemProperty -LiteralPath $conflictRegistryPath -Name 'PreExisting' -Value 'owned-before-run' + + $script:conflictingFixtureUserName = $userName + $script:conflictingFixtureUserSid = $userSid.Value + $script:conflictingFixtureProfileSid = $runnerProfileBefore.ProfileSid + $script:conflictingFixtureProfilePath = $runnerProfileBefore.CanonicalLocalPath + $script:conflictingFixtureDirectories = @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + ) -join '|' + $script:conflictingFixtureShortcut = $conflictShortcut + $script:conflictingFixtureRegistryPath = $conflictRegistryPath + + $result = Invoke-FixtureScenario 'OWNED_RESOURCES_THEN_DEADLINE' $stateDirectory + Assert-True ($result.ExitCode -eq 124) 'owned-resource timeout did not preserve watchdog status' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP:SMOKE_DATA_REMOVE:BEGIN:TIMED_OUT' ` + 'owned-resource fixture did not reach the forced timeout boundary' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'forced timeout did not execute bounded post-termination cleanup' + $redactedEvidence = "$($result.Output)`n$($result.Error)" + foreach ($forbidden in @( + $runnerProfileBefore.IdentitySid, + $runnerProfileBefore.CanonicalLocalPath, + $userName, + $userSid.Value, + $ownedFixtureUserName, + $ownedFixturePassword + )) { + Assert-NotContains $redactedEvidence $forbidden ` + 'ownership cleanup evidence exposed an identity or credential' + } + + $owned = Read-FixtureResourceState $stateDirectory + foreach ($ownedPath in @( + $owned.OwnedRoot, $owned.InstallRoot, $owned.ShortcutFolder, + $owned.Shortcut, $owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'post-termination cleanup left a run-owned file-system resource behind' + } + Assert-True (!(Test-Path -LiteralPath $owned.RegistryPath)) ` + 'post-termination cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $owned.RegistryRoot)) ` + 'post-termination cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $owned.UserName -ErrorAction SilentlyContinue)) ` + 'post-termination cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'post-termination cleanup left the run-owned profile behind' + + $replacementStateDirectory = New-StateDirectory 'replacement-collision' + $replacementResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' $replacementStateDirectory + Assert-True ($replacementResult.ExitCode -eq 125) ` + 'replacement collision did not fail the standalone cleanup' + Assert-Contains $replacementResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'replacement collision did not emit fixed cleanup failure evidence' + $replacementOwned = Read-FixtureResourceState $replacementStateDirectory + Assert-ReplacedFixtureResourcesSurvive $replacementOwned + Assert-True (Test-Path -LiteralPath $replacementOwned.ManifestPath -PathType Leaf) ` + 'false standalone cleanup result discarded authenticated recovery authority' + Restore-ReplacedFixtureAuthority $replacementOwned + $replacementRetry = Invoke-WorkflowCleanupController ` + $replacementOwned.ManifestPath $replacementOwned.RunId $replacementStateDirectory + $replacementRetryDiagnostic = + Get-SanitizedWorkflowCleanupResultDiagnostic $replacementRetry + Assert-True ($replacementRetry.ExitCode -eq 0 -and + $replacementRetry.Result -ceq 'COMPLETE') ` + "standalone cleanup did not retry to exact success after authority restoration:$replacementRetryDiagnostic" + Assert-OwnedResourcesGone $replacementOwned + Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` + 'successful standalone cleanup retry did not consume recovery authority' + + foreach ($replacementCase in @( + [PSCustomObject]@{ + Scenario = 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' + Directory = 'replaced-executable' + Label = 'executable' + }, + [PSCustomObject]@{ + Scenario = 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' + Directory = 'replaced-shortcut' + Label = 'shortcut' + } + )) { + $replacedStateDirectory = New-StateDirectory $replacementCase.Directory + $replacedResult = Invoke-FixtureScenario ` + $replacementCase.Scenario $replacedStateDirectory + Assert-True ($replacedResult.ExitCode -eq 125) ` + "replacement $($replacementCase.Label) did not fail before cleanup" + Assert-Contains $replacedResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + "replacement $($replacementCase.Label) did not emit fixed cleanup failure evidence" + $replacedOwned = Read-FixtureResourceState $replacedStateDirectory + if ($replacementCase.Label -ceq 'executable') { + Assert-ReplacedExecutableSurvives $replacedOwned + } else { + Assert-ReplacedShortcutSurvives $replacedOwned + } + Assert-MsiPreflightPreservedResources $replacedOwned + $replacedManifest = Get-Content -LiteralPath $replacedOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacedManifest.State -ceq 'ACTIVE') ` + "replacement $($replacementCase.Label) discarded ACTIVE recovery authority" + Restore-ReplacedFixtureAuthority $replacedOwned + $replacedRetry = Invoke-WorkflowCleanupController ` + $replacedOwned.ManifestPath $replacedOwned.RunId $replacedStateDirectory + Assert-True ($replacedRetry.ExitCode -eq 0 -and + $replacedRetry.Result -ceq 'COMPLETE') ` + "replacement $($replacementCase.Label) authority did not retry to success" + Assert-OwnedResourcesGone $replacedOwned + } + + $profileMismatchDirectory = New-StateDirectory 'profile-path-mismatch' + $profileMismatchResult = Invoke-FixtureScenario ` + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' $profileMismatchDirectory + Assert-True ($profileMismatchResult.ExitCode -eq 125) ` + 'mismatched durable profile path did not fail closed' + Assert-Contains $profileMismatchResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'mismatched durable profile path did not emit fixed cleanup failure evidence' + $profileMismatchOwned = Read-FixtureResourceState $profileMismatchDirectory + $survivingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($survivingProfiles.Count -eq 1) ` + 'mismatched durable path selected the owned profile for deletion' + $survivingProfilePath = (Resolve-Path -LiteralPath ` + ([string]$survivingProfiles[0].LocalPath) -ErrorAction Stop).ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $survivingProfilePath, + ([string]$profileMismatchOwned.ProfilePath).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched-path regression did not preserve the exact live profile' + $profileMismatchManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($profileMismatchManifest.State -ceq 'ACTIVE') ` + 'mismatched profile path discarded ACTIVE recovery authority' + $profileMismatchUsers = @($profileMismatchManifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + $remainingProfileUser = Get-LocalUser -Name $profileMismatchOwned.UserName ` + -ErrorAction Stop + Assert-True ($profileMismatchUsers.Count -eq 1 -and + [string]$remainingProfileUser.SID.Value -ceq [string]$profileMismatchOwned.UserSid -and + [string]$remainingProfileUser.Description -ceq + [string]$profileMismatchUsers[0].OwnershipMarker) ` + 'mismatched profile path discarded authenticated marker and SID authority' + $ownedProfileRecords = @($profileMismatchManifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + Assert-True ($ownedProfileRecords.Count -eq 1 -and + [string]::Equals( + [string]$ownedProfileRecords[0].LocalPath, + [string]$profileMismatchOwned.MismatchedProfilePath, + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched durable profile record was silently re-authorized' + + # A canonical profile belonging to another direct child is still not an + # owned path: its leaf is not the authenticated run username. + $ownedProfileRecords[0].LocalPath = $runnerProfileBefore.CanonicalLocalPath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $alternateLeafCleanup = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($alternateLeafCleanup.ExitCode -eq 21 -and + $alternateLeafCleanup.Result -ceq 'FAILED') ` + 'alternate ProfilesDirectory leaf did not fail closed' + $alternateLeafProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($alternateLeafProfiles.Count -eq 1) ` + 'alternate ProfilesDirectory leaf selected the owned profile for deletion' + $alternateLeafManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($alternateLeafManifest.State -ceq 'ACTIVE') ` + 'alternate ProfilesDirectory leaf discarded ACTIVE recovery authority' + + $ownedProfileRecords[0].LocalPath = [string]$profileMismatchOwned.ProfilePath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $profileMismatchRetry = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($profileMismatchRetry.ExitCode -eq 0 -and + $profileMismatchRetry.Result -ceq 'COMPLETE') ` + 'profile cleanup did not succeed after exact durable path restoration' + Assert-OwnedResourcesGone $profileMismatchOwned + + $byteIdenticalDirectory = New-StateDirectory 'byte-identical-replaced-executable' + $byteIdenticalResult = Invoke-FixtureScenario ` + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' $byteIdenticalDirectory + Assert-True ($byteIdenticalResult.ExitCode -eq 125) ` + 'byte-identical replace-via-move did not fail closed on entry identity' + $byteIdenticalOwned = Read-FixtureResourceState $byteIdenticalDirectory + Assert-ReplacedExecutableSurvives $byteIdenticalOwned + $byteIdenticalManifest = Get-Content -LiteralPath $byteIdenticalOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($byteIdenticalManifest.State -ceq 'ACTIVE') ` + 'byte-identical replace-via-move discarded ACTIVE recovery authority' + Remove-Item -LiteralPath $byteIdenticalOwned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $byteIdenticalOwned.ExecutableBackup ` + -Destination $byteIdenticalOwned.Executable -ErrorAction Stop + $byteIdenticalRetry = Invoke-WorkflowCleanupController ` + $byteIdenticalOwned.ManifestPath $byteIdenticalOwned.RunId $byteIdenticalDirectory + Assert-True ($byteIdenticalRetry.ExitCode -eq 0 -and + $byteIdenticalRetry.Result -ceq 'COMPLETE') ` + 'byte-identical file cleanup did not succeed after exact entry identity restoration' + Assert-True (!(Test-Path -LiteralPath $byteIdenticalOwned.Executable) -and + !(Test-Path -LiteralPath $byteIdenticalOwned.ManifestPath)) ` + 'byte-identical file retry did not consume the exact owned entry and authority' + + $foreignChildStateDirectory = New-StateDirectory 'in-place-foreign-child' + $foreignChildResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' $foreignChildStateDirectory + Assert-True ($foreignChildResult.ExitCode -eq 125) ` + 'in-place foreign child did not fail the standalone cleanup' + Assert-Contains $foreignChildResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'in-place foreign child did not emit fixed cleanup failure evidence' + $foreignChildOwned = Read-FixtureResourceState $foreignChildStateDirectory + $foreignChildPath = Join-Path $foreignChildOwned.InstallRoot 'foreign-in-place.txt' + Assert-True ((Get-Content -LiteralPath $foreignChildPath -Raw).Trim() -ceq ` + 'foreign-in-place') 'in-place foreign child was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignChildOwned.ManifestPath -PathType Leaf) ` + 'in-place foreign-child failure discarded authenticated recovery authority' + $foreignChildManifest = Get-Content -LiteralPath $foreignChildOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignChildManifest.State -ceq 'ACTIVE') ` + 'in-place foreign-child failure did not preserve the ACTIVE manifest' + Remove-Item -LiteralPath $foreignChildPath -Force -ErrorAction Stop + $foreignChildRetry = Invoke-WorkflowCleanupController ` + $foreignChildOwned.ManifestPath $foreignChildOwned.RunId $foreignChildStateDirectory + Assert-True ($foreignChildRetry.ExitCode -eq 0 -and + $foreignChildRetry.Result -ceq 'COMPLETE') ` + 'in-place foreign-child cleanup did not retry to exact success' + Assert-OwnedResourcesGone $foreignChildOwned + + $terminationFailureStateDirectory = New-StateDirectory 'termination-failure' + $terminationFailureResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_THEN_DEADLINE' $terminationFailureStateDirectory $true + Assert-True ($terminationFailureResult.ExitCode -eq 125) ` + 'unverified worker-tree termination did not fail closed' + Assert-Contains $terminationFailureResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'unverified worker-tree termination did not emit fixed failure evidence' + $terminationFailureOwned = Read-FixtureResourceState $terminationFailureStateDirectory + Assert-ProcessTreeGone (Read-FixtureProcessState $terminationFailureStateDirectory) + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.ManifestPath -PathType Leaf) ` + 'termination failure discarded authenticated recovery authority' + $terminationFailureManifest = Get-Content ` + -LiteralPath $terminationFailureOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($terminationFailureManifest.State -ceq 'ACTIVE') ` + 'termination failure did not preserve the ACTIVE manifest' + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.InstallRoot -PathType Container) ` + 'cleanup mutated resources before worker-tree termination was verified' + $terminationRetry = Invoke-WorkflowCleanupController ` + $terminationFailureOwned.ManifestPath $terminationFailureOwned.RunId ` + $terminationFailureStateDirectory + Assert-True ($terminationRetry.ExitCode -eq 0 -and + $terminationRetry.Result -ceq 'COMPLETE') ` + 'termination-failure authority did not retry to exact cleanup success' + Assert-OwnedResourcesGone $terminationFailureOwned + + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing install tree was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` + 'owned-before-run') 'pre-existing registry tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing shortcut was removed or changed' + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictSmokeDirectory 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing smoke data was removed or changed' + $remainingUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($remainingUser.SID.Equals($userSid)) 'pre-existing local user was removed or replaced' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' + + $gracefulStateDirectory = New-StateDirectory 'graceful-interruption' + $graceful = Start-ExternallyInterruptibleSupervisor $gracefulStateDirectory + try { + $gracefulProcessState = Read-FixtureProcessState $gracefulStateDirectory + $gracefulOwned = Read-FixtureResourceState $gracefulStateDirectory + $graceful.Pipeline.Stop() + try { [void]$graceful.Pipeline.EndInvoke($graceful.AsyncResult) } catch {} + Assert-ProcessTreeGone $gracefulProcessState + Assert-OwnedResourcesGone $gracefulOwned + } finally { + $graceful.Pipeline.Dispose() + } + + $workflowStateDirectory = New-StateDirectory 'workflow-cleanup' + $workflowRunId = [Guid]::NewGuid().ToString('N') + $workflowManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$workflowRunId.json" + $workflowSupervisor = [Diagnostics.Process]::new() + $workflowSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_FOR_INTERRUPTION' $workflowStateDirectory '' $false ` + $workflowManifest $workflowRunId + try { + if (!$workflowSupervisor.Start()) { throw 'workflow supervisor fixture did not start' } + $workflowProcessState = Read-FixtureProcessState $workflowStateDirectory + $workflowOwned = Read-FixtureResourceState $workflowStateDirectory + $workflowSupervisor.Kill($false) + Assert-True ($workflowSupervisor.WaitForExit(5000)) ` + 'killed workflow supervisor did not exit within the bound' + Assert-ProcessTreeGone $workflowProcessState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'killed supervisor did not preserve the durable ownership manifest' + $parameterFailure = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory -1 + Assert-True ($parameterFailure.ExitCode -eq 125 -and + $parameterFailure.Result -ceq 'FAILED' -and + $parameterFailure.ControllerStatus.StartsWith( + 'CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_', + [StringComparison]::Ordinal + )) 'controller parameter failure was not caught and phase-classified' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'controller parameter failure discarded authenticated recovery authority' + $earlyInitializationTimeout = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 5000 $true + Assert-True ($earlyInitializationTimeout.ExitCode -eq 124 -and + $earlyInitializationTimeout.ReportedExitCode -eq 124 -and + $earlyInitializationTimeout.Result -ceq 'TIMED_OUT') ` + 'early-initialization child cleanup did not report its fixed timeout' + $earlyInitializationState = Get-Content -LiteralPath ` + (Join-Path $workflowStateDirectory 'workflow-cleanup-early-processes.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-ProcessTreeGone $earlyInitializationState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'early-initialization timeout discarded authenticated recovery authority' + $timedOutCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 1 + Assert-True ($timedOutCleanup.ExitCode -eq 124 -and + $timedOutCleanup.ReportedExitCode -eq 124 -and + $timedOutCleanup.Result -ceq 'TIMED_OUT') ` + 'workflow cleanup did not report its injected fixed timeout' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'timed-out workflow cleanup discarded authenticated recovery authority' + + $installerBackup = Join-Path $testRoot 'fixture-owned-entry.msi' + Move-Item -LiteralPath $dummyInstaller -Destination $installerBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($dummyInstaller, [Text.Encoding]::ASCII.GetBytes( + 'foreign same-path MSI replacement must never be consulted')) + $foreignInstallerDigest = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash + try { + $replacedInstallerCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($replacedInstallerCleanup.ExitCode -eq 21 -and + $replacedInstallerCleanup.ReportedExitCode -eq 21 -and + $replacedInstallerCleanup.Result -ceq 'FAILED' -and + $replacedInstallerCleanup.ControllerStatus -ceq + 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'same-path installer replacement did not fail closed' + Assert-MsiPreflightPreservedResources $workflowOwned + $retainedAuthority = Get-Content -LiteralPath $workflowManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($retainedAuthority.State -ceq 'ACTIVE') ` + 'same-path installer replacement discarded ACTIVE recovery authority' + Assert-True ((Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash -ceq + $foreignInstallerDigest) ` + 'foreign same-path installer was executed or changed' + } finally { + if (Test-Path -LiteralPath $dummyInstaller) { + Remove-Item -LiteralPath $dummyInstaller -Force -ErrorAction SilentlyContinue + } + Move-Item -LiteralPath $installerBackup -Destination $dummyInstaller -ErrorAction Stop + } + Assert-True ( + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) -ceq + $dummyInstallerEntryIdentity -and + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash.ToLowerInvariant() -ceq + $dummyInstallerSha256 + ) 'exact installer authority was not restored for cleanup retry' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $failedWorkflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($failedWorkflowCleanup.ExitCode -eq 21 -and + $failedWorkflowCleanup.ReportedExitCode -eq 21 -and + $failedWorkflowCleanup.Result -ceq 'FAILED' -and + $failedWorkflowCleanup.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'workflow cleanup did not report a fixed replacement-collision failure' + Assert-True ((Get-ItemPropertyValue -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'workflow cleanup removed a replacement registry object' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'failed workflow cleanup discarded authenticated recovery authority' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$workflowOwned.Token) + $workflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($workflowCleanup.ExitCode -eq 0 -and + $workflowCleanup.ReportedExitCode -eq 0 -and + $workflowCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'workflow cleanup controller did not retry to fixed cleanup success' + Assert-Contains $workflowCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:COMPLETE' ` + 'workflow cleanup controller did not emit fixed completion evidence' + Assert-OwnedResourcesGone $workflowOwned + Assert-True (!(Test-Path -LiteralPath $workflowManifest)) ` + 'workflow cleanup did not consume the ownership manifest' + } finally { + if (!$workflowSupervisor.HasExited) { try { $workflowSupervisor.Kill($true) } catch {} } + $workflowSupervisor.Dispose() + } + + $normalStateDirectory = New-StateDirectory 'workflow-normal-already-cleaned' + $normalRunId = [Guid]::NewGuid().ToString('N') + $normalManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$normalRunId.json" + $normalSupervisor = [Diagnostics.Process]::new() + $normalSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_NORMAL_SUCCESS' $normalStateDirectory '' $false ` + $normalManifest $normalRunId + try { + if (!$normalSupervisor.Start()) { throw 'normal workflow supervisor fixture did not start' } + $normalOwned = Read-FixtureResourceState $normalStateDirectory + Assert-True ($normalSupervisor.WaitForExit(40000)) ` + 'normal workflow supervisor fixture exceeded its bound' + Assert-True ($normalSupervisor.ExitCode -eq 0) ` + 'normal workflow supervisor fixture did not complete successfully' + Assert-OwnedResourcesGone $normalOwned + Assert-True (Test-Path -LiteralPath $normalManifest -PathType Leaf) ` + 'normal supervisor did not preserve its empty ownership receipt' + $normalReceipt = Get-Content -LiteralPath $normalManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($normalReceipt.SchemaVersion -eq 3 -and + $normalReceipt.ManifestType -ceq 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -and + $normalReceipt.State -ceq 'EMPTY' -and + $normalReceipt.InstallerEntryIdentity -ceq $dummyInstallerEntryIdentity -and + $normalReceipt.InstallerSha256 -ceq $dummyInstallerSha256 -and + $normalReceipt.InstallerProductCode -ceq $dummyInstallerProductCode -and + @($normalReceipt.Directories).Count -eq 0 -and + @($normalReceipt.Files).Count -eq 0 -and + @($normalReceipt.RegistryKeys).Count -eq 0 -and + @($normalReceipt.RegistryValues).Count -eq 0 -and + @($normalReceipt.Users).Count -eq 0 -and + @($normalReceipt.Profiles).Count -eq 0) ` + 'normal supervisor did not produce a typed authenticated empty-state receipt' + $normalCleanup = Invoke-WorkflowCleanupController ` + $normalManifest $normalRunId $normalStateDirectory + Assert-True ($normalCleanup.ExitCode -eq 0 -and + $normalCleanup.ReportedExitCode -eq 0 -and + $normalCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'always cleanup did not accept the normal already-cleaned receipt' + Assert-True (!(Test-Path -LiteralPath $normalManifest)) ` + 'always cleanup did not consume the normal empty-state receipt' + } finally { + if (!$normalSupervisor.HasExited) { try { $normalSupervisor.Kill($true) } catch {} } + $normalSupervisor.Dispose() + } + + foreach ($manifestCase in @('MISSING','MALFORMED','STALE')) { + $badRunId = [Guid]::NewGuid().ToString('N') + $badManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$badRunId.json" + if ($manifestCase -eq 'MALFORMED') { + [IO.File]::WriteAllText($badManifest, '{not-json', [Text.Encoding]::UTF8) + } elseif ($manifestCase -eq 'STALE') { + $createdTicks = [DateTime]::UtcNow.AddHours(-4).Ticks + $staleManifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $badRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $workflowStateDirectory; BaselineClean = $false + InstallAttempted = $false; MsiTransactionState = 'NONE' + Directories = @(); Files = @() + RegistryKeys = @(); RegistryValues = @(); Users = @(); Profiles = @() + } + [IO.File]::WriteAllText( + $badManifest, + ($staleManifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + } + $failedCleanup = Invoke-WorkflowCleanupController ` + $badManifest $badRunId $workflowStateDirectory + Assert-True ($failedCleanup.ExitCode -ne 0) ` + "$manifestCase workflow manifest did not fail closed" + Assert-True ($failedCleanup.ExitCode -eq 20 -and + $failedCleanup.ReportedExitCode -eq 20 -and + $failedCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + "$manifestCase workflow manifest did not report fixed validation status" + Assert-Contains $failedCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + "$manifestCase workflow manifest did not emit fixed failure evidence" + if ($manifestCase -ne 'MISSING') { + Assert-True (Test-Path -LiteralPath $badManifest -PathType Leaf) ` + "$manifestCase workflow failure discarded authenticated recovery authority" + Remove-Item -LiteralPath $badManifest -Force -ErrorAction Stop + } + } + + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing install tree' + Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing registry tree' + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing shortcut' + Assert-True ((Get-LocalUser -Name $userName -ErrorAction Stop).SID.Equals($userSid)) ` + 'external cleanup changed the pre-existing local user' + } finally { + $script:conflictingFixtureUserName = $null + $script:conflictingFixtureUserSid = $null + $script:conflictingFixtureProfileSid = $null + $script:conflictingFixtureProfilePath = $null + $script:conflictingFixtureDirectories = $null + $script:conflictingFixtureShortcut = $null + $script:conflictingFixtureRegistryPath = $null + if ($registryCreated -and (Test-Path -LiteralPath $conflictRegistryPath)) { + Remove-Item -LiteralPath $conflictRegistryPath -Recurse -Force -ErrorAction SilentlyContinue + } + $fixtureRegistryRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture' + if ((Test-Path -LiteralPath $fixtureRegistryRoot) -and + @(Get-ChildItem -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue).Count -eq 0) { + Remove-Item -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue + } + if ($userCreated) { + $ownedUser = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + Assert-True ($null -ne $userSid -and $ownedUser.SID.Equals($userSid)) ` + 'refusing to remove a local user not owned by the fixture' + Remove-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'ownership local-user fixture cleanup failed' + } + } + $ownedUser = Get-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { $_.SID -ceq $ownedUser.SID.Value }) + foreach ($profile in $ownedProfiles) { + Remove-CimInstance -InputObject $profile -ErrorAction SilentlyContinue + } + Remove-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + } + Assert-RunnerProfileUnchanged $runnerProfileBefore + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED' + [Console]::Out.Flush() +} + +function Test-SmokePromotionInterruptionAuthority { + foreach ($testCase in @( + @{ Scenario = 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE'; Label = 'before promotion' }, + @{ Scenario = 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE'; Label = 'after promotion' }, + @{ Scenario = 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE'; Label = 'after artifact creation' } + )) { + $stateDirectory = New-StateDirectory ( + 'smoke-' + $testCase.Scenario.ToLowerInvariant().Replace('_', '-')) + $result = Invoke-FixtureScenario $testCase.Scenario $stateDirectory + Assert-True ($result.ExitCode -eq 124) ` + "smoke interruption $($testCase.Label) did not preserve watchdog status" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "smoke interruption $($testCase.Label) did not complete recovery cleanup" + $owned = Read-FixtureResourceState $stateDirectory + Assert-OwnedResourcesGone $owned + } + + $foreignStateDirectory = New-StateDirectory 'smoke-in-place-foreign-descendant' + $foreignResult = Invoke-FixtureScenario ` + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' $foreignStateDirectory + Assert-True ($foreignResult.ExitCode -eq 125) ` + 'smoke foreign descendant did not fail closed' + Assert-Contains $foreignResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'smoke foreign descendant did not emit fixed cleanup failure evidence' + $foreignOwned = Read-FixtureResourceState $foreignStateDirectory + Assert-True ((Get-Content -LiteralPath $foreignOwned.ForeignSmokePath -Raw).Trim() -ceq ` + 'foreign-smoke-in-place') 'smoke foreign descendant was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignOwned.ManifestPath -PathType Leaf) ` + 'smoke foreign descendant discarded authenticated recovery authority' + $foreignManifest = Get-Content -LiteralPath $foreignOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignManifest.State -ceq 'ACTIVE') ` + 'smoke foreign descendant did not preserve ACTIVE recovery authority' + Remove-Item -LiteralPath $foreignOwned.ForeignSmokePath -Force -ErrorAction Stop + $retry = Invoke-WorkflowCleanupController ` + $foreignOwned.ManifestPath $foreignOwned.RunId $foreignStateDirectory + Assert-True ($retry.ExitCode -eq 0 -and $retry.Result -ceq 'COMPLETE') ` + 'smoke foreign-descendant recovery did not retry to exact success' + Assert-OwnedResourcesGone $foreignOwned + + $tokenStateDirectory = New-StateDirectory 'smoke-token-mismatch' + $tokenResult = Invoke-FixtureScenario ` + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' $tokenStateDirectory + Assert-True ($tokenResult.ExitCode -eq 125) ` + 'mismatched smoke ownership token did not fail closed' + $tokenOwned = Read-FixtureResourceState $tokenStateDirectory + $tokenPath = Join-Path $tokenOwned.SmokeDirectory '.propr-installed-app-owner' + Assert-True ((Get-Content -LiteralPath $tokenPath -Raw).Trim() -ceq 'foreign-owner') ` + 'mismatched smoke ownership token was removed or changed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'mismatched smoke ownership token discarded recovery authority' + Remove-Item -LiteralPath $tokenPath -Force -ErrorAction Stop + $missingToken = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($missingToken.ExitCode -eq 20 -and $missingToken.Result -ceq 'FAILED') ` + 'missing smoke ownership token did not fail manifest validation closed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'missing smoke ownership token discarded recovery authority' + [IO.File]::WriteAllText($tokenPath, [string]$tokenOwned.Token, [Text.Encoding]::ASCII) + $tokenRetry = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($tokenRetry.ExitCode -eq 0 -and $tokenRetry.Result -ceq 'COMPLETE') ` + 'restored exact smoke ownership token did not retry to cleanup success' + Assert-OwnedResourcesGone $tokenOwned +} + +function Test-PrimaryWorkerFallbackForeignDescendants { + $stateDirectory = New-StateDirectory 'primary-fallback-foreign-descendants' + $result = Invoke-FixtureScenario 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' $stateDirectory + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ($result.ExitCode -eq 0) ` + "primary worker fallback foreign-descendant fixture did not complete:$diagnostic" + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'primary-fallback.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-True ((Get-Content -LiteralPath $state.InstallForeign -Raw).Trim() -ceq ` + 'foreign-install') 'primary install fallback removed or changed a foreign descendant' + Assert-True ((Get-Content -LiteralPath $state.ShortcutForeign -Raw).Trim() -ceq ` + 'foreign-shortcut') 'primary shortcut fallback removed or changed a foreign descendant' +} + +function Test-PreExistingAppPathsAuthority { + $appPaths = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + $protocol = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + $sentinelApplication = 'C:\pre-existing\propr-desktop.exe' + $sentinelProtocol = 'pre-existing-protocol' + Assert-True (!(Test-Path -LiteralPath $appPaths)) ` + 'pre-existing App Paths fixture baseline was not clean' + Assert-True (!(Test-Path -LiteralPath $protocol)) ` + 'pre-existing protocol fixture baseline was not clean' + try { + [void](New-Item -Path $appPaths -Force -ErrorAction Stop) + Set-Item -LiteralPath $appPaths -Value $sentinelApplication + Set-ItemProperty -LiteralPath $appPaths -Name 'Path' -Value 'C:\pre-existing' + [void](New-Item -Path $protocol -Force -ErrorAction Stop) + Set-Item -LiteralPath $protocol -Value $sentinelProtocol + Set-ItemProperty -LiteralPath $protocol -Name 'URL Protocol' -Value 'do-not-remove' + + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + 'PRE_EXISTING_APP_PATHS' $testRoot '' $true + try { + if (!$process.Start()) { throw 'pre-existing registry supervisor did not start' } + Assert-True ($process.WaitForExit(20000)) ` + 'pre-existing registry supervisor exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-True ($process.ExitCode -ne 0) ` + 'pre-existing App Paths authority was not rejected' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'pre-existing App Paths rejection did not finish bounded cleanup' + Assert-NotContains "$output`n$errorOutput" $sentinelApplication ` + 'pre-existing App Paths evidence was not redacted' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'pre-existing App Paths executable was removed or changed' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'pre-existing App Paths values were removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'pre-existing protocol key was removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'pre-existing protocol values were removed or changed' + + $mismatchRunId = [Guid]::NewGuid().ToString('N') + $mismatchManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$mismatchRunId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $mismatchState = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $mismatchRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false; FixtureRoot = $null + BaselineClean = $true; InstallAttempted = $true + MsiTransactionState = 'COMMITTED' + Directories = @(); Files = @(); Users = @(); Profiles = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + Name = 'installed'; Owned = $false; Provisional = $false + BaselineKeyExisted = $false; BaselineValueExisted = $false + BaselineValueKind = $null; BaselineValueData = $null + IdentityValueKind = $null; IdentityValueData = $null; KeyCreatedByRun = $false + }) + RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocol; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPaths; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + } + ) + } + [IO.File]::WriteAllText( + $mismatchManifest, + ($mismatchState | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + $mismatchCleanup = Invoke-WorkflowCleanupController ` + $mismatchManifest $mismatchRunId '' + Assert-True ($mismatchCleanup.ExitCode -ne 0) ` + 'mismatched App Paths ownership identity did not fail closed' + Assert-True ($mismatchCleanup.ExitCode -eq 20 -and + $mismatchCleanup.ReportedExitCode -eq 20 -and + $mismatchCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + 'mismatched App Paths ownership did not report fixed validation status' + Assert-Contains $mismatchCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + 'mismatched App Paths ownership did not emit fixed failure evidence' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'mismatched App Paths ownership removed the pre-existing executable value' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'mismatched App Paths ownership removed pre-existing values' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'mismatched protocol ownership removed the pre-existing key' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'mismatched protocol ownership removed pre-existing values' + } finally { + if ((Test-Path -LiteralPath $appPaths) -and + (Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) { + Remove-Item -LiteralPath $appPaths -Recurse -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $protocol) -and + (Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) { + Remove-Item -LiteralPath $protocol -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:APP_PATHS_PRE_EXISTING:PRESERVED' + [Console]::Out.Flush() +} + +function Test-HkcuInstalledValueOwnership { + $desktopKey = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + $installedName = 'installed' + $sentinelInstalled = 'pre-existing-installed' + $sentinelUnrelated = 'preserve-unrelated' + Assert-True (!(Test-Path -LiteralPath $desktopKey)) ` + 'HKCU installed-value fixture baseline was not clean' + + function New-HkcuManifest( + [bool]$BaselineKeyExisted, + [bool]$BaselineValueExisted, + [AllowNull()][string]$BaselineKind, + [AllowNull()][string]$BaselineData, + [bool]$KeyCreatedByRun, + [bool]$Provisional = $false, + [bool]$InstallAttempted = $false + ) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $installedIdentityData = [Convert]::ToBase64String( + [BitConverter]::GetBytes([int32]1)) + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false + FixtureRoot = $null + BaselineClean = $InstallAttempted + InstallAttempted = $InstallAttempted + MsiTransactionState = if ($InstallAttempted) { 'PENDING' } else { 'NONE' } + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $desktopKey; Name = $installedName + Owned = $true; Provisional = $Provisional + BaselineKeyExisted = $BaselineKeyExisted + BaselineValueExisted = $BaselineValueExisted + BaselineValueKind = $BaselineKind + BaselineValueData = $BaselineData + IdentityValueKind = if ($Provisional) { $null } else { 'DWord' } + IdentityValueData = if ($Provisional) { $null } else { $installedIdentityData } + KeyCreatedByRun = $KeyCreatedByRun + }) + Users = @() + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + try { + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, $sentinelInstalled, [Microsoft.Win32.RegistryValueKind]::String) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $baselineData = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($sentinelInstalled)) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $restoreManifest = New-HkcuManifest $true $true 'String' $baselineData $false + $restore = Invoke-WorkflowCleanupController $restoreManifest.Path $restoreManifest.RunId '' + Assert-True ($restore.ExitCode -eq 0 -and + $restore.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'pre-existing HKCU installed value restoration did not complete' + $restoredKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($restoredKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$restoredKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'pre-existing HKCU installed value was not restored exactly' + Assert-True ([string]$restoredKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'unrelated HKCU value was changed during baseline restoration' + + $unchangedManifest = New-HkcuManifest ` + $true $true 'String' $baselineData $false $false $true + $unchanged = Invoke-WorkflowCleanupController ` + $unchangedManifest.Path $unchangedManifest.RunId '' + Assert-True ($unchanged.ExitCode -eq 21 -and + $unchanged.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'path-only pending MSI receipt was not rejected before uninstall' + $unchangedKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($unchangedKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$unchangedKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'rejected pending MSI receipt changed the unchanged HKCU baseline' + Assert-True (Test-Path -LiteralPath $unchangedManifest.Path -PathType Leaf) ` + 'rejected pending MSI receipt discarded authenticated recovery authority' + Remove-Item -LiteralPath $unchangedManifest.Path -Force -ErrorAction Stop + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $nonemptyManifest = New-HkcuManifest $false $false $null $null $true + $nonempty = Invoke-WorkflowCleanupController $nonemptyManifest.Path $nonemptyManifest.RunId '' + Assert-True ($nonempty.ExitCode -eq 0) ` + 'run-owned HKCU value cleanup with unrelated values failed' + $nonemptyKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True (@($nonemptyKey.GetValueNames()) -cnotcontains $installedName -and + [string]$nonemptyKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'run-owned HKCU cleanup removed its nonempty key or unrelated value' + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $emptyManifest = New-HkcuManifest $false $false $null $null $true + $empty = Invoke-WorkflowCleanupController $emptyManifest.Path $emptyManifest.RunId '' + Assert-True ($empty.ExitCode -eq 0 -and !(Test-Path -LiteralPath $desktopKey)) ` + 'run-created empty HKCU key was not removed' + + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, 'foreign-conflict', [Microsoft.Win32.RegistryValueKind]::String) + $conflictManifest = New-HkcuManifest $false $false $null $null $true + $conflict = Invoke-WorkflowCleanupController ` + $conflictManifest.Path $conflictManifest.RunId '' + Assert-True ($conflict.ExitCode -eq 21 -and + $conflict.ReportedExitCode -eq 21 -and + $conflict.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'conflicting HKCU installed value did not fail with fixed resource-cleanup status' + $conflictingKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ([string]$conflictingKey.GetValue($installedName) -ceq 'foreign-conflict') ` + 'conflicting HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $conflictManifest.Path -PathType Leaf) ` + 'conflicting HKCU cleanup discarded authenticated recovery authority' + Remove-Item -LiteralPath $conflictManifest.Path -Force -ErrorAction Stop + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $provisionalManifest = New-HkcuManifest $false $false $null $null $true $true + $provisional = Invoke-WorkflowCleanupController ` + $provisionalManifest.Path $provisionalManifest.RunId '' + Assert-True ($provisional.ExitCode -eq 21 -and + $provisional.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional HKCU evidence authorized manual registry deletion' + Assert-True ((Get-Item -LiteralPath $desktopKey).GetValueKind($installedName).ToString() ` + -ceq 'DWord' -and + [int](Get-ItemPropertyValue -LiteralPath $desktopKey -Name $installedName) -eq 1) ` + 'provisional HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $provisionalManifest.Path -PathType Leaf) ` + 'provisional HKCU failure discarded authenticated recovery authority' + Remove-Item -LiteralPath $provisionalManifest.Path -Force -ErrorAction Stop + } finally { + if (Test-Path -LiteralPath $desktopKey) { + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:HKCU_INSTALLED_VALUE:PRESERVED' + [Console]::Out.Flush() +} + +function Test-ProvisionalUserMarkerOwnership { + function New-ProvisionalUserManifest([string]$UserName, [string]$OwnershipMarker) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $testRoot + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @([ordered]@{ + Name = $UserName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $OwnershipMarker + }) + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))u8" ` + -AsPlainText -Force + $positiveName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $positiveMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $replacementName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $replacementMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $positiveManifest = $null + $replacementManifest = $null + try { + $positiveManifest = New-ProvisionalUserManifest $positiveName $positiveMarker + New-LocalUser -Name $positiveName -Password $password ` + -Description $positiveMarker -AccountNeverExpires -PasswordNeverExpires | Out-Null + $positive = Invoke-WorkflowCleanupController ` + $positiveManifest.Path $positiveManifest.RunId $testRoot + Assert-True ($positive.ExitCode -eq 0 -and + $positive.Result -ceq 'COMPLETE') ` + 'marker-bound provisional local-user recovery did not complete' + Assert-True ($null -eq (Get-LocalUser -Name $positiveName -ErrorAction SilentlyContinue)) ` + 'marker-bound provisional local-user recovery left its account behind' + + $replacementManifest = New-ProvisionalUserManifest $replacementName $replacementMarker + New-LocalUser -Name $replacementName -Password $password ` + -Description "prpr-own-$([Guid]::NewGuid().ToString('N'))" ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $replacementSid = (Get-LocalUser -Name $replacementName -ErrorAction Stop).SID.Value + $replacement = Invoke-WorkflowCleanupController ` + $replacementManifest.Path $replacementManifest.RunId $testRoot + Assert-True ($replacement.ExitCode -eq 21 -and + $replacement.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional username authorized replacement-account deletion' + $survivingReplacement = Get-LocalUser -Name $replacementName -ErrorAction Stop + Assert-True ($survivingReplacement.SID.Value -ceq $replacementSid) ` + 'replacement account identity changed during provisional cleanup' + Assert-True (Test-Path -LiteralPath $replacementManifest.Path -PathType Leaf) ` + 'provisional replacement failure discarded authenticated recovery authority' + $replacementAuthority = Get-Content -LiteralPath $replacementManifest.Path ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacementAuthority.State -ceq 'ACTIVE') ` + 'provisional replacement failure did not preserve the ACTIVE manifest' + } finally { + foreach ($name in @($positiveName, $replacementName)) { + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -ne $user) { Remove-LocalUser -Name $name -ErrorAction SilentlyContinue } + } + foreach ($manifest in @($positiveManifest, $replacementManifest)) { + if ($null -ne $manifest -and (Test-Path -LiteralPath $manifest.Path)) { + Remove-Item -LiteralPath $manifest.Path -Force -ErrorAction SilentlyContinue + } + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PROVISIONAL_USER_MARKER:PRESERVED' + [Console]::Out.Flush() +} + +if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } +$actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +Assert-True ($actualArchitecture -ceq $Architecture) ` + "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" + +Test-WorkflowCleanupBodyParserRegression +[void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) +Initialize-TestInstaller +try { + Test-WorkflowCleanupStartupProtocol + Test-BootstrapTimeout + Test-WindowsPowerShellCleanupCompatibility + Test-OperationDeadlineAndTreeTermination + Test-NegativeWorkerExitFinalization + Test-FailClosedMarkers + Test-LiveCancellationAndRedaction + Test-MsiTransactionInterruptionGates + Test-PrimaryWorkerFallbackForeignDescendants + Test-PreExistingCleanupOwnership + Test-SmokePromotionInterruptionAuthority + Test-PreExistingAppPathsAuthority + Test-HkcuInstalledValueOwnership + Test-ProvisionalUserMarkerOwnership + Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" + [Console]::Out.Flush() +} finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 new file mode 100644 index 000000000..557dda2d4 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -0,0 +1,2601 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest +) + +enum SmokeEvidenceInspectionPhase { + DIRECTORY + ACL + FILE_METADATA + FILE_OPEN + FILE_READ + EVENT_PARSE + SUMMARY +} + +$ErrorActionPreference = 'Stop' +$bootstrapWatchdogTimeoutMilliseconds = 60 * 1000 +$markerTransitionTimeoutMilliseconds = 30 * 1000 +$ownershipHandshakeTimeoutMilliseconds = 5 * 1000 +if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledApp-[a-f0-9]{32}$') { + throw 'worker ownership event name is invalid' +} +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne($ownershipHandshakeTimeoutMilliseconds)) { + throw 'worker ownership was not established' + } +} finally { + $ownershipReady.Dispose() +} +$watchdogMarkerPath = [IO.Path]::GetFullPath($WatchdogMarker) +$watchdogMarkerParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') +if ((Split-Path -Leaf $watchdogMarkerPath) -notmatch + '^propr-installed-app-watchdog-[a-f0-9]{32}\.marker$' -or + ![string]::Equals( + (Split-Path -Parent $watchdogMarkerPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'watchdog marker path is invalid' +} +$ownershipManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) +if ((Split-Path -Leaf $ownershipManifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + ![string]::Equals( + (Split-Path -Parent $ownershipManifestPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'ownership manifest path is invalid' +} +$bootstrapDeadline = [DateTime]::UtcNow.AddMilliseconds($bootstrapWatchdogTimeoutMilliseconds).Ticks +$bootstrapRecord = '{0}|INITIALIZATION|PATHS|BEGIN' -f $bootstrapDeadline +$bootstrapBytes = [Text.Encoding]::ASCII.GetBytes($bootstrapRecord) +$bootstrapStream = [IO.FileStream]::new( + $watchdogMarkerPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $bootstrapStream.Write($bootstrapBytes, 0, $bootstrapBytes.Length) + $bootstrapStream.Flush($true) +} finally { + $bootstrapStream.Dispose() +} +Write-Host 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:PATHS:BEGIN' +[Console]::Out.Flush() + +$primaryFailure = $null +try { + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path +} catch { + throw 'installer resolution failed' +} +$installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' +$application = Join-Path $installRoot 'propr-desktop.exe' +$protocolRegistryPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' +$appPathsRegistryPath = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' +$hkcuDesktopRegistryPath = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' +$hkcuInstalledValueName = 'installed' +$testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" +$passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" +$password = ConvertTo-SecureString $passwordText -AsPlainText -Force +$passwordText = $null +$credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) +$installAttempted = $false +$msiInstallCompleted = $false +$installerArtifactAuthorityValid = $true +$testUserCreatedByRun = $false +$testUserSid = $null +$smokeUserDataDirectory = $null +$smokeOwnershipRecord = $null +$installRootExistedBeforeInstall = $false +$protocolExistedBeforeInstall = $false +$appPathsExistedBeforeInstall = $false +$hkcuDesktopKeyExistedBeforeInstall = $false +$hkcuInstalledValueExistedBeforeInstall = $false +$hkcuInstalledBaselineKind = $null +$hkcuInstalledBaselineData = $null +$installRootCreatedByRun = $false +$protocolCreatedByRun = $false +$appPathsCreatedByRun = $false +$protocolOwnedIdentity = $null +$appPathsOwnedIdentity = $null +$installRootOwnedIdentity = $null +$installRootOwnedTreeIdentity = $null +$shortcutFolderOwnedIdentity = $null +$shortcutFolderOwnedTreeIdentity = $null +$hkcuInstalledOwnedKind = $null +$hkcuInstalledOwnedData = $null +$shortcutOwnedIdentity = $null +$shortcutOwnedEntryIdentity = $null +$hkcuDesktopKeyCreatedByRun = $false +$msiTimeoutMilliseconds = 10 * 60 * 1000 +$msiCaptureRollbackGraceMilliseconds = 30 * 1000 +$applicationTimeoutMilliseconds = 5 * 60 * 1000 +$terminationTimeoutMilliseconds = 30 * 1000 +$redirectedStreamDrainTimeoutMilliseconds = 30 * 1000 +$externalOperationTimeoutMilliseconds = 60 * 1000 +$recursiveOperationTimeoutMilliseconds = 90 * 1000 +$alternateUserLaunchTimeoutMilliseconds = 90 * 1000 +$smokeEvidenceFileByteCap = 64 * 1024 +$smokeEvidenceOpenRetryDeadlineMilliseconds = 2 * 1000 +$smokeEvidenceOpenRetryDelayMilliseconds = 50 +$smokeEventCodes = [ordered]@{ + 'desktop.smoke.authorized' = 'SMOKE_AUTHORIZED' + 'desktop.app.ready' = 'APP_READY' + 'desktop.renderer.mvp_flows.ready' = 'MVP_FLOWS_READY' + 'desktop.renderer.layout.ready' = 'LAYOUT_READY' + 'desktop.native.reduced_window.ready' = 'REDUCED_NATIVE_WINDOW_READY' + 'desktop.renderer.ready' = 'RENDERER_READY' + 'desktop.app.shutdown' = 'APP_SHUTDOWN' + 'desktop.app.start_failed' = 'START_FAILED' + 'desktop.main_process.uncaught_exception' = 'UNCAUGHT_EXCEPTION' + 'desktop.log.write_failed' = 'LOG_WRITE_FAILURE' +} +$requiredSmokeEvents = @( + 'desktop.smoke.authorized', + 'desktop.app.ready', + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + 'desktop.renderer.ready', + 'desktop.app.shutdown' +) +$smokeEvidenceFileNames = @( + 'application.smoke-evidence.jsonl', + 'application.stdout.log', + 'application.stderr.log' +) +$machineTempValue = [Environment]::GetEnvironmentVariable('TEMP', [EnvironmentVariableTarget]::Machine) +if (!$machineTempValue) { throw 'machine temporary directory is unavailable' } +$machineTemp = [Environment]::ExpandEnvironmentVariables($machineTempValue) +if (![IO.Path]::IsPathRooted($machineTemp)) { throw 'machine temporary directory is not absolute' } +$machineTemp = (Resolve-Path -LiteralPath $machineTemp).Path +$windowsDirectory = [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows) +if (!$windowsDirectory -or ![IO.Path]::IsPathRooted($windowsDirectory)) { + throw 'Windows directory is unavailable' +} +$windowsDirectory = (Resolve-Path -LiteralPath $windowsDirectory -ErrorAction Stop).Path +$windowsDirectoryItem = Get-Item -LiteralPath $windowsDirectory -Force -ErrorAction Stop +if (!$windowsDirectoryItem.PSIsContainer -or + ($windowsDirectoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Windows directory is invalid' +} +$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) +if (!$commonPrograms -or ![IO.Path]::IsPathRooted($commonPrograms)) { + throw 'common Start Menu directory is unavailable' +} +$commonPrograms = (Resolve-Path -LiteralPath $commonPrograms -ErrorAction Stop).Path +$startMenuShortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' +$startMenuShortcut = Join-Path $startMenuShortcutFolder 'ProPR Desktop.lnk' +$installRootExistedBeforeInstall = Test-Path -LiteralPath $installRoot +$protocolExistedBeforeInstall = + Test-Path -LiteralPath $protocolRegistryPath +$appPathsExistedBeforeInstall = Test-Path -LiteralPath $appPathsRegistryPath +$hkcuDesktopKeyExistedBeforeInstall = Test-Path -LiteralPath $hkcuDesktopRegistryPath +$startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut +$startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder +$startMenuShortcutCreatedByRun = $false +$startMenuShortcutFolderCreatedByRun = $false +$shortcutFileByteCap = 64 * 1024 +$ownershipRunId = [IO.Path]::GetFileNameWithoutExtension($ownershipManifestPath).Substring( + 'propr-installed-app-ownership-'.Length) +$initialManifestItem = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop +if (($initialManifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $initialManifestItem.Length -le 0 -or $initialManifestItem.Length -gt 65536) { + throw 'initial ownership manifest metadata is invalid' +} +$initialManifestBytes = [byte[]]::new([int]$initialManifestItem.Length) +$initialManifestStream = [IO.File]::Open( + $ownershipManifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read +) +try { + $initialManifestOffset = 0 + while ($initialManifestOffset -lt $initialManifestBytes.Length) { + $read = $initialManifestStream.Read( + $initialManifestBytes, + $initialManifestOffset, + $initialManifestBytes.Length - $initialManifestOffset + ) + if ($read -eq 0) { throw 'initial ownership manifest read was incomplete' } + $initialManifestOffset += $read + } + if ($initialManifestStream.ReadByte() -ne -1) { + throw 'initial ownership manifest changed during read' + } +} finally { + $initialManifestStream.Dispose() +} +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$initialOwnershipState = ConvertFrom-Json ` + -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop +$initialManifestKeys = @($initialOwnershipState.PSObject.Properties | ForEach-Object { $_.Name }) +$expectedInitialManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' +) +if ($initialManifestKeys.Count -ne $expectedInitialManifestKeys.Count -or + @($expectedInitialManifestKeys | Where-Object { + $initialManifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $initialOwnershipState.SchemaVersion -ne 3 -or + [string]$initialOwnershipState.ManifestType -cne + 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$initialOwnershipState.State -cne 'ACTIVE' -or + [string]$initialOwnershipState.RunId -cne $ownershipRunId -or + ![string]::Equals( + [IO.Path]::GetFullPath([string]$initialOwnershipState.InstallerPath), + $installerPath, + [StringComparison]::OrdinalIgnoreCase + ) -or + [string]$initialOwnershipState.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$initialOwnershipState.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$initialOwnershipState.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $initialOwnershipState.Fixture -isnot [bool] -or $initialOwnershipState.Fixture -or + $null -ne $initialOwnershipState.FixtureRoot -or + $initialOwnershipState.BaselineClean -isnot [bool] -or + $initialOwnershipState.BaselineClean -or + $initialOwnershipState.InstallAttempted -isnot [bool] -or + $initialOwnershipState.InstallAttempted -or + [string]$initialOwnershipState.MsiTransactionState -cne 'NONE' -or + @($initialOwnershipState.Directories).Count -ne 0 -or + @($initialOwnershipState.Files).Count -ne 0 -or + @($initialOwnershipState.RegistryKeys).Count -ne 0 -or + @($initialOwnershipState.RegistryValues).Count -ne 0 -or + @($initialOwnershipState.Users).Count -ne 0 -or + @($initialOwnershipState.Profiles).Count -ne 0) { + throw 'initial ownership manifest identity is invalid' +} +$ownershipToken = [Guid]::NewGuid().ToString('N') +$ownershipState = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $ownershipRunId + CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks + ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks + InstallerPath = $installerPath + InstallerEntryIdentity = [string]$initialOwnershipState.InstallerEntryIdentity + InstallerSha256 = [string]$initialOwnershipState.InstallerSha256 + InstallerProductCode = [string]$initialOwnershipState.InstallerProductCode + Fixture = $false + FixtureRoot = $null + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() +} + +function Write-OwnershipManifest { + $temporaryManifest = "$ownershipManifestPath.new" + $bytes = [Text.Encoding]::UTF8.GetBytes( + ($ownershipState | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryManifest, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) +} + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + if (!(Test-SamePath (Split-Path -Parent $canonicalLocalPath) $profilesDirectory) -or + (Split-Path -Leaf $canonicalLocalPath) -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath +} + +function Write-DurableOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } + + public static string Read(string path) { return ReadEntry(path, true); } +} +'@ + +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt $shortcutFileByteCap) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority { + if (Test-Path -LiteralPath $installRoot) { + if (!$installRootCreatedByRun -or + [string]$installRootOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$installRootOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity -or + (Get-FileSystemTreeIdentity $installRoot) -cne $installRootOwnedTreeIdentity) { + throw 'refusing to uninstall over an install tree with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + if (!$startMenuShortcutFolderCreatedByRun -or + [string]$shortcutFolderOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$shortcutFolderOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity -or + (Get-FileSystemTreeIdentity $startMenuShortcutFolder) -cne + $shortcutFolderOwnedTreeIdentity) { + throw 'refusing to uninstall over a shortcut folder with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcut) { + if (!$startMenuShortcutCreatedByRun -or + [string]$shortcutOwnedIdentity -notmatch '^[a-f0-9]{64}$' -or + [string]$shortcutOwnedEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity -or + (Get-FileSystemEntryIdentity $startMenuShortcut $false) -cne + $shortcutOwnedEntryIdentity) { + throw 'refusing to uninstall over a shortcut with mismatched ownership identity' + } + } +} + +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority { + $matches = $false + try { + $matches = (Test-SamePath $installerPath ([string]$ownershipState.InstallerPath)) -and + [string]$ownershipState.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$ownershipState.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$ownershipState.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + (Get-FileSystemEntryIdentity $installerPath $false) -ceq + [string]$ownershipState.InstallerEntryIdentity -and + (Get-InstallerSha256 $installerPath) -ceq [string]$ownershipState.InstallerSha256 + } catch {} + if (!$matches) { + $script:installerArtifactAuthorityValid = $false + throw 'installer artifact no longer matches durable authority' + } +} + +function Assert-MsiProductIsUnregistered([string]$ProductCode) { + $installerCom = $null + try { + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-ExactCleanMsiBaselineAfterRollback { + foreach ($path in @( + $installRoot, + $startMenuShortcutFolder, + $protocolRegistryPath, + $appPathsRegistryPath + )) { + if (Test-Path -LiteralPath $path) { + throw 'Windows Installer rollback did not restore the exact clean baseline' + } + } + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $valueMatches = if ($hkcuInstalledValueExistedBeforeInstall) { + $current.Exists -and $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + } else { !$current.Exists } + $keyMatches = (Test-Path -LiteralPath $hkcuDesktopRegistryPath) -eq + $hkcuDesktopKeyExistedBeforeInstall + if (!$valueMatches -or !$keyMatches) { + throw 'Windows Installer rollback did not restore the exact current-user baseline' + } + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) +} + +function Wait-ExactCleanMsiBaselineAfterRollback { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + try { + Assert-ExactCleanMsiBaselineAfterRollback + return + } catch { + if ($stopwatch.ElapsedMilliseconds -ge $msiCaptureRollbackGraceMilliseconds) { + throw 'Windows Installer rollback clean-baseline grace expired' + } + } + Start-Sleep -Milliseconds 100 + } while ($true) +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + +function Restore-HkcuInstalledBaseline { + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $matchesBaseline = $hkcuInstalledValueExistedBeforeInstall -and $current.Exists -and + $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + $matchesOwnedIdentity = $current.Exists -and $hkcuInstalledOwnedKind -and + $hkcuInstalledOwnedData -and $current.Kind -ceq $hkcuInstalledOwnedKind -and + $current.Data -ceq $hkcuInstalledOwnedData + if ($current.Exists -and !$matchesBaseline -and !$matchesOwnedIdentity) { + throw 'refusing to replace a conflicting current-user installed value' + } + + if ($hkcuInstalledValueExistedBeforeInstall) { + if (!(Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + [void](New-Item -Path $hkcuDesktopRegistryPath -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse( + [Microsoft.Win32.RegistryValueKind], $hkcuInstalledBaselineKind, $false) + $bytes = [Convert]::FromBase64String($hkcuInstalledBaselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop).SetValue( + $hkcuInstalledValueName, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $hkcuDesktopRegistryPath ` + -Name $hkcuInstalledValueName -Force -ErrorAction Stop + } + + if ($hkcuDesktopKeyCreatedByRun -and (Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + $key = Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $hkcuDesktopRegistryPath -Force -ErrorAction Stop + } + } +} + +$hkcuInstalledSnapshot = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName +$hkcuInstalledValueExistedBeforeInstall = [bool]$hkcuInstalledSnapshot.Exists +$hkcuInstalledBaselineKind = $hkcuInstalledSnapshot.Kind +$hkcuInstalledBaselineData = $hkcuInstalledSnapshot.Data +$ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $false + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false +}) + +Write-OwnershipManifest + +function Write-WatchdogMarker( + [ValidateSet('INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')] + [string]$Stage, + [ValidateSet( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK' + )][string]$Substage, + [int]$TimeoutMilliseconds, + [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status +) { + $deadline = if ($Status -eq 'BEGIN') { + [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds).Ticks + } else { + [DateTime]::UtcNow.AddMilliseconds($markerTransitionTimeoutMilliseconds).Ticks + } + $record = '{0}|{1}|{2}|{3}' -f $deadline, $Stage, $Substage, $Status + $temporaryMarker = "$watchdogMarkerPath.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = $null + try { + $stream = [IO.FileStream]::new( + $temporaryMarker, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + [IO.File]::Move($temporaryMarker, $watchdogMarkerPath, $true) + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:{0}:{1}:{2}' -f ` + $Stage, $Substage, $Status) + [Console]::Out.Flush() +} + +function Invoke-BoundedExternalOperation( + [string]$Stage, + [string]$Substage, + [int]$TimeoutMilliseconds, + [scriptblock]$Operation +) { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'BEGIN' + try { + $result = & $Operation + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'COMPLETE' + return $result + } catch { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'FAILED' + throw + } +} + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRWindowsLogon +{ + public const int LOGON32_LOGON_NETWORK = 3; + public const int LOGON32_PROVIDER_DEFAULT = 0; + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, + ExactSpelling = true, EntryPoint = "LogonUserW")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool LogonUserW( + string userName, + string domain, + IntPtr password, + int logonType, + int logonProvider, + out SafeAccessTokenHandle token); +} +'@ + +Write-WatchdogMarker 'INITIALIZATION' 'PATHS' $bootstrapWatchdogTimeoutMilliseconds 'COMPLETE' +Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'BEGIN' +try { + if ($installRootExistedBeforeInstall -or $protocolExistedBeforeInstall -or + $appPathsExistedBeforeInstall -or + $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { + throw 'installed-app harness requires an unowned clean machine baseline' + } + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) + $ownershipState.BaselineClean = $true + Write-OwnershipManifest + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' +} catch { + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'FAILED' + throw +} + +function Write-Stage( + [ValidateSet('INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')][string]$Stage, + [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status +) { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}' -f $Stage, $Status) + [Console]::Out.Flush() +} + +function Write-CleanupSubstage( + [ValidateSet('UNINSTALL','CLEANUP')][string]$Scope, + [ValidateSet( + 'MSI_UNINSTALL', + 'INSTALL_TREE', + 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', + 'SHORTCUT_FILE', + 'SHORTCUT_FOLDER', + 'ORDINARY_USER_ABSENCE_PROBE', + 'SMOKE_DATA', + 'PROFILE', + 'USER', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK', + 'FINAL_AGGREGATION' + )][string]$Substage, + [ValidateSet('BEGIN','COMPLETE','FAILED','SKIPPED')][string]$Status +) { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}:{2}' -f $Scope, $Substage, $Status) + [Console]::Out.Flush() +} + +function Stop-SpawnedProcessTree( + [Diagnostics.Process]$Process, + [string]$Operation +) { + try { + if (!$Process.HasExited) { + $Process.Kill($true) + if (!$Process.WaitForExit($terminationTimeoutMilliseconds)) { + throw 'termination timeout' + } + } + } catch { + throw "$Operation process-tree termination failed" + } +} + +function Start-DirectProcess([hashtable]$StartParameters, [string]$Operation) { + try { + return Start-Process @StartParameters -PassThru -ErrorAction Stop + } catch { + throw "$Operation could not start" + } +} + +function Start-AlternateCredentialApplication( + [string]$FilePath, + [string[]]$Arguments, + [Management.Automation.PSCredential]$Credential, + [string]$Domain, + [string]$UserName, + [string]$WorkingDirectory, + [string]$SmokeDirectory, + [string]$WindowsDirectory, + [string]$StandardOutputPath, + [string]$StandardErrorPath, + [string]$Operation +) { + $process = $null + $standardOutputStream = $null + $standardErrorStream = $null + $standardOutputCopy = $null + $standardErrorCopy = $null + $started = $false + try { + if (![IO.Path]::IsPathRooted($FilePath) -or ![IO.Path]::IsPathRooted($WorkingDirectory)) { + throw 'alternate-credential application launch requires absolute paths' + } + + $fullSmokeDirectory = [IO.Path]::GetFullPath($SmokeDirectory) + if ((Split-Path -Leaf $fullSmokeDirectory) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals( + (Split-Path -Parent $fullSmokeDirectory), + $machineTemp, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'alternate-credential application launch requires the verified smoke directory' + } + $smokeDirectoryItem = Get-Item -LiteralPath $fullSmokeDirectory -Force -ErrorAction Stop + if (!$smokeDirectoryItem.PSIsContainer -or + ($smokeDirectoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'alternate-credential application launch requires the verified smoke directory' + } + $smokeDirectoryAcl = Get-Acl -LiteralPath $fullSmokeDirectory + $smokeDirectoryRules = @($smokeDirectoryAcl.Access) + $smokeDirectorySids = @($smokeDirectoryRules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + if (!$smokeDirectoryAcl.AreAccessRulesProtected -or $smokeDirectoryRules.Count -ne 3) { + throw 'alternate-credential application launch requires the verified smoke directory' + } + + $profileDirectory = Join-Path $fullSmokeDirectory 'profile' + $appDataDirectory = Join-Path $profileDirectory 'AppData' + $roamingAppDataDirectory = Join-Path $appDataDirectory 'Roaming' + $localAppDataDirectory = Join-Path $appDataDirectory 'Local' + $temporaryDirectory = Join-Path $fullSmokeDirectory 'temp' + foreach ($directory in @( + $profileDirectory, + $appDataDirectory, + $roamingAppDataDirectory, + $localAppDataDirectory, + $temporaryDirectory + )) { + New-Item -ItemType Directory -Path $directory -ErrorAction Stop | Out-Null + $directoryItem = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + if (!$directoryItem.PSIsContainer -or + ($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'alternate-credential child profile layout is invalid' + } + $directoryAcl = Get-Acl -LiteralPath $directory + $directoryRules = @($directoryAcl.Access) + $directorySids = @($directoryRules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $invalidDirectoryRules = @($directoryRules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne + [Security.AccessControl.FileSystemRights]::FullControl + }) + if ($directoryAcl.AreAccessRulesProtected -or $directoryRules.Count -ne 3 -or + $invalidDirectoryRules.Count -ne 0 -or + (Compare-Object $smokeDirectorySids $directorySids)) { + throw 'alternate-credential child profile ACL is not inherited from the smoke directory' + } + } + + # This is the complete child environment. Never add parent/CI variables here. + $childEnvironment = [ordered]@{ + 'APPDATA' = $roamingAppDataDirectory + 'LOCALAPPDATA' = $localAppDataDirectory + 'PROPR_DESKTOP_SMOKE_TEST' = '1' + 'SystemRoot' = $WindowsDirectory + 'TEMP' = $temporaryDirectory + 'TMP' = $temporaryDirectory + 'USERPROFILE' = $profileDirectory + } + + $standardOutputStream = [IO.FileStream]::new( + $StandardOutputPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::Asynchronous + ) + $standardErrorStream = [IO.FileStream]::new( + $StandardErrorPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::Asynchronous + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.Environment.Clear() + $startInfo.FileName = $FilePath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.WorkingDirectory = $WorkingDirectory + $startInfo.UserName = $UserName + $startInfo.Domain = $Domain + $startInfo.Password = $Credential.Password + $startInfo.LoadUserProfile = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { + $startInfo.ArgumentList.Add($argument) + } + foreach ($entry in $childEnvironment.GetEnumerator()) { + $startInfo.Environment.Add([string]$entry.Key, [string]$entry.Value) + } + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (!$process.Start()) { throw 'alternate-credential application process did not start' } + $started = $true + $standardOutputCopy = $process.StandardOutput.BaseStream.CopyToAsync($standardOutputStream) + $standardErrorCopy = $process.StandardError.BaseStream.CopyToAsync($standardErrorStream) + return [PSCustomObject]@{ + Process = $process + StandardOutputStream = $standardOutputStream + StandardErrorStream = $standardErrorStream + StandardOutputCopy = $standardOutputCopy + StandardErrorCopy = $standardErrorCopy + } + } catch { + if ($started -and $null -ne $process) { + try { Stop-SpawnedProcessTree $process $Operation } catch {} + } + foreach ($stream in @($standardOutputStream, $standardErrorStream)) { + if ($null -ne $stream) { $stream.Dispose() } + } + foreach ($task in @($standardOutputCopy, $standardErrorCopy)) { + if ($null -ne $task -and $task.IsCompleted) { $task.Dispose() } + } + if ($null -ne $process) { $process.Dispose() } + throw "$Operation could not start" + } +} + +function Close-RedirectedApplicationStreams([PSCustomObject]$Launch, [string]$Operation) { + $streamFailure = $false + try { + $copyTasks = [Threading.Tasks.Task[]]@($Launch.StandardOutputCopy, $Launch.StandardErrorCopy) + if (![Threading.Tasks.Task]::WaitAll($copyTasks, $redirectedStreamDrainTimeoutMilliseconds)) { + $streamFailure = $true + } elseif (@($copyTasks | Where-Object { $_.IsCanceled -or $_.IsFaulted }).Count -ne 0) { + $streamFailure = $true + } + } catch { + $streamFailure = $true + } finally { + $Launch.StandardOutputStream.Dispose() + $Launch.StandardErrorStream.Dispose() + foreach ($task in @($Launch.StandardOutputCopy, $Launch.StandardErrorCopy)) { + if ($task.IsCompleted) { $task.Dispose() } + } + } + if ($streamFailure) { throw "$Operation redirected-stream drain failed" } +} + +function Wait-BoundedProcess( + [Diagnostics.Process]$Process, + [int]$TimeoutMilliseconds, + [int[]]$AllowedExitCodes, + [string]$Operation +) { + try { + try { + $completed = $Process.WaitForExit($TimeoutMilliseconds) + } catch { + Stop-SpawnedProcessTree $Process $Operation + throw "$Operation bounded wait failed" + } + if (!$completed) { + Stop-SpawnedProcessTree $Process $Operation + throw "$Operation timed out" + } + + try { + $exitCode = $Process.ExitCode + } catch { + Stop-SpawnedProcessTree $Process $Operation + throw "$Operation exit status is unavailable" + } + if ($exitCode -notin $AllowedExitCodes) { + Stop-SpawnedProcessTree $Process $Operation + throw "$Operation exited $exitCode" + } + return $exitCode + } catch { + if (!$Process.HasExited) { Stop-SpawnedProcessTree $Process $Operation } + throw + } +} + +function Invoke-BoundedProcess( + [hashtable]$StartParameters, + [int]$TimeoutMilliseconds, + [int[]]$AllowedExitCodes, + [string]$Operation +) { + $process = Start-DirectProcess $StartParameters $Operation + try { + return Wait-BoundedProcess $process $TimeoutMilliseconds $AllowedExitCodes $Operation + } finally { + $process.Dispose() + } +} + +function Invoke-Msi([string[]]$Arguments, [string]$Operation) { + [void](Invoke-BoundedProcess ` + -StartParameters @{ FilePath = 'msiexec.exe'; ArgumentList = $Arguments } ` + -TimeoutMilliseconds $msiTimeoutMilliseconds ` + -AllowedExitCodes @(0,3010) ` + -Operation $Operation) +} + +function Test-StartMenuShortcutAsOrdinaryUser( + [Management.Automation.PSCredential]$Credential, + [string]$Domain, + [string]$UserName, + [Security.Principal.SecurityIdentifier]$UserSid, + [string]$ShortcutPath, + [bool]$ExpectedPresent +) { + $expectation = if ($ExpectedPresent) { 'PRESENT' } else { 'ABSENT' } + $failureCategory = $null + $passwordBuffer = [IntPtr]::Zero + [Microsoft.Win32.SafeHandles.SafeAccessTokenHandle]$token = $null + try { + $passwordBuffer = [Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode( + $Credential.Password + ) + if (![ProPRWindowsLogon]::LogonUserW( + $UserName, + $Domain, + $passwordBuffer, + [ProPRWindowsLogon]::LOGON32_LOGON_NETWORK, + [ProPRWindowsLogon]::LOGON32_PROVIDER_DEFAULT, + [ref]$token + )) { + $failureCategory = 'LOGON_FAILED' + } else { + [Security.Principal.WindowsIdentity]::RunImpersonated($token, [Action]{ + $identity = $null + $stream = $null + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($null -eq $identity.User -or !$identity.User.Equals($UserSid)) { + throw 'ordinary-user shortcut identity mismatch' + } + if ([string]::IsNullOrWhiteSpace($ShortcutPath) -or + ![IO.Path]::IsPathRooted($ShortcutPath)) { + throw 'ordinary-user shortcut path is invalid' + } + + $present = Test-Path -LiteralPath $ShortcutPath -ErrorAction Stop + if (!$ExpectedPresent -and !$present) { return } + if ($present -ne $ExpectedPresent) { + throw 'ordinary-user shortcut presence mismatch' + } + + $item = Get-Item -LiteralPath $ShortcutPath -Force -ErrorAction Stop + if (!($item -is [IO.FileInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt $shortcutFileByteCap) { + throw 'ordinary-user shortcut metadata is invalid' + } + $stream = [IO.File]::Open( + $ShortcutPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite + ) + if ($stream.Length -le 0 -or $stream.Length -gt $shortcutFileByteCap -or + $stream.ReadByte() -lt 0) { + throw 'ordinary-user shortcut read failed' + } + } finally { + if ($null -ne $stream) { $stream.Dispose() } + if ($null -ne $identity) { $identity.Dispose() } + } + }) + } + } catch { + if ($null -eq $failureCategory) { $failureCategory = 'ACCESS_CHECK_FAILED' } + } finally { + if ($passwordBuffer -ne [IntPtr]::Zero) { + try { + [Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($passwordBuffer) + } catch { + if ($null -eq $failureCategory) { $failureCategory = 'CLEANUP_FAILED' } + } + } + if ($null -ne $token) { + try { $token.Dispose() } catch { + if ($null -eq $failureCategory) { $failureCategory = 'CLEANUP_FAILED' } + } + } + } + + if ($null -eq $failureCategory) { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:SHORTCUT_PROBE:{0}:SUCCESS' -f $expectation) + return + } + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:SHORTCUT_PROBE:{0}:{1}' -f $expectation, $failureCategory) + throw 'ordinary-user shortcut probe failed' +} + +function New-SmokeUserDataDirectory( + [Security.Principal.SecurityIdentifier]$UserSid, + [string]$Path +) { + $path = [IO.Path]::GetFullPath($Path) + if ((Split-Path -Leaf $path) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals( + (Split-Path -Parent $path), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { + throw 'smoke user-data directory path is invalid' + } + $createdByRun = $false + try { + if (Test-Path -LiteralPath $path) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + New-Item -ItemType Directory -Path $path -ErrorAction Stop | Out-Null + $createdByRun = $true + $administratorsSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') + $systemSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administratorsSid) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $propagation = [Security.AccessControl.PropagationFlags]::None + foreach ($sid in @($UserSid, $systemSid, $administratorsSid)) { + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + $propagation, + [Security.AccessControl.AccessControlType]::Allow + ) + $acl.AddAccessRule($rule) | Out-Null + } + Set-Acl -LiteralPath $path -AclObject $acl + + $expectedSids = @($UserSid.Value, $systemSid.Value, $administratorsSid.Value) | Sort-Object -Unique + $appliedAcl = Get-Acl -LiteralPath $path + $actualRules = @($appliedAcl.Access) + $actualSids = @($actualRules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $invalidRules = @($actualRules | Where-Object { + $_.IsInherited -or $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne + [Security.AccessControl.FileSystemRights]::FullControl -or + $_.InheritanceFlags -ne $inheritance -or $_.PropagationFlags -ne $propagation + }) + $appliedOwnerSid = $appliedAcl.GetOwner( + [Security.Principal.SecurityIdentifier]).Value + if ($appliedOwnerSid -cne $administratorsSid.Value -or + !$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or + $invalidRules.Count -ne 0 -or (Compare-Object $expectedSids $actualSids)) { + throw 'smoke user-data directory ACL is not restricted to the test user, SYSTEM, and Administrators' + } + return $path + } catch { + if ($createdByRun) { + try { + if ((Test-Path -LiteralPath $path -PathType Container) -and + @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } catch {} + } + throw + } +} + +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $fullPath = [IO.Path]::GetFullPath([string]$Record.Path) + if ((Split-Path -Leaf $fullPath) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals((Split-Path -Parent $fullPath), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { + throw 'smoke user-data cleanup scope is invalid' + } + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $fullPath '.propr-installed-app-owner' + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead + } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() + } + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Promote-SmokeOwnershipRecord($Record) { + if ($null -eq $testUserSid -or + [string]$Record.UserSid -cne [string]$testUserSid.Value) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-OwnershipManifest + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + [string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $true +} + +function Remove-SmokeUserDataDirectory($Record) { + if ($null -eq $Record -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' + } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } + } + } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' + } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop +} + +function Get-SmokeEventEvidence( + [string]$Path, + [Security.Principal.SecurityIdentifier]$UserSid +) { + [SmokeEvidenceInspectionPhase]$inspectionPhase = [SmokeEvidenceInspectionPhase]::DIRECTORY + try { + $fullPath = [IO.Path]::GetFullPath($Path) + if ((Split-Path -Leaf $fullPath) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals((Split-Path -Parent $fullPath), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { + throw 'invalid smoke evidence directory' + } + $directory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (!$directory.PSIsContainer -or + ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'invalid smoke evidence directory' + } + + $inspectionPhase = [SmokeEvidenceInspectionPhase]::ACL + $administratorsSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') + $systemSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') + $expectedSids = @($UserSid.Value, $systemSid.Value, $administratorsSid.Value) | Sort-Object -Unique + $appliedAcl = Get-Acl -LiteralPath $fullPath + $actualRules = @($appliedAcl.Access) + $actualSids = @($actualRules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $invalidRules = @($actualRules | Where-Object { + $_.IsInherited -or $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne + [Security.AccessControl.FileSystemRights]::FullControl + }) + if (!$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or + $invalidRules.Count -ne 0 -or (Compare-Object $expectedSids $actualSids)) { + throw 'invalid smoke evidence directory' + } + + $events = @{} + foreach ($eventName in $smokeEventCodes.Keys) { $events[$eventName] = $false } + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + foreach ($fileName in $smokeEvidenceFileNames) { + $inspectionPhase = [SmokeEvidenceInspectionPhase]::FILE_METADATA + $filePath = Join-Path $fullPath $fileName + try { + $item = Get-Item -LiteralPath $filePath -Force -ErrorAction Stop + } catch [Management.Automation.ItemNotFoundException] { + continue + } + if (!($item -is [IO.FileInfo]) -or $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'invalid smoke evidence file' + } + + $bytesToRead = [Math]::Min([int64]$item.Length, [int64]$smokeEvidenceFileByteCap) + $bytes = New-Object byte[] ([int]$bytesToRead) + $inspectionPhase = [SmokeEvidenceInspectionPhase]::FILE_OPEN + $stream = $null + try { + $openRetryStopwatch = [Diagnostics.Stopwatch]::StartNew() + $openAttempt = 0 + while ($null -eq $stream) { + if ($openAttempt -gt 0 -and + $openRetryStopwatch.ElapsedMilliseconds -ge $smokeEvidenceOpenRetryDeadlineMilliseconds) { + throw 'smoke evidence file open retry deadline expired' + } + $openAttempt += 1 + try { + $stream = [IO.FileStream]::new( + [string]$filePath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::SequentialScan + ) + } catch [IO.IOException] { + $nativeErrorCode = $_.Exception.HResult -band 0xffff + if ($nativeErrorCode -notin @(32, 33) -or + $openRetryStopwatch.ElapsedMilliseconds -ge $smokeEvidenceOpenRetryDeadlineMilliseconds) { + throw + } + $remainingMilliseconds = $smokeEvidenceOpenRetryDeadlineMilliseconds - + $openRetryStopwatch.ElapsedMilliseconds + $retryDelayMilliseconds = [Math]::Min( + $smokeEvidenceOpenRetryDelayMilliseconds, + $remainingMilliseconds + ) + if ($retryDelayMilliseconds -le 0) { throw } + Start-Sleep -Milliseconds $retryDelayMilliseconds + } + } + $openRetryStopwatch.Stop() + $inspectionPhase = [SmokeEvidenceInspectionPhase]::FILE_READ + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { break } + $offset += $read + } + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + $inspectionPhase = [SmokeEvidenceInspectionPhase]::EVENT_PARSE + if ($offset -eq 0) { continue } + + try { + $text = $strictUtf8.GetString($bytes, 0, $offset) + } catch { + continue + } + foreach ($line in ($text -split "`r?`n")) { + try { + $record = ConvertFrom-Json -InputObject $line -ErrorAction Stop + if ($null -eq $record -or $record -isnot [PSCustomObject]) { continue } + $eventProperty = $record.PSObject.Properties['event'] + if ($null -eq $eventProperty -or $eventProperty.Name -cne 'event' -or + $eventProperty.Value -isnot [string]) { + continue + } + if ($fileName -ceq 'application.smoke-evidence.jsonl' -and + @($record.PSObject.Properties).Count -ne 1) { + continue + } + $eventName = $eventProperty.Value + if (!$smokeEventCodes.Contains($eventName)) { continue } + $events[$eventName] = $true + } catch { + continue + } + } + } + + $inspectionPhase = [SmokeEvidenceInspectionPhase]::SUMMARY + $summary = @() + foreach ($eventName in $smokeEventCodes.Keys) { + $state = if ($events[$eventName]) { 'PRESENT' } else { 'ABSENT' } + $summary += ('{0}={1}' -f $smokeEventCodes[$eventName], $state) + } + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:EVIDENCE:{0}' -f ($summary -join ',')) + return $events + } catch { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:EVIDENCE_INSPECTION_FAILED:{0}' -f $inspectionPhase) + throw 'smoke evidence inspection failed' + } +} + +try { + Write-Stage 'INSTALL' 'BEGIN' + try { + $installAttempted = $true + $ownershipState.InstallAttempted = $true + $ownershipState.MsiTransactionState = 'PENDING' + # PENDING is a recovery signal only. It never authorizes MSI uninstall or + # path-based reconstruction/deletion; only a durable transaction receipt can. + $ownershipState.Directories = @( + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $null; TreeIdentity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null; Identity = $null; TreeIdentity = $null + Provisional = $true + } + ) + $ownershipState.Files = @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $null; EntryIdentity = $null; Provisional = $true + }) + $ownershipState.RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + } + ) + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $true + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false + }) + Write-OwnershipManifest + $msiTransactionFailure = $null + try { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'MSI_INSTALL' ` + -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` + -Operation { + Assert-InstallerArtifactAuthority + Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + $script:msiInstallCompleted = $true + } + } catch { + $msiTransactionFailure = $_ + } + if ($null -ne $msiTransactionFailure) { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + Wait-ExactCleanMsiBaselineAfterRollback + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName; Owned = $false; Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null; IdentityValueData = $null + KeyCreatedByRun = $false + }) + $ownershipState.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-OwnershipManifest + } + throw $msiTransactionFailure + } else { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + if (!$script:msiInstallCompleted) { + throw 'MSI transaction commit status is unavailable' + } + $script:installRootCreatedByRun = + !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) + $script:protocolCreatedByRun = + !$protocolExistedBeforeInstall -and + (Test-Path -LiteralPath $protocolRegistryPath) + $script:appPathsCreatedByRun = + !$appPathsExistedBeforeInstall -and (Test-Path -LiteralPath $appPathsRegistryPath) + $script:hkcuDesktopKeyCreatedByRun = + !$hkcuDesktopKeyExistedBeforeInstall -and + (Test-Path -LiteralPath $hkcuDesktopRegistryPath) + $script:startMenuShortcutCreatedByRun = + !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) + $script:startMenuShortcutFolderCreatedByRun = + !$startMenuShortcutFolderExistedBeforeInstall -and + (Test-Path -LiteralPath $startMenuShortcutFolder) + if (!$script:installRootCreatedByRun -or !$script:protocolCreatedByRun -or + !$script:appPathsCreatedByRun -or !$script:startMenuShortcutCreatedByRun -or + !$script:startMenuShortcutFolderCreatedByRun) { + throw 'MSI commit did not create every canonical managed resource' + } + $ownedDirectories = @() + if ($script:installRootCreatedByRun) { + $script:installRootOwnedIdentity = Get-DirectoryIdentity $installRoot + $script:installRootOwnedTreeIdentity = Get-FileSystemTreeIdentity $installRoot + if (!$script:installRootOwnedIdentity -or !$script:installRootOwnedTreeIdentity) { + throw 'installed tree identity could not be captured' + } + $ownedDirectories += [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $script:installRootOwnedIdentity + TreeIdentity = $script:installRootOwnedTreeIdentity + Provisional = $false + } + } + if ($script:startMenuShortcutFolderCreatedByRun) { + $script:shortcutFolderOwnedIdentity = Get-DirectoryIdentity $startMenuShortcutFolder + $script:shortcutFolderOwnedTreeIdentity = + Get-FileSystemTreeIdentity $startMenuShortcutFolder + if (!$script:shortcutFolderOwnedIdentity -or + !$script:shortcutFolderOwnedTreeIdentity) { + throw 'installed shortcut folder identity could not be captured' + } + $ownedDirectories += [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null; Identity = $script:shortcutFolderOwnedIdentity + TreeIdentity = $script:shortcutFolderOwnedTreeIdentity + Provisional = $false + } + } + $ownershipState.Directories = $ownedDirectories + $ownershipState.Files = if ($script:startMenuShortcutCreatedByRun) { + $script:shortcutOwnedIdentity = Get-FileIdentity $startMenuShortcut + $script:shortcutOwnedEntryIdentity = + Get-FileSystemEntryIdentity $startMenuShortcut $false + if (!$script:shortcutOwnedIdentity -or !$script:shortcutOwnedEntryIdentity) { + throw 'installed shortcut identity could not be captured' + } + @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $script:shortcutOwnedIdentity + EntryIdentity = $script:shortcutOwnedEntryIdentity + Provisional = $false + }) + } else { @() } + $ownedRegistryKeys = @() + if ($script:protocolCreatedByRun) { + $script:protocolOwnedIdentity = Get-RegistryTreeIdentity $protocolRegistryPath + if ([string]$script:protocolOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed protocol identity could not be captured' + } + $ownedRegistryKeys += [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $script:protocolOwnedIdentity + Provisional = $false + } + } + if ($script:appPathsCreatedByRun) { + $script:appPathsOwnedIdentity = Get-RegistryTreeIdentity $appPathsRegistryPath + if ([string]$script:appPathsOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed App Paths identity could not be captured' + } + $ownedRegistryKeys += [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $script:appPathsOwnedIdentity + Provisional = $false + } + } + $ownershipState.RegistryKeys = $ownedRegistryKeys + $ownedHkcuInstalled = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName + if (!$ownedHkcuInstalled.Exists) { + throw 'installed current-user value identity could not be captured' + } + $script:hkcuInstalledOwnedKind = $ownedHkcuInstalled.Kind + $script:hkcuInstalledOwnedData = $ownedHkcuInstalled.Data + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $script:hkcuInstalledOwnedKind + IdentityValueData = $script:hkcuInstalledOwnedData + KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun + }) + $ownershipState.MsiTransactionState = 'COMMITTED' + Write-OwnershipManifest + } + } + Write-Stage 'INSTALL' 'COMPLETE' + } catch { + Write-Stage 'INSTALL' 'FAILED' + throw + } + + Write-Stage 'VALIDATION' 'BEGIN' + try { + Invoke-BoundedExternalOperation 'VALIDATION' 'INSTALL_TREE_SCAN' ` + $recursiveOperationTimeoutMilliseconds { + if (!(Test-Path -LiteralPath $application -PathType Leaf)) { + throw 'machine installer did not install the canonical application' + } + $forbidden = @(Get-ChildItem -LiteralPath $installRoot -Recurse -Force | Where-Object { + $_.Name -match '^propr-windows-(authority|launcher|bootstrap)' -or + $_.Name -in @('windows-authority', 'windows-update-authority') + }) + if ($forbidden.Count -ne 0) { + throw 'installed MVP contains a deferred Windows update authority resource' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'APPLICATION_IMAGE' ` + $externalOperationTimeoutMilliseconds { + $image = New-Object byte[] 4096 + $stream = [IO.File]::OpenRead($application) + try { $imageLength = $stream.Read($image, 0, $image.Length) } finally { $stream.Dispose() } + $pe = if ($imageLength -ge 64) { [BitConverter]::ToUInt32($image, 0x3c) } else { 0 } + $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image, 0) -ne 0x5a4d -or + $pe + 6 -gt $imageLength -or + [Text.Encoding]::ASCII.GetString($image, [int]$pe, 4) -cne "PE`0`0" -or + [BitConverter]::ToUInt16($image, [int]$pe + 4) -ne $expectedMachine) { + throw 'installed application architecture does not match the matrix target' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'PROTOCOL_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $protocolCommand = (Get-Item -LiteralPath ` + "$protocolRegistryPath\shell\open\command").GetValue('') + if ($protocolCommand -cne "`"$application`" `"%1`"") { + throw 'machine installer did not register canonical ProPR Connect protocol discovery' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'APP_PATH_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $appPathApplication = (Get-Item -LiteralPath $appPathsRegistryPath).GetValue('') + if ($appPathApplication -cne $application) { + throw 'machine installer did not register canonical executable discovery' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'HKCU_INSTALLED_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'machine installer did not author the current-user installed value' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'SHORTCUT_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + if (!($shortcutItem -is [IO.FileInfo]) -or + ($shortcutItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $shortcutItem.Length -le 0) { + throw 'machine installer did not create the common Start Menu shortcut' + } + } + Write-Stage 'VALIDATION' 'COMPLETE' + } catch { + Write-Stage 'VALIDATION' 'FAILED' + throw + } + + Write-Stage 'USER_SETUP' 'BEGIN' + try { + Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_CREATE' ` + $externalOperationTimeoutMilliseconds { + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'refusing to replace a pre-existing local user' + } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $provisionalUser = [ordered]@{ + Name = $testUser + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userOwnershipMarker + } + $ownershipState.Users = @($provisionalUser) + Write-OwnershipManifest + New-LocalUser -Name $testUser -Password $password ` + -Description $userOwnershipMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $script:testUserCreatedByRun = $true + $script:testUserSid = (Get-LocalUser -Name $testUser -ErrorAction Stop).SID + $provisionalUser.Sid = $script:testUserSid.Value + $provisionalUser.Provisional = $false + Write-OwnershipManifest + } + $testUserSid = Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_SID' ` + $externalOperationTimeoutMilliseconds { + $script:testUserSid + } + $smokeUserDataCandidate = Join-Path ` + $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" + if (Test-Path -LiteralPath $smokeUserDataCandidate) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + $smokeOwnershipRecord = [ordered]@{ + Kind = 'SMOKE_DATA'; Path = $smokeUserDataCandidate + Owned = $true; Token = $ownershipToken; Identity = $null; Provisional = $true + UserSid = $testUserSid.Value + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $ownershipState.Directories = @($ownershipState.Directories) + @($smokeOwnershipRecord) + Write-OwnershipManifest + $smokeUserDataDirectory = Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SMOKE_DATA_CREATE' $recursiveOperationTimeoutMilliseconds { + $ownedSmokeDirectory = New-SmokeUserDataDirectory $testUserSid $smokeUserDataCandidate + Write-DurableOwnershipToken ` + -Path (Join-Path $ownedSmokeDirectory '.propr-installed-app-owner') ` + -Token $ownershipToken + if (!(Promote-SmokeOwnershipRecord $smokeOwnershipRecord)) { + throw 'smoke user-data ownership promotion did not complete' + } + $ownedSmokeDirectory + } + Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SHORTCUT_PRESENT_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $true + } + Write-Stage 'USER_SETUP' 'COMPLETE' + } catch { + Write-Stage 'USER_SETUP' 'FAILED' + throw 'ordinary-user setup failed' + } + + $arguments = @( + '--disable-gpu', + '--propr-smoke-test', + "--user-data-dir=$smokeUserDataDirectory", + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev' + ) + Write-Stage 'APP_LAUNCH' 'BEGIN' + $applicationLaunch = $null + try { + $applicationLaunch = Invoke-BoundedExternalOperation ` + 'APP_LAUNCH' 'ALTERNATE_USER_START' $alternateUserLaunchTimeoutMilliseconds { + Start-AlternateCredentialApplication ` + -FilePath $application ` + -Arguments $arguments ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -WorkingDirectory $env:ProgramFiles ` + -SmokeDirectory $smokeUserDataDirectory ` + -WindowsDirectory $windowsDirectory ` + -StandardOutputPath (Join-Path $smokeUserDataDirectory 'application.stdout.log') ` + -StandardErrorPath (Join-Path $smokeUserDataDirectory 'application.stderr.log') ` + -Operation 'ordinary-user installed application launch/render/profile smoke' + } + Write-Stage 'APP_LAUNCH' 'COMPLETE' + } catch { + Write-Stage 'APP_LAUNCH' 'FAILED' + throw + } + Write-Stage 'APP_EXIT' 'BEGIN' + try { + $waitFailure = $null + try { + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'APPLICATION_WAIT' ` + ($applicationTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + [void](Wait-BoundedProcess ` + -Process $applicationLaunch.Process ` + -TimeoutMilliseconds $applicationTimeoutMilliseconds ` + -AllowedExitCodes @(0) ` + -Operation 'ordinary-user installed application launch/render/profile smoke') + } + } catch { + $waitFailure = $_ + } finally { + try { + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } + } catch { + if ($null -eq $waitFailure) { $waitFailure = $_ } + } finally { + $applicationLaunch.Process.Dispose() + $applicationLaunch = $null + } + } + $smokeEvidence = Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'EVIDENCE_INSPECTION' $externalOperationTimeoutMilliseconds { + Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid + } + if ($null -ne $waitFailure) { throw $waitFailure } + if (@($requiredSmokeEvents | Where-Object { !$smokeEvidence[$_] }).Count -ne 0) { + throw 'SMOKE_REQUIRED_EVENTS_MISSING' + } + Write-Stage 'APP_EXIT' 'COMPLETE' + } catch { + Write-Stage 'APP_EXIT' 'FAILED' + throw + } finally { + if ($null -ne $applicationLaunch) { + try { + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } + } finally { + $applicationLaunch.Process.Dispose() + } + } + } +} catch { + $primaryFailure = $_ + throw +} finally { + $cleanupFailed = $false + $profileCleanupFailed = $false + if ($installerArtifactAuthorityValid) { + Assert-InstallerArtifactAuthority + if ($installAttempted -and + [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { + Write-Stage 'UNINSTALL' 'BEGIN' + $uninstallFailed = $false + + Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'MSI_UNINSTALL' ` + ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + Assert-MsiManagedFileSystemAuthority + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath) -and + (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity)) { + throw 'refusing to uninstall over protocol metadata with a mismatched ownership identity' + } + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath) -and + (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity)) { + throw 'refusing to uninstall over executable metadata with a mismatched ownership identity' + } + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'refusing to uninstall over current-user metadata with mismatched ownership' + } + Assert-InstallerArtifactAuthority + Invoke-Msi @( + '/x', [string]$ownershipState.InstallerProductCode, '/qn', '/norestart' + ) 'machine uninstall' + } + Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'FAILED' + $uninstallFailed = $true + } + if (!$installerArtifactAuthorityValid) { + throw 'installer authority changed before uninstall; ACTIVE recovery authority retained' + } + + Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'INSTALL_TREE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $installRoot) { + throw 'machine uninstall left the canonical install tree behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'PROTOCOL_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $protocolRegistryPath) { + throw 'machine uninstall left protocol discovery metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'APP_PATH_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $appPathsRegistryPath) { + throw 'machine uninstall left executable discovery metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'HKCU_INSTALLED_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if ((Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName).Exists) { + throw 'machine uninstall left current-user installed metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FILE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcut) { + throw 'machine uninstall left the common Start Menu shortcut behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FOLDER_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + throw 'machine uninstall left the common Start Menu folder behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'FAILED' + $uninstallFailed = $true + } + + if ($null -ne $testUserSid) { + Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_ABSENCE_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $false + } + Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'FAILED' + $uninstallFailed = $true + } + } else { + Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'SKIPPED' + } + + if ($uninstallFailed) { + Write-Stage 'UNINSTALL' 'FAILED' + $cleanupFailed = $true + } else { + Write-Stage 'UNINSTALL' 'COMPLETE' + } + } + + Write-Stage 'CLEANUP' 'BEGIN' + Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SMOKE_DATA_REMOVE' $recursiveOperationTimeoutMilliseconds { + Remove-SmokeUserDataDirectory $smokeOwnershipRecord + } + Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'BEGIN' + try { + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $profiles = @(Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_LOOKUP' $externalOperationTimeoutMilliseconds { + @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $testUserSid.Value + }) + }) + $ownedUserRecords = @($ownershipState.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + if ($ownedUserRecords.Count -ne 1) { + throw 'durable profile owner identity is missing' + } + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + if ($profiles.Count -ne 0 -and $ownedProfileRecords.Count -eq 0) { + $currentOwnedUser = Get-LocalUser -Name $testUser -ErrorAction Stop + if ([string]$currentOwnedUser.SID.Value -cne $testUserSid.Value -or + [string]$currentOwnedUser.Description -cne + [string]$ownedUserRecords[0].OwnershipMarker) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $testUserSid.Value) { + throw 'profile SID changed during ownership promotion' + } + $ownershipState.Profiles = @($ownershipState.Profiles) + @([ordered]@{ + Sid = $testUserSid.Value + LocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + Owned = $true + }) + } + Write-OwnershipManifest + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_REMOVE' $recursiveOperationTimeoutMilliseconds { + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $testUserSid.Value) { + throw 'refusing to remove a profile without exact durable SID and path ownership' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $matchingRecords = @() + foreach ($record in $ownedProfileRecords) { + if (!$record.Owned -or [string]$record.Sid -cne $testUserSid.Value) { + continue + } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $testUser + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { + throw 'refusing to remove a profile without exact durable SID and path ownership' + } + # Repeat every live/durable path check at the deletion boundary. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $testUser + if ([string]$profile.SID -cne $testUserSid.Value -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } + } + Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'FAILED' + $profileCleanupFailed = $true + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'USER' 'BEGIN' + try { + if ($profileCleanupFailed) { + throw 'profile cleanup failed; retaining authenticated local-user authority' + } + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $ownedUser = Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_LOOKUP' $externalOperationTimeoutMilliseconds { + Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue + } + if ($null -ne $ownedUser) { + if (!$ownedUser.SID.Equals($testUserSid)) { + throw 'refusing to remove a local user with a mismatched SID' + } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_REMOVE' $externalOperationTimeoutMilliseconds { + Remove-LocalUser -Name $testUser -ErrorAction Stop + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'test local user cleanup did not complete' + } + } + } + } + Write-CleanupSubstage 'CLEANUP' 'USER' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'USER' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'INSTALL_ROOT_FALLBACK' $recursiveOperationTimeoutMilliseconds { + if ($installRootCreatedByRun -and (Test-Path -LiteralPath $installRoot)) { + $ownedInstallRoot = Get-Item -LiteralPath $installRoot -Force -ErrorAction Stop + if (!$ownedInstallRoot.PSIsContainer -or + ($ownedInstallRoot.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'refusing to remove an invalid owned install tree' + } + if (!$installRootOwnedIdentity -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity) { + throw 'refusing to remove an install tree with a mismatched ownership identity' + } + if (@(Get-ChildItem -LiteralPath $installRoot -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned install tree is not empty' + } + Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop + } + } + Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROTOCOL_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath)) { + if (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity) { + throw 'refusing to remove protocol metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $protocolRegistryPath -Recurse -Force -ErrorAction Stop + } + } + Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'APP_PATH_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath)) { + if (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity) { + throw 'refusing to remove executable metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $appPathsRegistryPath -Recurse -Force -ErrorAction Stop + } + } + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' $externalOperationTimeoutMilliseconds { + Restore-HkcuInstalledBaseline + } + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' + $shortcutFallbackFailed = $false + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SHORTCUT_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { + if (!$shortcutOwnedIdentity -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity) { + throw 'refusing to remove a shortcut with a mismatched ownership identity' + } + Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + } + if ($startMenuShortcutFolderCreatedByRun -and + (Test-Path -LiteralPath $startMenuShortcutFolder)) { + $ownedShortcutFolder = Get-Item ` + -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + if (!$ownedShortcutFolder.PSIsContainer -or + ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned common Start Menu folder is invalid' + } + if (!$shortcutFolderOwnedIdentity -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity) { + throw 'refusing to remove a shortcut folder with a mismatched ownership identity' + } + if (@(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force ` + -ErrorAction Stop).Count -ne 0) { + throw 'owned common Start Menu folder is not empty' + } + Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + } + } + } catch { + $shortcutFallbackFailed = $true + } + if ($shortcutFallbackFailed) { + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'FAILED' + $cleanupFailed = $true + } else { + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'COMPLETE' + } + + Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'BEGIN' + if ($cleanupFailed) { + Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'FAILED' + Write-Stage 'CLEANUP' 'FAILED' + if ($null -eq $primaryFailure) { + throw 'installed Windows cleanup did not complete' + } + } else { + $ownershipState.State = 'EMPTY' + $ownershipState.BaselineClean = $false + $ownershipState.InstallAttempted = $false + $ownershipState.MsiTransactionState = 'NONE' + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @() + $ownershipState.Users = @() + $ownershipState.Profiles = @() + Write-OwnershipManifest + Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'COMPLETE' + Write-Stage 'CLEANUP' 'COMPLETE' + } + } +} diff --git a/apps/desktop/scripts/verify-darwin-image.mjs b/apps/desktop/scripts/verify-darwin-image.mjs new file mode 100644 index 000000000..eb9edf130 --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-image.mjs @@ -0,0 +1,162 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, lstat, mkdtemp, open, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCallback); +const HDIUTIL = '/usr/bin/hdiutil'; +const MAX_DMG_BYTES = 8 * 1024 * 1024 * 1024; +const TRANSIENT_VERIFY_FAILURE = /^hdiutil: verify failed - (?:Resource temporarily unavailable|Resource busy)\s*$/; + +const hashHeld = async (handle, size) => { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < Number(size)) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, Number(size) - position), position); + if (bytesRead <= 0) throw new Error('DMG bytes changed while held'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return hash.digest('hex'); +}; + +const acquireCanonicalImage = async path => { + const canonical = await realpath(path); + if (canonical !== resolve(path)) throw new Error('DMG verification requires a canonical image pathname'); + const pathStats = await lstat(path, { bigint: true }); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_DMG_BYTES)) { + throw new Error('DMG verification requires one nonempty regular image'); + } + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.dev !== pathStats.dev || stats.ino !== pathStats.ino + || stats.size !== pathStats.size || stats.nlink !== 1n) { + throw new Error('DMG identity changed before verification'); + } + return { path: canonical, handle, stats, sha256: await hashHeld(handle, stats.size) }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +}; + +const sameStats = (left, right) => left.dev === right.dev && left.ino === right.ino + && left.size === right.size && left.nlink === right.nlink; + +const reverifyHeld = async (image, label) => { + const stats = await image.handle.stat({ bigint: true }); + if (!sameStats(stats, image.stats) || await hashHeld(image.handle, stats.size) !== image.sha256) { + throw new Error(`DMG identity or checksum changed during ${label}`); + } + const pathStats = await lstat(image.path, { bigint: true }).catch(() => undefined); + if (!pathStats || !sameStats(pathStats, stats) || pathStats.isSymbolicLink()) { + throw new Error(`DMG pathname changed during ${label}`); + } +}; + +const createProtectedSnapshot = async source => { + const createdRoot = await mkdtemp(join(tmpdir(), 'propr-dmg-verify-')); + const root = await realpath(createdRoot); + const path = join(root, 'image.dmg'); + let writer; + let handle; + try { + writer = await open(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL + | fsConstants.O_NOFOLLOW, 0o600); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < Number(source.stats.size)) { + const { bytesRead } = await source.handle.read( + buffer, 0, Math.min(buffer.length, Number(source.stats.size) - position), position, + ); + if (bytesRead <= 0) throw new Error('DMG bytes changed while creating the verification lease'); + let written = 0; + while (written < bytesRead) { + const result = await writer.write(buffer, written, bytesRead - written, position + written); + if (result.bytesWritten <= 0) throw new Error('DMG verification snapshot write failed'); + written += result.bytesWritten; + } + position += bytesRead; + } + await writer.sync(); + await writer.close(); + writer = undefined; + await chmod(path, 0o400); + handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const stats = await handle.stat({ bigint: true }); + const sha256 = await hashHeld(handle, stats.size); + if (!stats.isFile() || stats.nlink !== 1n || stats.size !== source.stats.size || sha256 !== source.sha256) { + throw new Error('DMG verification snapshot does not match the held source'); + } + // Deny creation, rename, and deletion for the entire hdiutil interval. + // The randomized parent is searchable but neither enumerable nor writable. + await chmod(root, 0o500); + return { root, path, handle, stats, sha256 }; + } catch (error) { + await writer?.close().catch(() => undefined); + await handle?.close().catch(() => undefined); + await chmod(root, 0o700).catch(() => undefined); + await rm(root, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +}; + +const releaseSnapshot = async snapshot => { + await snapshot.handle.close().catch(() => undefined); + await chmod(snapshot.root, 0o700).catch(() => undefined); + await chmod(snapshot.path, 0o600).catch(() => undefined); + await rm(snapshot.root, { recursive: true, force: true }); +}; + +export const verifyDarwinImage = async (path, { + run = (file, arguments_) => execFile(file, arguments_, { timeout: 120_000, maxBuffer: 64 * 1024 }), + wait = milliseconds => new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds)), + nativePlatform = process.platform, +} = {}) => { + if (nativePlatform !== 'darwin') throw new Error('DMG verification requires native macOS'); + const source = await acquireCanonicalImage(path); + let snapshot; + try { + snapshot = await createProtectedSnapshot(source); + await reverifyHeld(source, 'private snapshot creation'); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await run(HDIUTIL, ['verify', snapshot.path]); + } catch (error) { + const stderr = typeof error === 'object' && error !== null && typeof error.stderr === 'string' ? error.stderr : ''; + if (!TRANSIENT_VERIFY_FAILURE.test(stderr) || attempt === 2) { + throw new Error(TRANSIENT_VERIFY_FAILURE.test(stderr) + ? 'Native DMG verification remained busy after bounded retries' + : 'Native DMG verification rejected the image'); + } + await reverifyHeld(snapshot, 'verification retry'); + await reverifyHeld(source, 'verification retry'); + await wait(250 * (attempt + 1)); + continue; + } + await reverifyHeld(snapshot, 'verification'); + await reverifyHeld(source, 'verification'); + return { size: Number(source.stats.size), sha256: source.sha256, attempts: attempt + 1 }; + } + throw new Error('Native DMG verification exhausted its bounded retry policy'); + } finally { + try { + if (snapshot) await releaseSnapshot(snapshot); + } finally { + await source.handle.close().catch(() => undefined); + } + } +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 3) throw new Error('Expected exactly one DMG pathname'); + await verifyDarwinImage(process.argv[2]); + process.stdout.write('Native DMG verification passed\n'); +} diff --git a/apps/desktop/scripts/verify-darwin-image.test.mjs b/apps/desktop/scripts/verify-darwin-image.test.mjs new file mode 100644 index 000000000..38b877d72 --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-image.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rename, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { verifyDarwinImage } from './verify-darwin-image.mjs'; + +test('Darwin image verification retries only bounded documented resource states', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-verify-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let calls = 0; + const result = await verifyDarwinImage(image, { + nativePlatform: 'darwin', + wait: async () => undefined, + run: async () => { + calls += 1; + if (calls < 3) throw Object.assign(new Error('busy'), { + stderr: calls === 1 + ? 'hdiutil: verify failed - Resource temporarily unavailable\n' + : 'hdiutil: verify failed - Resource busy\n', + }); + }, + }); + assert.equal(result.attempts, 3); + assert.equal(calls, 3); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('Darwin image verification does not retry malformed/truncated images or accept mutation', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-malformed-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let malformedCalls = 0; + await assert.rejects(verifyDarwinImage(image, { + nativePlatform: 'darwin', + wait: async () => undefined, + run: async () => { + malformedCalls += 1; + throw Object.assign(new Error('malformed'), { stderr: 'hdiutil: verify failed - image not recognized\n' }); + }, + }), /rejected the image/); + assert.equal(malformedCalls, 1); + + await assert.rejects(verifyDarwinImage(image, { + nativePlatform: 'darwin', + run: async () => { await truncate(image, 3); }, + }), /identity or checksum changed/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('Darwin image verification holds a fixed hdiutil image behind a real mutation and replacement barrier', { + skip: process.platform !== 'darwin', +}, async () => { + assert.equal(process.platform, 'darwin', 'the native image mutation barrier must run on Darwin'); + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-lease-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let verifiedPath; + const result = await verifyDarwinImage(image, { + nativePlatform: 'darwin', + run: async (file, arguments_) => { + assert.equal(file, '/usr/bin/hdiutil'); + assert.equal(arguments_[0], 'verify'); + verifiedPath = arguments_[1]; + await assert.rejects(writeFile(verifiedPath, 'mutated'), error => ['EACCES', 'EPERM'].includes(error.code)); + await assert.rejects( + rename(verifiedPath, `${verifiedPath}.displaced`), + error => ['EACCES', 'EPERM'].includes(error.code), + ); + }, + }); + assert.equal(result.sha256.length, 64); + assert.notEqual(verifiedPath, image); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs new file mode 100644 index 000000000..9de49e00e --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; +import { + DarwinSigningDiagnosticError, + darwinSigningDiagnosticLine, +} from './sign-darwin-packaged-connect.mjs'; + +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const SHA1_PATTERN = /^[A-F0-9]{40}$/u; +const CERTIFICATE_LINE = /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/gmu; +const ADHOC_SIGNATURE_LINE = /^\s*signature\s*=\s*adhoc\s*$/iu; +const IDENTIFIER_LINE = /^\s*identifier\s*=\s*(.*?)\s*$/iu; +const SIGNATURE_SIZE_LINE = /^\s*signature\s+size\s*=\s*(.*?)\s*$/iu; +const POSITIVE_SIGNATURE_SIZE = /^[1-9][0-9]*$/u; +const DESIGNATED_REQUIREMENT_PREFIX = /^designated\s*=>/iu; +const DESIGNATED_REQUIREMENT_GRAMMAR = /^designated\s*=>\s*identifier\s+"([^"]+)"\s+and\s+certificate\s+leaf\s*=\s*H\s*"([A-F0-9]{40})"$/iu; +const VERIFICATION_TIMEOUT_MS = 20_000; +const VERIFICATION_TERMINATION_GRACE_MS = 1_000; +const VERIFICATION_MAX_OUTPUT_BYTES = 256 * 1024; + +export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ + certificateLookupFailure: 'CERTIFICATE_LOOKUP_FAILURE', + signatureDisplayFailure: 'SIGNATURE_DISPLAY_FAILURE', + embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', + strictVerifyFailure: 'STRICT_VERIFY_FAILURE', + keychainEvidenceFailure: 'KEYCHAIN_EVIDENCE_FAILURE', + adhocSignatureFailure: 'ADHOC_SIGNATURE_FAILURE', + identifierMetadataFailure: 'IDENTIFIER_METADATA_FAILURE', + signatureMetadataFailure: 'SIGNATURE_METADATA_FAILURE', + requirementEvidenceFailure: 'REQUIREMENT_EVIDENCE_FAILURE', + evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', +}); + +const verificationFailure = (diagnostic, cause) => new DarwinSigningDiagnosticError( + diagnostic, + cause, +); + +const runVerificationCommand = async (runCommand, executable, arguments_, diagnostic) => { + try { + return await runCommand({ + executable, + arguments: arguments_, + timeoutMs: VERIFICATION_TIMEOUT_MS, + terminationGraceMs: VERIFICATION_TERMINATION_GRACE_MS, + maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES, + forwardOutput: false, + }); + } catch (cause) { + throw verificationFailure(diagnostic, cause); + } +}; + +const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); + +const expectedRequirementsFor = expectedCertificateSha1 => { + try { + const expectedSha1 = expectedCertificateSha1; + if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); + const expression = `identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${expectedSha1}"`; + return { + expectedSha1, + expression, + }; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } +}; + +const assertExactKeychainCertificate = (certificateDetails, expectedCertificateSha1) => { + const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); + try { + const fingerprints = [...certificateDetails.matchAll(CERTIFICATE_LINE)] + .map(match => match[1].toUpperCase()); + if (fingerprints.length !== 1 || fingerprints[0] !== expectedSha1) { + throw new Error('invalid-keychain-certificate-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure, + cause, + ); + } +}; + +const assertNotAdhocSignature = signatureDetails => { + try { + if (normalizeLines(signatureDetails).some(line => ADHOC_SIGNATURE_LINE.test(line))) { + throw new Error('ad-hoc-signature-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.adhocSignatureFailure, + cause, + ); + } +}; + +const assertIdentifierMetadata = signatureDetails => { + try { + const identifiers = normalizeLines(signatureDetails) + .map(line => line.match(IDENTIFIER_LINE)) + .filter(match => match !== null) + .map(match => match[1]); + if (identifiers.length !== 1 || identifiers[0] !== REQUIRED_IDENTIFIER) { + throw new Error('invalid-identifier-display-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure, + cause, + ); + } +}; + +const assertSignatureMetadata = signatureDetails => { + try { + const signatureSizes = normalizeLines(signatureDetails) + .map(line => line.match(SIGNATURE_SIZE_LINE)) + .filter(match => match !== null) + .map(match => match[1]); + if (signatureSizes.length !== 1 || !POSITIVE_SIGNATURE_SIZE.test(signatureSizes[0])) { + throw new Error('invalid-signature-display-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, + cause, + ); + } +}; + +const assertDesignatedRequirement = ( + designatedRequirement, + expectedSha1, + previousDesignatedRequirement, +) => { + try { + const designatedLines = normalizeLines(designatedRequirement) + .map(line => line.trim()) + .filter(line => DESIGNATED_REQUIREMENT_PREFIX.test(line)); + if (designatedLines.length !== 1) { + throw new Error('ambiguous-embedded-requirement-evidence'); + } + const requirementMatch = designatedLines[0].match(DESIGNATED_REQUIREMENT_GRAMMAR); + if (!requirementMatch + || requirementMatch[1] !== REQUIRED_IDENTIFIER + || requirementMatch[2].toUpperCase() !== expectedSha1) { + throw new Error('invalid-embedded-requirement-evidence'); + } + const normalizedRequirement = `${designatedLines[0]}\n`; + if (previousDesignatedRequirement !== undefined + && previousDesignatedRequirement !== normalizedRequirement) { + throw new Error('unstable-embedded-requirement-evidence'); + } + return normalizedRequirement; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure, + cause, + ); + } +}; + +export const assertDarwinSigningEvidence = ({ + expectedCertificateSha1, + signatureDetails, + designatedRequirement, + previousDesignatedRequirement, +}) => { + const expected = expectedRequirementsFor(expectedCertificateSha1); + assertNotAdhocSignature(signatureDetails); + assertIdentifierMetadata(signatureDetails); + assertSignatureMetadata(signatureDetails); + return assertDesignatedRequirement( + designatedRequirement, + expected.expectedSha1, + previousDesignatedRequirement, + ); +}; + +export const inspectDarwinSigningEvidence = async ({ + application, + keychain, + expectedCertificateSha1, + runCommand = runBoundedProcess, +}) => { + expectedRequirementsFor(expectedCertificateSha1); + const certificateResult = await runVerificationCommand( + runCommand, + '/usr/bin/security', + ['find-certificate', '-a', '-Z', keychain], + DARWIN_VERIFICATION_DIAGNOSTICS.certificateLookupFailure, + ); + assertExactKeychainCertificate( + `${certificateResult.stdout}\n${certificateResult.stderr}`, + expectedCertificateSha1, + ); + const signatureResult = await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['-d', '--verbose=4', application], + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + ); + const requirementResult = await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['-d', '-r-', application], + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + ); + await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['--verify', '--deep', '--strict', application], + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, + ); + try { + return { + signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, + designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, + }; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } +}; + +export const verifyDarwinPackagedConnectSignature = async ({ + mode, + application, + expectedCertificateSha1, + proofPath, + keychain, + runCommand = runBoundedProcess, +}) => { + if (mode !== 'establish' && mode !== 'stable') { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + if (!keychain || !keychain.endsWith('.keychain-db')) { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + let previousDesignatedRequirement; + if (mode === 'stable') { + try { + previousDesignatedRequirement = await readFile(proofPath, 'utf8'); + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } + } + const evidence = await inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1, + runCommand, + }); + const requirement = assertDarwinSigningEvidence({ + expectedCertificateSha1, + previousDesignatedRequirement, + ...evidence, + }); + if (mode === 'establish') { + try { + await writeFile(proofPath, requirement, { + encoding: 'utf8', mode: 0o600, flag: 'wx', + }); + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } + } +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [ + mode, application, expectedCertificateSha1, proofPath, keychain, + ] = process.argv.slice(2); + try { + if (process.platform !== 'darwin' + || !mode || !application || !expectedCertificateSha1 || !proofPath + || !keychain) { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + await verifyDarwinPackagedConnectSignature({ + mode, application, expectedCertificateSha1, proofPath, keychain, + }); + } catch (error) { + process.stderr.write(darwinSigningDiagnosticLine(error)); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs new file mode 100644 index 000000000..490f4a4ae --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -0,0 +1,455 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + classifyDarwinSigningFailure, + darwinSigningDiagnosticLine, +} from './sign-darwin-packaged-connect.mjs'; +import { + DARWIN_VERIFICATION_DIAGNOSTICS, + assertDarwinSigningEvidence, + inspectDarwinSigningEvidence, + verifyDarwinPackagedConnectSignature, +} from './verify-darwin-packaged-connect-signature.mjs'; + +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const application = '/private/tmp/propr-desktop.app'; +const keychain = '/private/tmp/propr-smoke.keychain-db'; +const fingerprint = 'A'.repeat(40); +const otherFingerprint = 'B'.repeat(40); +const requirementExpressionFor = certificateSha1 => ( + `identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${certificateSha1}"` +); +const requirementFor = certificateSha1 => ( + `designated => ${requirementExpressionFor(certificateSha1)}` +); + +const validEvidence = (overrides = {}) => ({ + expectedCertificateSha1: fingerprint, + signatureDetails: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + `Identifier=${REQUIRED_IDENTIFIER}`, + 'Signature size=1024', + ].join('\n'), + designatedRequirement: `${requirementFor(fingerprint)}\n`, + ...overrides, +}); + +const createVerifierSimulator = (overrides = {}) => { + const fixture = { + signed: true, + certificateFingerprints: [fingerprint], + identifierLine: `Identifier=${REQUIRED_IDENTIFIER}`, + signatureLine: 'Signature size=1024', + designatedRequirement: `${requirementFor(fingerprint)}\n`, + strictValid: true, + ...overrides, + }; + const calls = []; + const runCommand = async options => { + calls.push(options); + const arguments_ = options.arguments; + if (options.executable === '/usr/bin/security') { + assert.deepEqual(arguments_, ['find-certificate', '-a', '-Z', keychain]); + return { + stdout: fixture.certificateFingerprints + .map(value => `SHA-1 hash: ${value}`) + .join('\n'), + stderr: '', + }; + } + if (arguments_[0] === '-d' && arguments_[1] === '--verbose=4') { + if (!fixture.signed) throw new Error(`unsigned secret ${application}`); + return { + stdout: '', + stderr: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + fixture.identifierLine, + 'Format=app bundle with Mach-O universal (x86_64 arm64)', + fixture.signatureLine, + 'Info.plist entries=25', + ].filter(value => value !== null).join('\n'), + }; + } + if (arguments_[0] === '-d' && arguments_[1] === '-r-') { + return { stdout: '', stderr: fixture.designatedRequirement }; + } + if (arguments_.includes('--strict')) { + if (!fixture.strictValid) throw new Error(`strict failure ${application}`); + return { stdout: '', stderr: '' }; + } + throw new Error('unexpected simulated verifier invocation'); + }; + return { calls, runCommand }; +}; + +const withPrivateProofPath = async callback => { + const directory = await mkdtemp(join(tmpdir(), 'propr-signature-evidence-')); + try { + await callback(join(directory, 'designated-requirement.txt')); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const isDiagnostic = diagnostic => error => { + assert.equal(classifyDarwinSigningFailure(error), diagnostic); + assert.equal( + darwinSigningDiagnosticLine(error), + `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${diagnostic}\n`, + ); + assert.doesNotMatch(darwinSigningDiagnosticLine(error), /private|[A-F0-9]{40}/u); + return true; +}; + +const verifyEstablish = async (proofPath, fixture = {}) => { + const simulator = createVerifierSimulator(fixture); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: simulator.runCommand, + }); + return simulator; +}; + +describe('Darwin packaged Connect acceptance signature proof', () => { + test('uses the portable bounded certificate, display, requirement, and strict proof chain', async () => { + const simulator = createVerifierSimulator(); + const evidence = await inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: simulator.runCommand, + }); + + assert.equal( + assertDarwinSigningEvidence({ expectedCertificateSha1: fingerprint, ...evidence }), + `${requirementFor(fingerprint)}\n`, + ); + assert.deepEqual(simulator.calls.map(call => [call.executable, call.arguments]), [ + ['/usr/bin/security', ['find-certificate', '-a', '-Z', keychain]], + ['/usr/bin/codesign', ['-d', '--verbose=4', application]], + ['/usr/bin/codesign', ['-d', '-r-', application]], + ['/usr/bin/codesign', ['--verify', '--deep', '--strict', application]], + ]); + assert.ok(!simulator.calls.some(call => call.arguments.some(argument => ( + argument === '-R' + || argument.startsWith('-R=') + || argument === '--extract-certificates' + )))); + for (const call of simulator.calls) { + assert.equal(call.timeoutMs, 20_000); + assert.equal(call.terminationGraceMs, 1_000); + assert.equal(call.maxOutputBytes, 256 * 1024); + assert.equal(call.forwardOutput, false); + } + }); + + test('accepts exactly the generated keychain fingerprint and stable normalized requirements', async () => { + await withPrivateProofPath(async proofPath => { + const displayedRequirement = `${requirementFor(fingerprint.toLowerCase())}\n`; + await verifyEstablish(proofPath, { designatedRequirement: displayedRequirement }); + assert.equal(await readFile(proofPath, 'utf8'), displayedRequirement); + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: displayedRequirement, + }).runCommand, + }); + }); + }); + + test('rejects duplicate and wrong keychain fingerprints', async () => { + for (const certificateFingerprints of [ + [fingerprint, fingerprint], + [fingerprint, otherFingerprint], + [otherFingerprint], + ]) { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { certificateFingerprints }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure), + ); + }); + } + }); + + test('rejects explicit ad-hoc signature metadata with spacing and case variants', async () => { + for (const signatureLine of [ + 'Signature=adhoc', + ' sIgNaTuRe = AdHoC ', + ]) { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { signatureLine }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.adhocSignatureFailure), + ); + }); + } + }); + + test('rejects empty signature details', () => { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails: '' })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + }); + + test('rejects missing, wrong, duplicate, and conflicting identifier metadata distinctly', () => { + for (const signatureDetails of [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'Signature size=1024', + 'Identifier=dev.other.desktop', + 'Identifier=DEV.PROPR.DESKTOP', + `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}`, + `Identifier=${REQUIRED_IDENTIFIER}\nIDENTIFIER = dev.other.desktop`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + } + }); + + test('rejects missing, zero, duplicate, and conflicting signature-size metadata distinctly', () => { + for (const signatureDetails of [ + `Identifier=${REQUIRED_IDENTIFIER}`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=0`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=01`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024\nSignature size=1024`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024\nSIGNATURE SIZE = 2048`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + ); + } + }); + + test('requires root identifier evidence during both initial and stable native inspections', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { identifierLine: null }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ identifierLine: null }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure)); + }); + }); + + test('requires positive signature-size evidence during both native inspections', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { signatureLine: null }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + ); + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ signatureLine: 'Signature size=0' }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure)); + }); + }); + + test('rejects the wrong embedded requirement leaf distinctly', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { + designatedRequirement: `${requirementFor(otherFingerprint)}\n`, + }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + }); + }); + + test('requires byte-exact embedded designated-requirement stability after reprobe', async () => { + await withPrivateProofPath(async proofPath => { + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: `${requirementFor(fingerprint.toLowerCase())}\n`, + }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure)); + }); + }); + + test('strict verification failure has its fixed secret-safe subcode', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { strictValid: false }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure), + ); + }); + }); + + test('wraps every native verifier operation in its distinct fixed subcode', async () => { + const diagnostics = [ + DARWIN_VERIFICATION_DIAGNOSTICS.certificateLookupFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, + ]; + for (const [failureIndex, diagnostic] of diagnostics.entries()) { + let invocation = 0; + const simulator = createVerifierSimulator(); + await assert.rejects(inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: async options => { + if (invocation++ === failureIndex) { + throw new Error(`private failure ${application} ${fingerprint}`); + } + return simulator.runCommand(options); + }, + }), isDiagnostic(diagnostic)); + } + }); + + test('unsigned code fails at signature display with a fixed secret-safe subcode', async () => { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: createVerifierSimulator({ signed: false }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); + }); + + test('accepts surrounding requirement metadata and CRLF, spacing, and keyword case variants', async () => { + await withPrivateProofPath(async proofPath => { + const selectedLine = [ + 'DeSiGnAtEd => IDENTIFIER "dev.propr.desktop" AnD', + `CERTIFICATE LEAF = h "${fingerprint.toLowerCase()}"`, + ].join(' '); + const requirementOutput = [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'warning: using architecture arm64', + ` ${selectedLine} `, + 'Format=app bundle with Mach-O thin (arm64)', + ].join('\r\n'); + await verifyEstablish(proofPath, { designatedRequirement: requirementOutput }); + assert.equal(await readFile(proofPath, 'utf8'), `${selectedLine}\n`); + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: requirementOutput, + }).runCommand, + }); + }); + }); + + test('accepts realistic verbose metadata with exactly one identifier and positive signature size', () => { + for (const signatureDetails of [ + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=2048`, + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\r\n IDENTIFIER = ${REQUIRED_IDENTIFIER} \r\nFormat=app bundle with Mach-O thin (arm64)\r\n SIGNATURE SIZE = 4096 `, + ]) { + assert.equal( + assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + `${requirementFor(fingerprint)}\n`, + ); + } + }); + + test('rejects duplicate designated lines, wrong identifiers and leaves, and extra clauses', () => { + for (const designatedRequirement of [ + '', + [requirementFor(fingerprint), requirementFor(fingerprint)].join('\n'), + `${requirementFor(fingerprint)}\n${requirementFor(otherFingerprint)}\n`, + `${requirementFor(fingerprint).replace(REQUIRED_IDENTIFIER, 'dev.other.desktop')}\n`, + `${requirementFor(fingerprint).replace(REQUIRED_IDENTIFIER, 'DEV.PROPR.DESKTOP')}\n`, + `${requirementFor(otherFingerprint)}\n`, + `${requirementFor(fingerprint)} or anchor apple\n`, + `${requirementFor(fingerprint)} and certificate 1 trusted\n`, + `designated => (${requirementExpressionFor(fingerprint)})\n`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ designatedRequirement })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + } + }); + + test('ignores unrelated surrounding lines but requires exactly one designated line', () => { + for (const designatedRequirement of [ + [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'Format=app bundle with Mach-O universal (x86_64 arm64)', + ].join('\n'), + [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + requirementFor(fingerprint), + 'Format=app bundle with Mach-O thin (x86_64)', + requirementFor(fingerprint), + ].join('\n'), + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ designatedRequirement })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + } + }); + + test('normalizes only surrounding line whitespace when comparing stable requirements', () => { + const initial = validEvidence({ + designatedRequirement: `metadata\r\n ${requirementFor(fingerprint)} \r\nmore metadata\r\n`, + }); + assert.equal( + assertDarwinSigningEvidence(initial), + `${requirementFor(fingerprint)}\n`, + ); + assert.equal( + assertDarwinSigningEvidence({ + ...initial, + previousDesignatedRequirement: `${requirementFor(fingerprint)}\n`, + }), + `${requirementFor(fingerprint)}\n`, + ); + assert.throws( + () => assertDarwinSigningEvidence({ + ...initial, + designatedRequirement: `${requirementFor(fingerprint).replace(' and ', ' and ')}\n`, + previousDesignatedRequirement: `${requirementFor(fingerprint)}\n`, + }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + }); + + test('retains EVIDENCE_ASSERTION_FAILURE for invalid verifier inputs', () => { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ + expectedCertificateSha1: 'not-a-sha1', + })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); + }); +}); diff --git a/apps/desktop/scripts/windows-fixture-acl.mjs b/apps/desktop/scripts/windows-fixture-acl.mjs new file mode 100644 index 000000000..e935478f0 --- /dev/null +++ b/apps/desktop/scripts/windows-fixture-acl.mjs @@ -0,0 +1,212 @@ +import { spawnSync } from 'node:child_process'; +import { lstatSync } from 'node:fs'; +import { win32 } from 'node:path'; +import { TextDecoder } from 'node:util'; + +export const windowsFixtureAclSource = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +function Set-ProprFixtureAcl { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][ValidateSet('directory','file')][string]$EntryKind, + [Parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string]$EntryPath + ) + try { + if(-not [IO.Path]::IsPathRooted($EntryPath)){exit 40} + } catch { exit 40 } + try { + $canonicalPath=[IO.Path]::GetFullPath($EntryPath) + } catch { exit 48 } + try { + if(-not [String]::Equals($canonicalPath,$EntryPath,[StringComparison]::OrdinalIgnoreCase)){exit 49} + } catch { exit 49 } + try { + $item=Get-Item -LiteralPath $canonicalPath + $directory=$EntryKind -eq 'directory' + if($directory -ne $item.PSIsContainer){exit 41} + } catch { exit 41 } + try { + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null -eq $current){exit 42} + } catch { exit 42 } + try { + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + } catch { exit 43 } + try { + $sections=[System.Security.AccessControl.AccessControlSections]::Access + $acl=if($directory){ + [System.IO.Directory]::GetAccessControl($canonicalPath,$sections) + }else{[System.IO.File]::GetAccessControl($canonicalPath,$sections)} + } catch { exit 44 } + try { + $null=$acl.SetAccessRuleProtection($true,$false) + foreach($existing in @($acl.Access)){$null=$acl.RemoveAccessRuleSpecific($existing)} + } catch { exit 45 } + try { + foreach($identity in @($current,$system,$admins)){ + $rights=[Security.AccessControl.FileSystemRights]::FullControl + $accessType=[Security.AccessControl.AccessControlType]::Allow + $rule=if($directory){ + $inheritance=[Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit + $propagation=[Security.AccessControl.PropagationFlags]::None + [Security.AccessControl.FileSystemAccessRule]::new($identity,$rights,$inheritance,$propagation,$accessType) + }else{[Security.AccessControl.FileSystemAccessRule]::new($identity,$rights,$accessType)} + $null=$acl.AddAccessRule($rule) + } + } catch { exit 46 } + try { + if($directory){ + $null=[System.IO.Directory]::SetAccessControl($canonicalPath,[System.Security.AccessControl.DirectorySecurity]$acl) + }else{ + $null=[System.IO.File]::SetAccessControl($canonicalPath,[System.Security.AccessControl.FileSecurity]$acl) + } + } catch { exit 47 } +} +try { + $null=Set-ProprFixtureAcl -EntryKind $env:PROPR_FIXTURE_ACL_KIND -EntryPath $env:PROPR_FIXTURE_ACL_PATH +} catch { + exit 50 +}`; + +export const encodedWindowsFixtureAcl = Buffer.from(windowsFixtureAclSource, 'utf16le').toString('base64'); + +const WINDOWS_FIXTURE_PATH_MAX_BYTES = 4 * 1024; +const WINDOWS_FIXTURE_PROCESS_MAX_BYTES = 8 * 1024; + +const windowsFixtureCanonicalPathSource = String.raw` +$ErrorActionPreference='Stop' +try { + $entryPath=$env:PROPR_FIXTURE_CANONICAL_PATH + if([String]::IsNullOrEmpty($entryPath) -or -not [IO.Path]::IsPathRooted($entryPath)){exit 60} +} catch { exit 60 } +try { + $canonicalPath=[IO.Path]::GetFullPath($entryPath) +} catch { exit 61 } +try { + $utf8=[Text.UTF8Encoding]::new($false) + $byteCount=$utf8.GetByteCount($canonicalPath) + if($byteCount -lt 1 -or $byteCount -gt 4096 -or $canonicalPath.IndexOf([char]0) -ge 0 -or $canonicalPath.IndexOf([char]13) -ge 0 -or $canonicalPath.IndexOf([char]10) -ge 0){exit 62} + [Console]::OutputEncoding=$utf8 + $null=[Console]::Out.Write($canonicalPath) +} catch { exit 63 } +`; + +const encodedWindowsFixtureCanonicalPath = Buffer.from( + windowsFixtureCanonicalPathSource, + 'utf16le', +).toString('base64'); + +const canonicalizationFailure = (phase, category) => { + const error = new Error(`Windows fixture canonicalization failed [phase=${phase} category=${category}]`); + error.stack = error.message; + throw error; +}; + +export const windowsPowerShell51Path = (environment = process.env) => { + const systemRoot = environment.SystemRoot; + if (typeof systemRoot !== 'string' + || systemRoot.length === 0 + || systemRoot.includes('\0') + || systemRoot.includes('\r') + || systemRoot.includes('\n') + || !win32.isAbsolute(systemRoot)) { + canonicalizationFailure('powershell-path', 'invalid-system-root'); + } + return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +}; + +const entryTypeMatches = (status, entryKind) => (entryKind === 'directory' + ? status.isDirectory() && !status.isSymbolicLink() + : status.isFile() && !status.isSymbolicLink()); + +const sameEntryIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; + +const normalizationCategory = (originalPath, canonicalPath) => { + if (originalPath === canonicalPath) return 'unchanged'; + if (originalPath.toUpperCase() === canonicalPath.toUpperCase()) return 'case-normalization'; + if (originalPath.split(/[\\/]/u).some(component => /~\d/u.test(component))) { + return 'short-name-expansion'; + } + if (originalPath.replaceAll('/', '\\').toUpperCase() === canonicalPath.toUpperCase()) { + return 'separator-normalization'; + } + return 'filesystem-path-normalization'; +}; + +const readEntry = (entryPath, phase) => { + try { + return lstatSync(entryPath, { bigint: true }); + } catch { + canonicalizationFailure(phase, 'entry-inspection-failed'); + } +}; + +export const canonicalizeWindowsFixtureEntry = ({ entryKind, entryPath, powershellPath }) => { + if ((entryKind !== 'directory' && entryKind !== 'file') || typeof entryPath !== 'string') { + canonicalizationFailure('input', 'invalid-entry'); + } + if (typeof powershellPath !== 'string' || powershellPath.length === 0) { + canonicalizationFailure('powershell-path', 'invalid-executable'); + } + + const before = readEntry(entryPath, 'original-before'); + if (!entryTypeMatches(before, entryKind)) canonicalizationFailure('original-before', 'type-mismatch'); + + const result = spawnSync(powershellPath, [ + '-NoLogo', '-NoProfile', '-NonInteractive', + '-EncodedCommand', encodedWindowsFixtureCanonicalPath, + ], { + shell: false, + windowsHide: true, + timeout: 10_000, + maxBuffer: WINDOWS_FIXTURE_PROCESS_MAX_BYTES, + env: { + ...process.env, + PROPR_FIXTURE_CANONICAL_PATH: entryPath, + }, + }); + if (result.error || result.signal) canonicalizationFailure('powershell-invocation', 'process-failed'); + if (!Buffer.isBuffer(result.stderr) || result.stderr.length !== 0) { + canonicalizationFailure('powershell-invocation', 'powershell-stderr'); + } + const failurePhase = new Map([ + [60, 'rooted-path'], + [61, 'full-path'], + [62, 'bounded-result'], + [63, 'result-write'], + ]).get(result.status); + if (failurePhase) canonicalizationFailure(failurePhase, 'operation-failed'); + if (result.status !== 0) canonicalizationFailure('powershell-invocation', 'unexpected-exit'); + if (!Buffer.isBuffer(result.stdout) + || result.stdout.length === 0 + || result.stdout.length > WINDOWS_FIXTURE_PATH_MAX_BYTES) { + canonicalizationFailure('result-validation', 'invalid-size'); + } + + let canonicalPath; + try { + canonicalPath = new TextDecoder('utf-8', { fatal: true }).decode(result.stdout); + } catch { + canonicalizationFailure('result-validation', 'invalid-encoding'); + } + if (canonicalPath.includes('\0') || canonicalPath.includes('\r') || canonicalPath.includes('\n')) { + canonicalizationFailure('result-validation', 'invalid-framing'); + } + if (!win32.isAbsolute(canonicalPath)) canonicalizationFailure('result-validation', 'unrooted-path'); + + const canonical = readEntry(canonicalPath, 'canonical-entry'); + const after = readEntry(entryPath, 'original-after'); + if (!entryTypeMatches(canonical, entryKind) || !entryTypeMatches(after, entryKind)) { + canonicalizationFailure('identity-proof', 'type-mismatch'); + } + if (!sameEntryIdentity(before, canonical) || !sameEntryIdentity(before, after)) { + canonicalizationFailure('identity-proof', 'identity-mismatch'); + } + + return { + path: canonicalPath, + normalization: normalizationCategory(entryPath, canonicalPath), + }; +}; diff --git a/apps/desktop/scripts/windows-fixture-acl.test.mjs b/apps/desktop/scripts/windows-fixture-acl.test.mjs new file mode 100644 index 000000000..c46767bc2 --- /dev/null +++ b/apps/desktop/scripts/windows-fixture-acl.test.mjs @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { it } from 'node:test'; +import { + canonicalizeWindowsFixtureEntry, + encodedWindowsFixtureAcl, + windowsFixtureAclSource, + windowsPowerShell51Path, +} from './windows-fixture-acl.mjs'; + +const windowsIt = process.platform === 'win32' ? it : it.skip; + +const ownerClassifierSource = String.raw` +function Get-ProprOwnerCategoryToken { + param( + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Owner, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Current + ) + if($Owner.Value -eq $Current.Value){return 1} + if($Owner.Value -eq 'S-1-5-32-544'){return 2} + if($Owner.Value -eq 'S-1-5-18'){return 3} + return 0 +}`; + +const exactDaclProofSource = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +${ownerClassifierSource} +try { + $entryKind=$env:PROPR_FIXTURE_ACL_KIND + $entryPath=$env:PROPR_FIXTURE_ACL_PATH + $proofKind=$env:PROPR_FIXTURE_ACL_PROOF + $expectedOwnerCategory=$env:PROPR_FIXTURE_ACL_OWNER_CATEGORY + if(($entryKind -ne 'directory' -and $entryKind -ne 'file') -or + ($proofKind -ne 'owner' -and $proofKind -ne 'exact') -or + [String]::IsNullOrEmpty($entryPath)){exit 70} + if($proofKind -eq 'owner' -and -not [String]::IsNullOrEmpty($expectedOwnerCategory)){exit 70} + if($proofKind -eq 'exact' -and + $expectedOwnerCategory -ne 'current-user' -and + $expectedOwnerCategory -ne 'administrators' -and + $expectedOwnerCategory -ne 'system'){exit 70} +} catch { exit 70 } +try { + $sections=[System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner + $acl=if($entryKind -eq 'directory'){ + [System.IO.Directory]::GetAccessControl($entryPath,$sections) + }else{[System.IO.File]::GetAccessControl($entryPath,$sections)} +} catch { exit 71 } +try { + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]) + if($null -eq $current -or $null -eq $owner){exit 72} + $ownerCategoryToken=Get-ProprOwnerCategoryToken -Owner $owner -Current $current +} catch { exit 72 } +if($ownerCategoryToken -eq 0){exit 78} +if($proofKind -eq 'owner'){ + if($ownerCategoryToken -eq 1){exit 75} + if($ownerCategoryToken -eq 2){exit 76} + if($ownerCategoryToken -eq 3){exit 77} + exit 72 +} +try { + $expectedOwnerCategoryToken=if($expectedOwnerCategory -eq 'current-user'){1} + elseif($expectedOwnerCategory -eq 'administrators'){2} + elseif($expectedOwnerCategory -eq 'system'){3} + else{exit 70} + if($ownerCategoryToken -ne $expectedOwnerCategoryToken){exit 79} +} catch { exit 72 } +try { + $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) + if(-not $acl.AreAccessRulesProtected -or -not $acl.AreAccessRulesCanonical -or + $rules.Count -ne 3 -or @($rules | Where-Object {$_.IsInherited}).Count -ne 0){exit 73} +} catch { exit 73 } +try { + $expectedSids=@($current.Value,'S-1-5-18','S-1-5-32-544') + $expectedInheritance=if($entryKind -eq 'directory'){ + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit + }else{[Security.AccessControl.InheritanceFlags]::None} + foreach($sid in $expectedSids){ + $matches=@($rules | Where-Object {$_.IdentityReference.Value -eq $sid}) + if($matches.Count -ne 1){exit 74} + $rule=$matches[0] + if($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or + $rule.InheritanceFlags -ne $expectedInheritance -or + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None -or + $rule.IsInherited){exit 74} + } +} catch { exit 74 } +`; + +const encodedExactDaclProof = Buffer.from(exactDaclProofSource, 'utf16le').toString('base64'); + +const ownerClassifierRegressionSource = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +${ownerClassifierSource} +try { + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $unknown=[Security.Principal.SecurityIdentifier]::new('S-1-0-0') + if($null -eq $current -or + (Get-ProprOwnerCategoryToken -Owner $current -Current $current) -ne 1 -or + (Get-ProprOwnerCategoryToken -Owner $admins -Current $current) -ne 2 -or + (Get-ProprOwnerCategoryToken -Owner $system -Current $current) -ne 3){exit 80} + $unknownOwnerCategoryToken=Get-ProprOwnerCategoryToken -Owner $unknown -Current $current +} catch { exit 82 } +if($unknownOwnerCategoryToken -eq 0){exit 78} +exit 81 +`; + +const encodedOwnerClassifierRegression = Buffer.from( + ownerClassifierRegressionSource, + 'utf16le', +).toString('base64'); + +const baselineOwnerCategories = new Map([ + [75, 'current-user'], + [76, 'administrators'], + [77, 'system'], +]); + +const assertPowerShellStreamEmpty = (stream, category) => { + if (!Buffer.isBuffer(stream) || stream.length !== 0) { + const error = new Error(`Windows fixture ACL helper stream contract failed [category=${category}]`); + error.stack = error.message; + throw error; + } +}; + +const runAclProof = (powershell, entry, proofKind, ownerCategory = '') => spawnSync( + powershell, + [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedExactDaclProof, + ], + { + shell: false, + windowsHide: true, + timeout: 30_000, + env: { + ...process.env, + PROPR_FIXTURE_ACL_KIND: entry.kind, + PROPR_FIXTURE_ACL_PATH: entry.path, + PROPR_FIXTURE_ACL_PROOF: proofKind, + PROPR_FIXTURE_ACL_OWNER_CATEGORY: ownerCategory, + }, + }, +); + +const proofFailureCategory = status => new Map([ + [70, 'input'], + [71, 'access-control-read'], + [72, 'owner-lookup'], + [73, 'protection'], + [74, 'rules'], + [78, 'owner-not-allowlisted'], + [79, 'owner-category-mismatch'], +]).get(status) ?? 'unexpected-exit'; + +const assertProofProcess = result => { + assert.ifError(result.error); + assert.equal(result.signal, null); + assertPowerShellStreamEmpty(result.stdout, 'dacl-proof-stdout'); + assertPowerShellStreamEmpty(result.stderr, 'dacl-proof-stderr'); +}; + +const classifyBaselineOwner = (powershell, entry) => { + const result = runAclProof(powershell, entry, 'owner'); + assertProofProcess(result); + const ownerCategory = baselineOwnerCategories.get(result.status); + assert.ok( + ownerCategory, + `${entry.kind} owner ACL proof failed [category=${proofFailureCategory(result.status)}]`, + ); + return ownerCategory; +}; + +const assertExactAcl = (powershell, entry, ownerCategory) => { + const result = runAclProof(powershell, entry, 'exact', ownerCategory); + assertProofProcess(result); + assert.equal( + result.status, + 0, + `${entry.kind} exact ACL proof failed [category=${proofFailureCategory(result.status)}]`, + ); +}; + +const assertOwnerCategoryMismatch = (powershell, entry, ownerCategory) => { + const mismatchedCategory = ownerCategory === 'current-user' ? 'administrators' : 'current-user'; + const result = runAclProof(powershell, entry, 'exact', mismatchedCategory); + assertProofProcess(result); + assert.equal(result.status, 79, `${entry.kind} accepted a mismatched owner category`); +}; + +windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and byte-empty', t => { + const powershell = windowsPowerShell51Path(); + const version = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Console]::Out.Write($PSVersionTable.PSVersion.ToString(2))', + ], { shell: false, windowsHide: true, encoding: 'utf8', timeout: 10_000 }); + assert.ifError(version.error); + assert.equal(version.status, 0); + assert.equal(version.stdout, '5.1'); + assert.equal(version.stderr, ''); + + assert.match( + windowsFixtureAclSource, + /AccessControlSections\]::Access\s*\r?\n/u, + 'production mutation must request the access-control section', + ); + assert.doesNotMatch( + windowsFixtureAclSource, + /AccessControlSections\]::Owner|\.SetOwner\s*\(/u, + 'production mutation must not request or set owner', + ); + + const classifierRegression = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', + '-EncodedCommand', encodedOwnerClassifierRegression, + ], { shell: false, windowsHide: true, timeout: 10_000 }); + assertProofProcess(classifierRegression); + const classifierCategory = new Map([ + [78, 'unknown-owner'], + [80, 'allowlisted-owner'], + [81, 'unknown-owner-accepted'], + [82, 'owner-lookup'], + ]).get(classifierRegression.status) ?? 'unexpected-exit'; + assert.equal( + classifierRegression.status, + 78, + `owner classifier regression failed [category=${classifierCategory}]`, + ); + + const temporaryDirectoryAlias = tmpdir(); + const canonicalTemporaryDirectory = realpathSync(temporaryDirectoryAlias); + const fixture = mkdtempSync(join(canonicalTemporaryDirectory, 'propr-fixture-acl-output-')); + const directory = join(fixture, 'data'); + const file = join(directory, 'identity.json'); + mkdirSync(directory); + writeFileSync(file, '{}\n'); + + try { + const canonicalDirectory = canonicalizeWindowsFixtureEntry({ + entryKind: 'directory', entryPath: directory, powershellPath: powershell, + }); + const canonicalFile = canonicalizeWindowsFixtureEntry({ + entryKind: 'file', entryPath: file, powershellPath: powershell, + }); + const canonicalizedEntries = [ + [directory, canonicalDirectory], + [file, canonicalFile], + ]; + const normalizationCategories = new Set(); + for (const [originalPath, entry] of canonicalizedEntries) { + if (entry.path.toUpperCase() !== originalPath.toUpperCase()) { + normalizationCategories.add(entry.normalization); + } + } + for (const category of [...normalizationCategories].sort()) { + t.diagnostic(`PS5.1 path normalization category=${category}`); + } + + const entriesWithBaselineOwner = [ + { kind: 'directory', path: canonicalDirectory.path }, + { kind: 'file', path: canonicalFile.path }, + ].map(entry => ({ ...entry, ownerCategory: classifyBaselineOwner(powershell, entry) })); + + const entries = [ + { label: 'relative path', kind: 'directory', path: 'data', status: 40 }, + { label: 'mismatched directory kind', kind: 'file', path: canonicalDirectory.path, status: 41 }, + { label: 'mismatched file kind', kind: 'directory', path: canonicalFile.path, status: 41 }, + // A server-only UNC is rooted, but PS5.1/.NET Framework rejects it because + // a valid UNC must also name a share. This reaches GetFullPath (phase 48). + { label: 'invalid full path', kind: 'file', path: '\\\\propr-invalid-unc\\', status: 48 }, + { label: 'canonical traversal alias', kind: 'directory', path: `${canonicalDirectory.path}\\..\\data`, status: 49 }, + { label: 'empty path', kind: 'directory', path: '', status: 50 }, + { label: 'invalid entry kind', kind: 'invalid', path: canonicalFile.path, status: 50 }, + { label: 'directory success', kind: 'directory', path: canonicalDirectory.path, status: 0 }, + { label: 'file success', kind: 'file', path: canonicalFile.path, status: 0 }, + ]; + + // Node realpath can retain a spelling that PS5.1 further canonicalizes. + // Keep that spelling uncanonicalized and prove the helper rejects it. + if (canonicalDirectory.path.toUpperCase() !== directory.toUpperCase()) { + entries.unshift( + { + label: 'precanonical directory spelling', + kind: 'directory', + path: directory, + status: 49, + }, + { + label: 'precanonical file spelling', + kind: 'file', + path: file, + status: 49, + }, + ); + } + + for (const entry of entries) { + const result = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedWindowsFixtureAcl, + ], { + shell: false, + windowsHide: true, + timeout: 30_000, + env: { + ...process.env, + PROPR_FIXTURE_ACL_KIND: entry.kind, + PROPR_FIXTURE_ACL_PATH: entry.path, + }, + }); + + assert.ifError(result.error); + assert.equal(result.signal, null); + assertPowerShellStreamEmpty(result.stdout, 'powershell-stdout'); + assertPowerShellStreamEmpty(result.stderr, 'powershell-stderr'); + assert.equal(result.status, entry.status, `${entry.label} returned the wrong redacted phase code`); + if (entry.status === 0) { + const baseline = entriesWithBaselineOwner.find(candidate => candidate.kind === entry.kind); + assert.ok(baseline, `missing ${entry.kind} baseline owner category`); + assertExactAcl(powershell, entry, baseline.ownerCategory); + assertOwnerCategoryMismatch(powershell, entry, baseline.ownerCategory); + } + } + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/scripts/windows-installer-version.d.mts b/apps/desktop/scripts/windows-installer-version.d.mts new file mode 100644 index 000000000..dcc0e1511 --- /dev/null +++ b/apps/desktop/scripts/windows-installer-version.d.mts @@ -0,0 +1,2 @@ +export const WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR: string; +export function assertWindowsInstallerProductVersion(version: unknown): string; diff --git a/apps/desktop/scripts/windows-installer-version.mjs b/apps/desktop/scripts/windows-installer-version.mjs new file mode 100644 index 000000000..5d7e2ddfe --- /dev/null +++ b/apps/desktop/scripts/windows-installer-version.mjs @@ -0,0 +1,18 @@ +export const WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR = + 'Windows MSI ProductVersion must use three numeric components with major and minor at most 255 and patch at most 65535'; + +const WINDOWS_INSTALLER_PRODUCT_VERSION_PATTERN = + /^(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,4})$/; + +export const assertWindowsInstallerProductVersion = version => { + const match = typeof version === 'string' + ? WINDOWS_INSTALLER_PRODUCT_VERSION_PATTERN.exec(version) + : null; + if (!match + || Number(match[1]) > 255 + || Number(match[2]) > 255 + || Number(match[3]) > 65535) { + throw new Error(WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR); + } + return version; +}; diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.mjs new file mode 100644 index 000000000..87430d408 --- /dev/null +++ b/apps/desktop/scripts/windows-packaged-connect-staging.mjs @@ -0,0 +1,387 @@ +import { spawnSync } from 'node:child_process'; +import { open } from 'node:fs/promises'; +import { win32 } from 'node:path'; +import { + canonicalizeWindowsFixtureEntry, + windowsPowerShell51Path, +} from './windows-fixture-acl.mjs'; + +export const WINDOWS_ARTIFACT_FAILURE_CATEGORIES = Object.freeze([ + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed', +]); + +export const WINDOWS_ARTIFACT_FAILURE_PHASES = Object.freeze([ + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'result-verify', +]); + +export const WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES = Object.freeze([ + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding', +]); + +export const WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES = Object.freeze([ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', +]); + +export const WINDOWS_ARTIFACT_FAILURE_SUBPHASES = Object.freeze([ + ...WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + ...WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, +]); + +const STAGING_PARENT_LEAF = 'propr-connect-packaged-stage'; +const STAGING_LEAF_PATTERN = /^propr-connect-package-[a-f0-9]{32}$/u; +const EXPECTED_MACHINES = Object.freeze({ x64: 0x8664, arm64: 0xaa64 }); +const MAX_CONTRACT_PATH_LENGTH = 4096; +const MAX_HANDOFF_LENGTH = 16_384; +const STAGED_CONTRACT_HANDOFF_PREFIX = '--propr-windows-staged-contract='; +const PE_HEADER_BYTES = 4096; + +const isAllowedSubphase = (phase, subphase) => ( + (phase === 'staged-contract' + && WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.includes(subphase)) + || (phase === 'ordinary-user-preflight' + && WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES.includes(subphase)) +); + +export const packagedConnectArtifactSensitiveNeedles = ({ + platform, + artifactRoot, + binaryPath, + stagedContract, + stagedHandoff, +}) => platform === 'win32' ? [ + artifactRoot, + binaryPath, + stagedContract.runnerTemp, + stagedContract.parent, + stagedContract.leaf, + stagedHandoff, +] : []; + +export class WindowsArtifactFailure extends Error { + constructor(category, phase = 'application-runtime', subphase) { + const fixedCategory = WINDOWS_ARTIFACT_FAILURE_CATEGORIES.includes(category) + ? category : 'artifact-inaccessible'; + const fixedPhase = WINDOWS_ARTIFACT_FAILURE_PHASES.includes(phase) + ? phase : 'application-runtime'; + const fixedSubphase = isAllowedSubphase(fixedPhase, subphase) + ? subphase : undefined; + super(`Packaged Connect Windows artifact failed [category=${fixedCategory} phase=${fixedPhase}` + + `${fixedSubphase ? ` subphase=${fixedSubphase}` : ''}]`); + this.name = 'WindowsArtifactFailure'; + this.category = fixedCategory; + this.phase = fixedPhase; + this.subphase = fixedSubphase; + this.stack = this.message; + } +} + +const fail = (category, phase, subphase) => { + throw new WindowsArtifactFailure(category, phase, subphase); +}; + +const isCanonicalAbsoluteWindowsPath = value => ( + typeof value === 'string' + && value.length > 3 + && value.length <= MAX_CONTRACT_PATH_LENGTH + && !value.includes('\0') + && !value.includes('\r') + && !value.includes('\n') + && !value.includes('/') + && /^[A-Za-z]:\\/u.test(value) + && win32.isAbsolute(value) + && win32.normalize(value) === value + && !value.endsWith('\\') +); + +export const parseWindowsStagedPackageContract = environment => { + const runnerTemp = environment?.RUNNER_TEMP; + const parent = environment?.PROPR_DESKTOP_CONNECT_STAGING_PARENT; + const leaf = environment?.PROPR_DESKTOP_CONNECT_STAGING_LEAF; + if (!isCanonicalAbsoluteWindowsPath(runnerTemp)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + if (!isCanonicalAbsoluteWindowsPath(parent)) { + fail('artifact-type', 'staged-contract', 'staging-parent-input-shape'); + } + if (win32.dirname(parent) !== runnerTemp) { + fail('artifact-type', 'staged-contract', 'parent-to-runner-binding'); + } + if (win32.basename(parent) !== STAGING_PARENT_LEAF) { + fail('artifact-type', 'staged-contract', 'fixed-parent-leaf'); + } + if (!STAGING_LEAF_PATTERN.test(leaf ?? '')) { + fail('artifact-type', 'staged-contract', 'generated-stage-leaf'); + } + const root = win32.join(parent, leaf); + if (win32.dirname(root) !== parent || win32.basename(root) !== leaf) { + fail('artifact-type', 'staged-contract', 'derived-root-to-parent-binding'); + } + return Object.freeze({ + runnerTemp, + parent, + leaf, + root, + executable: win32.join(root, 'propr-desktop.exe'), + resources: win32.join(root, 'resources'), + applicationArchive: win32.join(root, 'resources', 'app.asar'), + }); +}; + +export const parseWindowsStagedPackageHandoff = arguments_ => { + if (!Array.isArray(arguments_) || arguments_.length !== 1 + || typeof arguments_[0] !== 'string' + || !arguments_[0].startsWith(STAGED_CONTRACT_HANDOFF_PREFIX)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const encoded = arguments_[0].slice(STAGED_CONTRACT_HANDOFF_PREFIX.length); + if (encoded.length < 4 || encoded.length > MAX_HANDOFF_LENGTH + || encoded.length % 4 !== 0 + || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const decoded = bytes.toString('utf8'); + if (!Buffer.from(decoded, 'utf8').equals(bytes)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const fields = decoded.split('\n'); + if (fields.length !== 3) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + return parseWindowsStagedPackageContract({ + RUNNER_TEMP: fields[0], + PROPR_DESKTOP_CONNECT_STAGING_PARENT: fields[1], + PROPR_DESKTOP_CONNECT_STAGING_LEAF: fields[2], + }); +}; + +export const assertPackagedWindowsPeArchitecture = (bytes, expectedArchitecture) => { + if (!Buffer.isBuffer(bytes) || !Object.hasOwn(EXPECTED_MACHINES, expectedArchitecture)) { + fail('architecture-mismatch', 'staged-architecture'); + } + if (bytes.length < 0x40 || bytes.toString('ascii', 0, 2) !== 'MZ') { + fail('artifact-type', 'staged-architecture'); + } + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset < 0x40 + || peOffset + 6 > bytes.length + || bytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') { + fail('artifact-type', 'staged-architecture'); + } + if (bytes.readUInt16LE(peOffset + 4) !== EXPECTED_MACHINES[expectedArchitecture]) { + fail('architecture-mismatch', 'staged-architecture'); + } +}; + +const readPeHeader = async path => { + let handle; + try { + handle = await open(path, 'r'); + const bytes = Buffer.alloc(PE_HEADER_BYTES); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + return bytes.subarray(0, bytesRead); + } catch (error) { + if (error?.code === 'ENOENT') fail('artifact-missing', 'staged-architecture'); + fail('artifact-inaccessible', 'staged-architecture'); + } finally { + await handle?.close().catch(() => {}); + } +}; + +const windowsStagedPackagePreflightSource = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +try { + $parent=$env:PROPR_DESKTOP_CONNECT_STAGING_PARENT + $leaf=$env:PROPR_DESKTOP_CONNECT_STAGING_LEAF + if([String]::IsNullOrEmpty($parent) -or [String]::IsNullOrEmpty($leaf)){exit 80} + $root=[IO.Path]::Combine($parent,$leaf) + $executable=[IO.Path]::Combine($root,'propr-desktop.exe') + $resources=[IO.Path]::Combine($root,'resources') + $archive=[IO.Path]::Combine($resources,'app.asar') + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if($null -eq $current -or $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){exit 81} + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +} catch { exit 80 } +try { + $entries=@( + @{Path=$parent;Directory=$true}, + @{Path=$root;Directory=$true}, + @{Path=$resources;Directory=$true}, + @{Path=$archive;Directory=$false}, + @{Path=$executable;Directory=$false} + ) + $descendants=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + if($descendants.Count -lt 1 -or $descendants.Count -gt 20000){exit 82} + foreach($item in $descendants){$entries+=@{Path=$item.FullName;Directory=$item.PSIsContainer}} +} catch { exit 83 } +try { + foreach($entry in $entries){ + $item=Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + if($item.PSIsContainer -ne $entry.Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [String]::Equals($item.FullName,$entry.Path,[StringComparison]::OrdinalIgnoreCase)){exit 82} + $sections=[Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner + $acl=if($entry.Directory){[IO.Directory]::GetAccessControl($entry.Path,$sections)}else{[IO.File]::GetAccessControl($entry.Path,$sections)} + $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) + if($owner.Value -ne $admins.Value -or -not $acl.AreAccessRulesProtected -or + -not $acl.AreAccessRulesCanonical -or $rules.Count -ne 3){exit 84} + foreach($identity in @($current,$system,$admins)){ + $matches=@($rules | Where-Object {$_.IdentityReference.Value -eq $identity.Value}) + if($matches.Count -ne 1 -or $matches[0].AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow){exit 84} + $expected=if($identity.Value -eq $current.Value){[Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize}else{[Security.AccessControl.FileSystemRights]::FullControl} + $expectedInheritance=if($entry.Directory){[Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit}else{[Security.AccessControl.InheritanceFlags]::None} + if($matches[0].FileSystemRights -ne $expected -or $matches[0].InheritanceFlags -ne $expectedInheritance -or + $matches[0].PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None -or $matches[0].IsInherited){exit 84} + } + } +} catch { exit 84 } +try { + $stream=[IO.FileStream]::new($executable,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { if($stream.ReadByte() -lt 0){exit 85} } finally { $stream.Dispose() } +} catch { exit 85 } +`; + +const encodedWindowsStagedPackagePreflight = Buffer.from( + windowsStagedPackagePreflightSource, + 'utf16le', +).toString('base64'); + +export const assertWindowsStagedPackagePreflightResult = result => { + if (result?.error || result?.signal || !Buffer.isBuffer(result?.stdout) + || result.stdout.length !== 0 || !Buffer.isBuffer(result?.stderr) + || result.stderr.length !== 0) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'preflight-invocation'); + } + if (result.status === 0) return; + if (result.status === 83) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'descendant-enumeration'); + } + if (result.status === 85) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'executable-read'); + } + if ([80, 81, 82, 84].includes(result.status)) { + fail('artifact-type', 'ordinary-user-preflight', 'authority-contract'); + } + fail('artifact-inaccessible', 'ordinary-user-preflight', 'unexpected-exit'); +}; + +const runWindowsStagedPackagePreflight = paths => { + const powershell = windowsPowerShell51Path(); + const result = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedWindowsStagedPackagePreflight, + ], { + shell: false, + windowsHide: true, + timeout: 60_000, + maxBuffer: 1024, + env: { + SystemRoot: process.env.SystemRoot, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: paths.parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: paths.leaf, + }, + }); + assertWindowsStagedPackagePreflightResult(result); +}; + +const canonicalizeEntry = async (kind, path) => canonicalizeWindowsFixtureEntry({ + entryKind: kind, + entryPath: path, + powershellPath: windowsPowerShell51Path(), +}); + +export const validateWindowsStagedPackage = async ({ + environment = process.env, + expectedArchitecture = process.arch, + inspectPath, + canonicalize = canonicalizeEntry, + readHeader = readPeHeader, + preflight = runWindowsStagedPackagePreflight, +} = {}) => { + const paths = parseWindowsStagedPackageContract(environment); + const inspect = inspectPath ?? (await import('node:fs/promises')).lstat; + const entries = [ + ['directory', paths.runnerTemp], + ['directory', paths.parent], + ['directory', paths.root], + ['directory', paths.resources], + ['file', paths.applicationArchive], + ['file', paths.executable], + ]; + for (const [kind, path] of entries) { + let stats; + try { stats = await inspect(path); } catch (error) { + if (error?.code === 'ENOENT') fail('artifact-missing', 'staged-tree'); + fail('artifact-inaccessible', 'staged-tree'); + } + if (stats.isSymbolicLink() + || (kind === 'directory' ? !stats.isDirectory() : !stats.isFile())) { + fail('artifact-type', 'staged-tree'); + } + let canonical; + try { canonical = await canonicalize(kind, path); } catch { fail('artifact-type', 'staged-tree'); } + if (!canonical || typeof canonical.path !== 'string' + || canonical.path.toUpperCase() !== path.toUpperCase()) fail('artifact-type', 'staged-tree'); + } + assertPackagedWindowsPeArchitecture(await readHeader(paths.executable), expectedArchitecture); + try { await preflight(paths); } catch (error) { + if (error instanceof WindowsArtifactFailure) throw error; + fail('artifact-inaccessible', 'ordinary-user-preflight', 'preflight-invocation'); + } + return paths; +}; + +export const classifyWindowsArtifactFailure = error => { + if (error instanceof WindowsArtifactFailure + && WINDOWS_ARTIFACT_FAILURE_CATEGORIES.includes(error.category)) return error.category; + if (error?.code === 'ENOENT') return 'artifact-missing'; + if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'artifact-inaccessible'; + return 'spawn-failed'; +}; + +export const describeWindowsArtifactFailure = (error, fallbackPhase = 'application-runtime') => { + const phase = error instanceof WindowsArtifactFailure + && WINDOWS_ARTIFACT_FAILURE_PHASES.includes(error.phase) + ? error.phase + : (WINDOWS_ARTIFACT_FAILURE_PHASES.includes(fallbackPhase) + ? fallbackPhase : 'application-runtime'); + const preSpawn = !['application-spawn', 'application-runtime', 'result-verify'].includes(phase); + const category = error instanceof WindowsArtifactFailure + ? classifyWindowsArtifactFailure(error) + : (preSpawn ? (error?.code === 'ENOENT' ? 'artifact-missing' : 'artifact-inaccessible') + : classifyWindowsArtifactFailure(error)); + const fixedErrorSubphase = error instanceof WindowsArtifactFailure + && isAllowedSubphase(phase, error.subphase) + ? error.subphase : undefined; + const subphase = phase === 'ordinary-user-preflight' + ? (fixedErrorSubphase ?? 'preflight-invocation') + : fixedErrorSubphase; + return Object.freeze({ category, phase, ...(subphase ? { subphase } : {}) }); +}; diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs new file mode 100644 index 000000000..9baa63f5b --- /dev/null +++ b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs @@ -0,0 +1,1883 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { link, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, win32 } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + assertPackagedWindowsPeArchitecture, + assertWindowsStagedPackagePreflightResult, + classifyWindowsArtifactFailure, + describeWindowsArtifactFailure, + packagedConnectArtifactSensitiveNeedles, + parseWindowsStagedPackageContract, + parseWindowsStagedPackageHandoff, + validateWindowsStagedPackage, + WINDOWS_ARTIFACT_FAILURE_CATEGORIES, + WINDOWS_ARTIFACT_FAILURE_PHASES, + WINDOWS_ARTIFACT_FAILURE_SUBPHASES, + WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, + WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + WindowsArtifactFailure, +} from './windows-packaged-connect-staging.mjs'; +import { windowsPowerShell51Path } from './windows-fixture-acl.mjs'; + +const windowsTest = process.platform === 'win32' ? test : test.skip; +const orchestratorPath = fileURLToPath(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url)); +const taskkillPath = String.raw`C:\Windows\System32\taskkill.exe`; +const hostPreflightSubphases = Object.freeze([ + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-capture-contract', + 'host-staging-handoff', +]); +const launcherAuthoritySubphases = Object.freeze([ + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', +]); +const fixedHostDiagnosticSubphases = Object.freeze([ + ...hostPreflightSubphases, + ...launcherAuthoritySubphases, +]); +const launcherInvocationSubphases = Object.freeze([ + 'host-node-path-binding', + ...launcherAuthoritySubphases, +]); +const positiveHostNodeProducerSubphases = Object.freeze([ + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', +]); +const captureRedirectionFailurePredicates = Object.freeze([ + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'post-redirection-identity', + 'capture-owner', + 'dacl-canonicality', + 'unauthorized-writer', + 'link-path-type', + 'identity-replacement', + 'capture-content', + 'cleanup', +]); +const captureRedirectionReportedPredicates = Object.freeze([ + ...captureRedirectionFailurePredicates, + 'diagnostic-contract', +]); +const captureProducerExitBuckets = Object.freeze(['zero', 'forced-23', 'other']); +const captureProducerOutputStates = Object.freeze(['exact-expected', 'empty', 'other-bounded']); +const captureRedirectionResultPredicates = Object.freeze([ + 'redirect-child-exit', + 'capture-content', +]); +const captureRedirectionDiagnosticPattern = new RegExp( + '^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=([a-z-]+):cleanup=none\\r?\\n$', + 'u', +); +const captureRedirectionResultDiagnosticPattern = new RegExp( + '^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=([a-z-]+):exit=([a-z0-9-]+)' + + ':out=([a-z-]+):err=([a-z-]+):cleanup=none\\r?\\n$', + 'u', +); +const captureRedirectionAcceptedPattern = + /^PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:accepted\r?\n$/u; +const hostileDiagnosticPattern = /[A-Z]:\\|\\\\|S-1-5-|account-name|username|stdout|stderr|exception|native-text|command-line|sddl|exit-code|environment-secret/iu; +const uppercasePathDiagnosticPattern = /\bPATH\b/u; +const hasHostileDiagnosticEvidence = value => hostileDiagnosticPattern.test(value) + || uppercasePathDiagnosticPattern.test(value); +const assertNoHostileDiagnosticEvidence = value => { + assert.doesNotMatch(value, hostileDiagnosticPattern); + assert.doesNotMatch(value, uppercasePathDiagnosticPattern); +}; + +const parent = String.raw`C:\runner-temp\propr-connect-packaged-stage`; +const leaf = 'propr-connect-package-0123456789abcdef0123456789abcdef'; +const environment = { + RUNNER_TEMP: String.raw`C:\runner-temp`, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: leaf, +}; +const handoffFor = ({ + RUNNER_TEMP = environment.RUNNER_TEMP, + PROPR_DESKTOP_CONNECT_STAGING_PARENT = environment.PROPR_DESKTOP_CONNECT_STAGING_PARENT, + PROPR_DESKTOP_CONNECT_STAGING_LEAF = environment.PROPR_DESKTOP_CONNECT_STAGING_LEAF, +} = {}) => '--propr-windows-staged-contract=' + Buffer.from([ + RUNNER_TEMP, + PROPR_DESKTOP_CONNECT_STAGING_PARENT, + PROPR_DESKTOP_CONNECT_STAGING_LEAF, +].join('\n'), 'utf8').toString('base64'); +const regularFile = { + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, +}; +const regularDirectory = { + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, +}; + +const peFixture = architecture => { + const bytes = Buffer.alloc(256); + bytes.write('MZ', 0, 'ascii'); + bytes.writeUInt32LE(0x80, 0x3c); + bytes.write('PE\0\0', 0x80, 'ascii'); + bytes.writeUInt16LE(architecture === 'arm64' ? 0xaa64 : 0x8664, 0x84); + return bytes; +}; + +const processExists = processId => { + try { + process.kill(processId, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +}; + +const waitForProcessExit = async (processId, timeoutMilliseconds = 5_000) => { + const deadline = Date.now() + timeoutMilliseconds; + while (processExists(processId) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 25)); + } + return !processExists(processId); +}; + +const startNativeNodeTree = async () => { + const rootSource = String.raw` +const { spawn } = require('node:child_process'); +const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + shell: false, + windowsHide: true, + stdio: 'ignore', +}); +process.stdout.write(String(descendant.pid) + '\n'); +setInterval(() => {}, 1000); +`; + const root = spawn(process.execPath, ['-e', rootSource], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const descendantProcessId = await new Promise((resolve, reject) => { + let output = ''; + const timeout = setTimeout(() => reject(new Error('native process tree did not start')), 5_000); + root.once('error', error => { + clearTimeout(timeout); + reject(error); + }); + root.stdout.on('data', chunk => { + output += chunk.toString('ascii'); + const newline = output.indexOf('\n'); + if (newline < 0) return; + clearTimeout(timeout); + const value = output.slice(0, newline).trim(); + if (!/^[1-9][0-9]{0,9}$/u.test(value)) reject(new Error('native descendant pid was invalid')); + else resolve(Number(value)); + }); + }); + return { root, descendantProcessId }; +}; + +const terminateTreeAfterTest = processId => { + if (!Number.isSafeInteger(processId) || processId < 1 || !processExists(processId)) return; + spawnSync(taskkillPath, ['/PID', String(processId), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + timeout: 5_000, + }); +}; + +const runLauncherAuthorityTest = (path, testCase = 'normal', retargetPath) => { + const arguments_ = [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'launcher-authority', + '-LauncherAuthorityTestCase', + testCase, + '-LauncherAuthorityTestPath', + path, + ]; + if (retargetPath !== undefined) { + arguments_.push('-LauncherAuthorityTestRetargetPath', retargetPath); + } + return spawnSync(windowsPowerShell51Path(), arguments_, { + shell: false, + windowsHide: true, + timeout: 15_000, + }); +}; + +const runHostNodeProducerTest = testCase => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'host-node-producer', + '-HostNodeProducerTestCase', + testCase, +], { + shell: false, + windowsHide: true, + timeout: 10_000, +}); + +const runCaptureParserTest = ( + path, + authorityCase = 'existing', + environmentOverrides = {}, +) => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', orchestratorPath, + '-Architecture', process.arch, + '-LifecycleTestMode', 'capture-parser', + '-CaptureParserTestPath', path, + '-CaptureParserAuthorityTestCase', authorityCase, +], { + shell: false, + windowsHide: true, + timeout: 10_000, + env: { ...process.env, ...environmentOverrides }, +}); + +const runCaptureRedirectionTest = (producerTestCase = 'success') => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', orchestratorPath, + '-Architecture', process.arch, + '-LifecycleTestMode', 'capture-redirection', + '-CaptureRedirectionProducerTestCase', producerTestCase, +], { + shell: false, + windowsHide: true, + timeout: 45_000, +}); + +const failCaptureRedirectionTest = result => { + let evidence = 'predicate=diagnostic-contract'; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 256) { + const diagnostic = result.stderr.toString('utf8'); + const resultMatch = captureRedirectionResultDiagnosticPattern.exec(diagnostic); + const predicateMatch = captureRedirectionDiagnosticPattern.exec(diagnostic); + if (resultMatch + && captureRedirectionResultPredicates.includes(resultMatch[1]) + && captureProducerExitBuckets.includes(resultMatch[2]) + && captureProducerOutputStates.includes(resultMatch[3]) + && captureProducerOutputStates.includes(resultMatch[4]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `predicate=${resultMatch[1]}:exit=${resultMatch[2]}` + + `:out=${resultMatch[3]}:err=${resultMatch[4]}`; + } else if (predicateMatch + && captureRedirectionFailurePredicates.includes(predicateMatch[1]) + && !captureRedirectionResultPredicates.includes(predicateMatch[1]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `predicate=${predicateMatch[1]}`; + } + } + assert.ok(captureRedirectionReportedPredicates.includes(evidence.slice('predicate='.length).split(':')[0])); + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:failed:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +test('capture redirection mismatch reporting is total and redacted for each launch predicate', () => { + const resultFor = stderr => ({ + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }); + const assertDiagnosticContract = (result, label) => assert.throws( + () => failCaptureRedirectionTest(result), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + ':failed:predicate=diagnostic-contract' + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + label, + ); + for (const predicate of ['redirect-open', 'redirect-timeout']) { + const diagnostic = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=${predicate}:cleanup=none\r\n`; + assert.throws( + () => failCaptureRedirectionTest(resultFor(diagnostic)), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + `:failed:predicate=${predicate}` + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + predicate, + ); + + assertDiagnosticContract(resultFor( + diagnostic + String.raw`C:\hostile\capture S-1-5-21 account-name username stdout stderr exception native-text command-line sddl exit-code environment-secret`, + ), `${predicate}-hostile-output`); + + assertDiagnosticContract({ + error: new Error(String.raw`C:\hostile\exception`), + signal: 'hostile-signal', + status: null, + stdout: Buffer.from('environment-secret'), + stderr: Buffer.from(diagnostic), + }, `${predicate}-totality`); + } + + for (const [predicate, exit, out, err] of [ + ['redirect-child-exit', 'zero', 'exact-expected', 'exact-expected'], + ['redirect-child-exit', 'forced-23', 'empty', 'other-bounded'], + ['capture-content', 'other', 'other-bounded', 'empty'], + ]) { + const diagnostic = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=${predicate}:exit=${exit}:out=${out}:err=${err}:cleanup=none\r\n`; + assert.throws( + () => failCaptureRedirectionTest(resultFor(diagnostic)), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + `:failed:predicate=${predicate}:exit=${exit}:out=${out}:err=${err}` + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + `${predicate}-${exit}-${out}-${err}`, + ); + assertDiagnosticContract(resultFor( + diagnostic + String.raw`C:\hostile\capture S-1-5-21 environment-secret`, + ), `${predicate}-hostile-output`); + } + + for (const diagnostic of [ + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:cleanup=none\r\n', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=23:out=exact-expected:err=exact-expected' + + ':cleanup=none\r\n', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=other:out=raw-value:err=empty' + + ':cleanup=none\r\n', + ]) assertDiagnosticContract(resultFor(diagnostic), 'result-attribution-totality'); +}); + +const assertLauncherAuthorityRejected = (result, category, subphase) => { + const expected = `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`; + const diagnostic = Buffer.isBuffer(result.stderr) + && result.stderr.length <= 512 ? result.stderr.toString('utf8').trim() : ''; + if (result.error || result.signal !== null || result.status !== 1 + || !Buffer.isBuffer(result.stdout) || result.stdout.length !== 0 + || diagnostic !== expected || hasHostileDiagnosticEvidence(diagnostic)) { + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:rejection-diagnostic-failed` + + `:category=${category}:phase=ordinary-user-preflight:subphase=${subphase}`, + ); + error.stack = error.message; + throw error; + } +}; + +const failAcceptedLauncherCase = (caseName, result) => { + const fallback = 'category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=host-state-contract'; + let evidence = fallback; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 512) { + const diagnostic = result.stderr.toString('utf8').trim(); + const match = /^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=(artifact-missing|artifact-inaccessible|artifact-type|architecture-mismatch|spawn-failed):phase=(ordinary-user-preflight):subphase=([a-z-]+):cleanup=none$/u.exec(diagnostic); + if (match && launcherInvocationSubphases.includes(match[3]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `category=${match[1]}:phase=${match[2]}:subphase=${match[3]}`; + } + } + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted-case-failed:case=${caseName}:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +const assertLauncherAuthorityAccepted = (result, caseName) => { + if (result.error || result.signal !== null || result.status !== 0 + || !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr) + || result.stdout.toString('utf8').trim() + !== 'PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted' + || result.stderr.length !== 0) { + failAcceptedLauncherCase(caseName, result); + } +}; + +const failPositiveHostNodeProducer = result => { + const fallback = 'category=artifact-inaccessible:phase=ordinary-user-preflight' + + ':subphase=host-node-command-cardinality'; + let evidence = fallback; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 512) { + const diagnostic = result.stderr.toString('utf8').trim(); + const match = /^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=(artifact-inaccessible|artifact-type):phase=(ordinary-user-preflight):subphase=([a-z-]+):cleanup=none$/u.exec(diagnostic); + if (match && positiveHostNodeProducerSubphases.includes(match[3]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `category=${match[1]}:phase=${match[2]}:subphase=${match[3]}`; + } + } + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:positive-case-failed:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +const assertPositiveHostNodeProducer = result => { + if (result.error || result.signal !== null || result.status !== 0 + || !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr) + || result.stdout.toString('utf8').trim() + !== 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:accepted' + || result.stderr.length !== 0) { + failPositiveHostNodeProducer(result); + } +}; + +test('positive host Node producer failures expose only fixed allowlisted evidence', () => { + for (const category of ['artifact-inaccessible', 'artifact-type']) { + for (const subphase of positiveHostNodeProducerSubphases) { + const result = { + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from( + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ), + }; + assert.throws( + () => failPositiveHostNodeProducer(result), + { + message: 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST' + + `:positive-case-failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}`, + }, + ); + } + } + + const fallback = 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST' + + ':positive-case-failed:category=artifact-inaccessible' + + ':phase=ordinary-user-preflight:subphase=host-node-command-cardinality'; + for (const stderr of [ + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=spawn-failed' + + ':phase=ordinary-user-preflight:subphase=host-node-source:cleanup=none', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=application-spawn:subphase=host-node-source:cleanup=none', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=ordinary-user-preflight:subphase=host-node-path-binding:cleanup=none', + String.raw`C:\hostile\node.exe \\hostile PATH account-name S-1-5-21 stdout stderr exception native-text environment-secret`, + ]) { + assert.throws( + () => failPositiveHostNodeProducer({ + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }), + { message: fallback }, + ); + } + assertNoHostileDiagnosticEvidence(fallback); +}); + +test('hostile diagnostics reject uppercase PATH without matching fixed path subphases', () => { + assert.equal( + hasHostileDiagnosticEvidence( + 'category=artifact-type:phase=ordinary-user-preflight:subphase=host-node-path-binding', + ), + false, + ); + assert.equal(hasHostileDiagnosticEvidence('PATH'), true); +}); + +const validationOptions = overrides => ({ + environment, + expectedArchitecture: 'arm64', + inspectPath: async path => path.endsWith('.exe') || path.endsWith('.asar') + ? regularFile + : regularDirectory, + canonicalize: async (kind, path) => ({ path }), + readHeader: async () => peFixture('arm64'), + preflight: async () => {}, + ...overrides, +}); + +describe('packaged Windows Connect staging contract', () => { + test('accepts only the exact generated leaf below the fixed canonical staging parent', () => { + const contract = parseWindowsStagedPackageContract(environment); + assert.equal(contract.parent, parent); + assert.equal(contract.root, win32.join(parent, leaf)); + assert.equal(contract.executable, win32.join(parent, leaf, 'propr-desktop.exe')); + + for (const [invalid, subphase] of [ + [{}, 'runner-temp-input-shape'], + [{ PROPR_DESKTOP_CONNECT_STAGED_ROOT: contract.root }, 'runner-temp-input-shape'], + [{ ...environment, RUNNER_TEMP: 'runner-temp' }, 'runner-temp-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: `${parent}\\` }, 'staging-parent-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`\\server\share\propr-connect-packaged-stage` }, 'staging-parent-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`C:\runner-temp\x\propr-connect-packaged-stage` }, 'parent-to-runner-binding'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`C:\runner-temp\other` }, 'fixed-parent-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: '../package' }, 'generated-stage-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: 'propr-connect-package-ABCDEF0123456789abcdef0123456789' }, 'generated-stage-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: 'propr-connect-package-0123' }, 'generated-stage-leaf'], + ]) { + assert.throws( + () => parseWindowsStagedPackageContract(invalid), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-type' + && error.phase === 'staged-contract' + && error.subphase === subphase, + ); + } + }); + + test('accepts one bounded parent-owned handoff and rejects every other input shape', () => { + const contract = parseWindowsStagedPackageHandoff([handoffFor()]); + assert.equal(contract.runnerTemp, environment.RUNNER_TEMP); + assert.equal(contract.parent, parent); + assert.equal(contract.leaf, leaf); + for (const arguments_ of [ + [], + [handoffFor(), handoffFor()], + ['--propr-windows-staged-contract=not-base64'], + ['--different-contract=AAAA'], + [`--propr-windows-staged-contract=${'A'.repeat(16_388)}`], + ['--propr-windows-staged-contract=' + Buffer.from('one\ntwo', 'utf8').toString('base64')], + ]) { + assert.throws( + () => parseWindowsStagedPackageHandoff(arguments_), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-type' + && error.phase === 'staged-contract' + && error.subphase === 'runner-temp-input-shape', + ); + } + }); + + test('emits only fixed staged-contract predicate evidence', () => { + const diagnostics = WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.map(subphase => { + const failure = new WindowsArtifactFailure('artifact-type', 'staged-contract', subphase); + return JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(failure, 'application-spawn'), + }); + }); + assert.deepEqual(diagnostics, WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.map(subphase => ( + `{"event":"packaged_connect.artifact_failed","category":"artifact-type",` + + `"phase":"staged-contract","subphase":"${subphase}"}` + ))); + assertNoHostileDiagnosticEvidence(diagnostics.join('\n')); + + const hostileSubphase = new WindowsArtifactFailure( + 'artifact-type', + 'staged-contract', + String.raw`C:\secret\account-name-S-1-5-21-123`, + ); + assert.equal(hostileSubphase.subphase, undefined); + assert.deepEqual(describeWindowsArtifactFailure(hostileSubphase, 'staged-contract'), { + category: 'artifact-type', + phase: 'staged-contract', + }); + assertNoHostileDiagnosticEvidence(hostileSubphase.message); + }); + + test('rejects missing, inaccessible, reparse, wrong-type, and noncanonical entries before preflight', async () => { + let preflightCalls = 0; + const assertCategory = async (inspectPath, canonicalize, category) => { + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ + inspectPath, + canonicalize: canonicalize ?? (async (kind, path) => ({ path })), + preflight: async () => { preflightCalls += 1; }, + })), + error => error instanceof WindowsArtifactFailure && error.category === category, + ); + }; + await assertCategory(async () => { const error = new Error('sensitive path'); error.code = 'ENOENT'; throw error; }, null, 'artifact-missing'); + await assertCategory(async () => { const error = new Error('sensitive path'); error.code = 'EACCES'; throw error; }, null, 'artifact-inaccessible'); + await assertCategory(async () => ({ ...regularDirectory, isSymbolicLink: () => true }), null, 'artifact-type'); + await assertCategory(async () => regularFile, null, 'artifact-type'); + await assertCategory( + async path => path.endsWith('.exe') || path.endsWith('.asar') ? regularFile : regularDirectory, + async (kind, path) => ({ path: `${path}-alias` }), + 'artifact-type', + ); + assert.equal(preflightCalls, 0, 'a rejected package must fail before the access preflight'); + }); + + test('proves target PE architecture and ordinary-user access before returning the executable', async () => { + let preflightCalls = 0; + const result = await validateWindowsStagedPackage(validationOptions({ + preflight: async paths => { + preflightCalls += 1; + assert.equal(paths.executable, win32.join(parent, leaf, 'propr-desktop.exe')); + }, + })); + assert.equal(result.root, win32.join(parent, leaf)); + assert.equal(preflightCalls, 1); + + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ readHeader: async () => peFixture('x64') })), + error => error instanceof WindowsArtifactFailure && error.category === 'architecture-mismatch', + ); + }); + + test('maps a hostile preflight callback throw totally and redacts all supplied evidence', async () => { + const hostile = new Error( + String.raw`hostile exception C:\secret\package S-1-5-21-123 account-name raw stdout raw stderr environment-secret`, + ); + hostile.stdout = 'raw stdout'; + hostile.stderr = 'raw stderr'; + hostile.environment = { SECRET: 'environment-secret' }; + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ + preflight: async () => { throw hostile; }, + })), + error => { + assert.ok(error instanceof WindowsArtifactFailure); + assert.equal(error.category, 'artifact-inaccessible'); + assert.equal(error.phase, 'ordinary-user-preflight'); + assert.equal(error.subphase, 'preflight-invocation'); + const diagnostic = JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(error, 'ordinary-user-preflight'), + }); + assert.equal( + diagnostic, + '{"event":"packaged_connect.artifact_failed","category":"artifact-inaccessible","phase":"ordinary-user-preflight","subphase":"preflight-invocation"}', + ); + assertNoHostileDiagnosticEvidence(`${error.message}\n${diagnostic}`); + return true; + }, + ); + }); + + test('keeps PE type and architecture failures distinct', () => { + assert.doesNotThrow(() => assertPackagedWindowsPeArchitecture(peFixture('arm64'), 'arm64')); + assert.throws( + () => assertPackagedWindowsPeArchitecture(Buffer.from('not a PE'), 'arm64'), + error => error.category === 'artifact-type', + ); + assert.throws( + () => assertPackagedWindowsPeArchitecture(peFixture('x64'), 'arm64'), + error => error.category === 'architecture-mismatch', + ); + }); + + test('maps hostile exceptions to a fixed path-free allowlist', () => { + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_CATEGORIES, [ + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed', + ]); + const hostile = new Error(String.raw`spawn C:\secret\propr-desktop.exe ENOENT --token=secret`); + hostile.code = 'ENOENT'; + assert.equal(classifyWindowsArtifactFailure(hostile), 'artifact-missing'); + assert.equal(classifyWindowsArtifactFailure(new Error('username SID environment stack')), 'spawn-failed'); + for (const category of WINDOWS_ARTIFACT_FAILURE_CATEGORIES) { + const failure = new WindowsArtifactFailure(category, 'staged-tree'); + assert.equal(classifyWindowsArtifactFailure(failure), category); + assert.doesNotMatch(failure.message, /[A-Z]:\\|S-1-5-|--|username|environment|stack/iu); + } + const invalidSubphase = new WindowsArtifactFailure( + 'artifact-inaccessible', + 'ordinary-user-preflight', + String.raw`C:\secret\account-name-S-1-5-21-123`, + ); + assert.equal(invalidSubphase.subphase, undefined); + assert.doesNotMatch(invalidSubphase.message, /[A-Z]:\\|S-1-5-|account-name/iu); + }); + + test('classifies fixed phases without collapsing pre-spawn failures into spawn', () => { + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_PHASES, [ + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'result-verify', + ]); + assert.deepEqual(WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, [ + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding', + ]); + assert.deepEqual( + describeWindowsArtifactFailure(new Error(String.raw`C:\secret\account`), 'fixture-setup'), + { category: 'artifact-inaccessible', phase: 'fixture-setup' }, + ); + assert.deepEqual( + describeWindowsArtifactFailure( + new WindowsArtifactFailure( + 'artifact-type', + 'ordinary-user-preflight', + 'authority-contract', + ), + 'application-spawn', + ), + { + category: 'artifact-type', + phase: 'ordinary-user-preflight', + subphase: 'authority-contract', + }, + ); + assert.deepEqual( + describeWindowsArtifactFailure(new Error('--token secret'), 'application-spawn'), + { category: 'spawn-failed', phase: 'application-spawn' }, + ); + }); + + test('maps every preflight transport and exit result to fixed subphase evidence', () => { + assert.deepEqual(WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, [ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', + ]); + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_SUBPHASES, [ + ...WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + ...WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, + ]); + const clean = status => ({ + status, + error: undefined, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + assert.doesNotThrow(() => assertWindowsStagedPackagePreflightResult(clean(0))); + + for (const [status, category, subphase] of [ + [80, 'artifact-type', 'authority-contract'], + [81, 'artifact-type', 'authority-contract'], + [82, 'artifact-type', 'authority-contract'], + [83, 'artifact-inaccessible', 'descendant-enumeration'], + [84, 'artifact-type', 'authority-contract'], + [85, 'artifact-inaccessible', 'executable-read'], + [1, 'artifact-inaccessible', 'unexpected-exit'], + [86, 'artifact-inaccessible', 'unexpected-exit'], + [null, 'artifact-inaccessible', 'unexpected-exit'], + ]) { + assert.throws( + () => assertWindowsStagedPackagePreflightResult(clean(status)), + error => error instanceof WindowsArtifactFailure + && error.category === category + && error.phase === 'ordinary-user-preflight' + && error.subphase === subphase, + ); + } + + const invocationFailures = [ + { ...clean(null), error: new Error(String.raw`C:\secret\invoke.exe`) }, + { ...clean(null), signal: 'SIGTERM' }, + { ...clean(0), stdout: Buffer.from('raw stdout account-name') }, + { ...clean(0), stderr: Buffer.from('raw stderr S-1-5-21-123') }, + { ...clean(0), stdout: 'not-a-buffer' }, + { ...clean(0), stderr: 'not-a-buffer' }, + ]; + for (const result of invocationFailures) { + assert.throws( + () => assertWindowsStagedPackagePreflightResult(result), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-inaccessible' + && error.phase === 'ordinary-user-preflight' + && error.subphase === 'preflight-invocation', + ); + } + }); + + test('preflight diagnostics exclude path, SID, account name, stdout, and stderr evidence', () => { + const clean = status => ({ + status, + error: undefined, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + const hostileResult = { + status: 85, + error: new Error(String.raw`C:\runner-temp\secret\propr-desktop.exe account-name S-1-5-21-123`), + signal: null, + stdout: Buffer.from('raw stdout account-name'), + stderr: Buffer.from(String.raw`raw stderr C:\secret S-1-5-21-123`), + }; + const diagnosticFor = result => { + try { + assertWindowsStagedPackagePreflightResult(result); + assert.fail('the preflight result must fail'); + } catch (error) { + return JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(error, 'ordinary-user-preflight'), + }); + } + }; + const diagnostics = [ + diagnosticFor(hostileResult), + diagnosticFor(clean(83)), + diagnosticFor(clean(85)), + diagnosticFor(clean(86)), + diagnosticFor(clean(84)), + ]; + assert.deepEqual( + diagnostics.map(diagnostic => JSON.parse(diagnostic).subphase), + [ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', + ], + ); + assertNoHostileDiagnosticEvidence(diagnostics.join('\n')); + }); + + test('scopes staged-root and executable leak needles to Windows', () => { + const options = { + artifactRoot: String.raw`C:\runner-temp\stage\leaf`, + binaryPath: String.raw`C:\runner-temp\stage\leaf\propr-desktop.exe`, + stagedContract: { + runnerTemp: String.raw`C:\runner-temp`, + parent: String.raw`C:\runner-temp\stage`, + leaf: 'leaf', + }, + stagedHandoff: handoffFor(), + }; + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'darwin', ...options }), []); + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'linux', ...options }), []); + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'win32', ...options }), [ + options.artifactRoot, + options.binaryPath, + options.stagedContract.runnerTemp, + options.stagedContract.parent, + options.stagedContract.leaf, + options.stagedHandoff, + ]); + }); +}); + +test('the workflow stages before alternate credentials and the harness preflights before application spawn', async () => { + const workflow = await readFile(new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url), 'utf8'); + const orchestrator = await readFile(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url), 'utf8'); + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match(workflow, /run-packaged-windows-connect-smoke\.ps1\s+-Architecture '\$\{\{ matrix\.arch \}\}'/u); + assert.doesNotMatch(workflow, /Start-Process|Get-Content|New-LocalUser/u); + + const copy = orchestrator.indexOf('Copy-Item -LiteralPath $entry.FullName'); + const acl = orchestrator.indexOf('Set-StagedEntryAcl $item'); + const alternateLaunch = orchestrator.indexOf('$process = Start-Process'); + const nativeAuthorityTests = workflow.indexOf( + 'node --test apps/desktop/scripts/windows-packaged-connect-staging.test.mjs', + ); + const packageStep = workflow.indexOf('npm run desktop:package'); + const packagedLaunch = workflow.indexOf('run-packaged-windows-connect-smoke.ps1'); + assert.ok(copy >= 0 && copy < acl && acl < alternateLaunch); + assert.ok(nativeAuthorityTests >= 0 + && nativeAuthorityTests < packageStep + && packageStep < packagedLaunch); + assert.doesNotMatch(orchestrator.slice(alternateLaunch, alternateLaunch + 700), /\s-Wait(?:\s|`)/u); + assert.match(orchestrator, /Assert-PeArchitecture \$sourceExecutable \$Architecture/u); + assert.match(orchestrator, /Assert-PeArchitecture \$stagedExecutable \$Architecture/u); + assert.match(orchestrator, /FileSystemRights\]::ReadAndExecute/u); + assert.match(orchestrator, /FileSystemRights\]::FullControl/u); + assert.match(orchestrator, /SetAccessRuleProtection\(\$true, \$false\)/u); + assert.match(orchestrator, /SetOwner\(\$Administrators\)/u); + assert.match(orchestrator, /\[Diagnostics\.Process\]::new\(\)/u); + assert.match(orchestrator, /\$taskkillExecutable = 'C:\\Windows\\System32\\taskkill\.exe'/u); + assert.match( + orchestrator, + /\$taskkillStart\.Arguments = \[String\]::Join\(' ', \[string\[\]\]@\('\/PID', \$processIdText, '\/T', '\/F'\)\)/u, + ); + assert.match(orchestrator, /\$taskkillStart\.UseShellExecute = \$false/u); + assert.match(orchestrator, /\$processIdText -cnotmatch '\^\[1-9\]\[0-9\]\{0,9\}\$'/u); + assert.match(orchestrator, /\$taskkillProcess\.WaitForExit\(\$terminationTimeoutMilliseconds\)/u); + assert.match(orchestrator, /Task\]::WaitAll\([\s\S]*?\$streamCloseTimeoutMilliseconds/u); + assert.doesNotMatch(orchestrator, /(?:cmd(?:\.exe)?|powershell(?:\.exe)?)['"]?\s+\/c[\s\S]*?taskkill/iu); + assert.match(orchestrator, /WaitForExit\(\$cleanupTimeoutMilliseconds\)/u); + assert.match(orchestrator, /if\(!\$cleanupProcess\.WaitForExit[\s\S]*?\$cleanupProcess\.Kill\(\)[\s\S]*?WaitForExit\(\$terminationTimeoutMilliseconds\)/u); + assert.match(orchestrator, /Remove-Item -LiteralPath \$root -Recurse/u); + assert.doesNotMatch(orchestrator, /Remove-Item -LiteralPath \$parent -Recurse/u); + assert.match(orchestrator, /\$createdAccount\.SID\.Value -cne \$testUserSid\.Value/u); + assert.match(orchestrator, /\$administratorsSid\.Translate\(\[Security\.Principal\.NTAccount\]\)/u); + assert.match(orchestrator, /\.psbase\.Invoke\('IsMember', \$ordinaryUserEntry\.Path\)/u); + assert.doesNotMatch(orchestrator, /Get-LocalGroupMember/u); + assert.doesNotMatch(orchestrator, /Get-Content|Write-(?:Host|Error|Verbose|Debug|Information)|GITHUB_WORKSPACE/u); + const hostNodeProducer = orchestrator.slice( + orchestrator.indexOf('function Get-ValidatedHostNodePath'), + orchestrator.indexOf('function Stop-SpawnedProcess'), + ); + const producerTransitions = [ + ['host-node-command-cardinality', 'Get-Command node.exe'], + ['host-node-command-type', '$candidate -is [System.Management.Automation.ApplicationInfo]'], + ['host-node-source', '@($candidate.Source)'], + ]; + for (let index = 0; index < producerTransitions.length; index += 1) { + const [subphase, operation] = producerTransitions[index]; + const transition = hostNodeProducer.indexOf(`Set-OrdinaryUserPreflightSubphase '${subphase}'`); + const operationIndex = hostNodeProducer.indexOf(operation); + const nextTransition = index + 1 < producerTransitions.length + ? hostNodeProducer.indexOf( + `Set-OrdinaryUserPreflightSubphase '${producerTransitions[index + 1][0]}'`, + ) + : hostNodeProducer.length; + assert.ok(transition >= 0 && transition < operationIndex && operationIndex < nextTransition, + `${subphase} must cover exactly its producer operation boundary`); + } + assert.match(hostNodeProducer, /Get-Command node\.exe[\s\S]*?-CommandType Application[\s\S]*?-TotalCount 1[\s\S]*?-ErrorAction Stop/u); + assert.match(hostNodeProducer, /\$commandResults\.Count -ne 1[\s\S]*?host-node-command-type[\s\S]*?\$candidate = \$commandResults\[0\][\s\S]*?System\.Management\.Automation\.ApplicationInfo/u); + assert.match(hostNodeProducer, /\$sourceResults\.Count -ne 1[\s\S]*?\$sourceResults\[0\] -is \[string\]/u); + assert.match(hostNodeProducer, /return \$sourceResults\[0\]/u); + assert.doesNotMatch(hostNodeProducer, /validatedSources|StringComparison|foreach \(\$candidate in \$commandResults\)/u); + assert.doesNotMatch(hostNodeProducer, /PSObject\.Properties\['Source'\]/u); + assert.doesNotMatch(hostNodeProducer, /\$env:PATH|Select-Object\s+-First|where(?:\.exe)?/iu); + assert.doesNotMatch(orchestrator, /\$node\s*=\s*['"]node(?:\.exe)?['"]/iu); + const hostBoundary = orchestrator.slice( + orchestrator.indexOf('$node = Get-ValidatedHostNodePath', orchestrator.indexOf("Set-FailurePhase 'staging-acl'")), + orchestrator.indexOf("Set-FailurePhase 'application-spawn'"), + ); + const hostTransitions = [ + ['host-node-path-binding', '$launcherAuthority = Get-TrustedHostLauncher -Path $node'], + ['host-node-launcher-return-authority', '$launcherAuthorityResults = @($launcherAuthority)'], + ['host-capture-contract', '$stdout = Join-Path $authenticatedRunnerTemp'], + ['host-staging-handoff', '$handoffText = [String]::Join'], + ]; + for (let index = 0; index < hostTransitions.length; index += 1) { + const [subphase, operation] = hostTransitions[index]; + const transition = hostBoundary.indexOf(`Set-OrdinaryUserPreflightSubphase '${subphase}'`); + const operationIndex = hostBoundary.indexOf(operation); + const nextTransition = index + 1 < hostTransitions.length + ? hostBoundary.indexOf(`Set-OrdinaryUserPreflightSubphase '${hostTransitions[index + 1][0]}'`) + : hostBoundary.length; + assert.ok(transition >= 0 && transition < operationIndex && operationIndex < nextTransition, + `${subphase} must cover exactly its host operation boundary`); + } + assert.match(orchestrator, /function Set-PrimaryFailureFromException[\s\S]*?\$script:primaryPhase = \$failurePhase[\s\S]*?\$script:primarySubphase = if \(\$failureSubphases -ccontains \$failureSubphase\)/u); + assert.match(orchestrator, /function Get-TrustedHostLauncher[\s\S]*?GetFinalPath\(\$sourceHandle\)[\s\S]*?Open\(\$finalPath, \$true\)[\s\S]*?GetIdentity\(\$authorityHandle\)[\s\S]*?Open\(\$selectedPath, \$false\)/u); + assert.doesNotMatch(hostBoundary, /Get-TrustedHostLauncher \$node/u); + assert.match(orchestrator, /\$node = \$launcherPathProperty\.Value[\s\S]*?-FilePath \$node/u); + assert.match(hostBoundary, /SafeFileHandle[\s\S]*?\.IsInvalid[\s\S]*?\.IsClosed/u); + assert.match(orchestrator, /Start-Process[\s\S]*?finally \{\s*\$launcherAuthority\.Handle\.Dispose\(\)/u); + assert.match(orchestrator, /\$handoffArgument = '--propr-windows-staged-contract=' \+ \[Convert\]::ToBase64String\(\$handoffBytes\)/u); + assert.match(orchestrator, /-ArgumentList @\('scripts\/smoke-packaged-connect\.mjs', \$handoffArgument\)[\s\S]*?-Credential \$credential[\s\S]*?-LoadUserProfile/u); + assert.doesNotMatch(orchestrator, /SetEnvironmentVariable\('PROPR_DESKTOP_CONNECT_STAGING_/u); + assert.match(orchestrator, /FILE_FLAG_OPEN_REPARSE_POINT/u); + assert.match( + orchestrator, + /\[DllImport\("kernel32\.dll", CharSet = CharSet\.Unicode, ExactSpelling = true, SetLastError = true\)\]\s*private static extern SafeFileHandle CreateFileW/u, + ); + assert.match( + orchestrator, + /\[DllImport\("kernel32\.dll", CharSet = CharSet\.Unicode, ExactSpelling = true, SetLastError = true\)\]\s*private static extern uint GetFinalPathNameByHandleW/u, + ); + assert.match(orchestrator, /FILE_ID_INFO[\s\S]*?GetFileInformationByHandleEx[\s\S]*?FileIdInfo = 18/u); + assert.match(orchestrator, /FILE_SHARE_READ\s*\n\s*: FILE_SHARE_READ \| FILE_SHARE_WRITE \| FILE_SHARE_DELETE/u); + assert.match(orchestrator, /\$Path\.Length -gt 259[\s\S]*?\[\\x00-\\x1f\\x7f\]/u); + const selectedPathValidation = orchestrator.slice( + orchestrator.indexOf('function Get-BoundedAbsoluteWindowsPath'), + orchestrator.indexOf('function ConvertFrom-NativeFinalPath'), + ); + const selectedPathPredicateTransitions = [ + ['host-launcher-selected-path-input', '[String]::IsNullOrEmpty($Path)'], + ['host-launcher-selected-path-extra-colon', "$Path.Substring(2).Contains(':')"], + ['host-launcher-selected-path-get-full-path', '$fullPath = [IO.Path]::GetFullPath($Path)'], + ['host-launcher-selected-path-absolute-shape', "$driveAbsolute = $fullPath -cmatch '^[A-Za-z]:\\\\'"], + ['host-launcher-selected-path-canonical-equality', '[String]::Equals($fullPath, $Path'], + ]; + let previousSelectedPathPredicate = -1; + for (const [subphase, predicate] of selectedPathPredicateTransitions) { + const transition = selectedPathValidation.indexOf( + `Set-OrdinaryUserPreflightSubphase '${subphase}'`, + ); + const predicateIndex = selectedPathValidation.indexOf(predicate); + assert.ok(previousSelectedPathPredicate < transition && transition < predicateIndex, + `${subphase} must identify only its selected-path predicate`); + previousSelectedPathPredicate = predicateIndex; + } + assert.match(orchestrator, /function Get-CanonicalItem[\s\S]*?FileAttributes\]::ReparsePoint/u); + assert.match(orchestrator, /function Assert-PackageTreeTypes[\s\S]*?FileAttributes\]::ReparsePoint/u); + const captureAuthority = orchestrator.slice( + orchestrator.indexOf('function Assert-CaptureAuthorityAcl'), + orchestrator.indexOf('function Read-PackagedConnectSmokeFailure'), + ); + const captureParser = orchestrator.slice( + orchestrator.indexOf('function Read-PackagedConnectSmokeFailure'), + orchestrator.indexOf('$hostLauncherNativeSource'), + ); + assert.match(captureParser, /packaged_connect\.artifact_failed/u); + assert.match(captureParser, /packaged_connect\.smoke_failed/u); + assert.doesNotMatch(captureParser, /packaged_connect\.child_failed/u); + const nestedDiagnosticEvents = captureParser.slice( + captureParser.indexOf('$diagnosticEvents = @('), + captureParser.indexOf('$diagnosticCodes = @('), + ); + assert.match(nestedDiagnosticEvents, /'desktop\.renderer\.connect_discovery\.proof'/u); + assert.equal( + (orchestrator.match(/desktop\.renderer\.connect_discovery\.proof/gu) ?? []).length, + 1, + ); + assert.match(captureParser, /Test-UniqueJsonPropertyNames \$jsonLine/u); + assert.match(captureParser, /\[Text\.UTF8Encoding\]::new\(\$false, \$true\)/u); + assert.match(captureAuthority, /\$captureLength -lt 1 -or \$captureLength -gt 65536/u); + assert.match(captureParser, /\$diagnosticRecords\.Count -gt 20/u); + assert.match(captureAuthority, /\$ownerValues -cnotcontains \$owner\.Value/u); + assert.match(captureAuthority, /\$acl\.AreAccessRulesProtected/u); + assert.match(captureAuthority, /\$acl\.AreAccessRulesCanonical/u); + assert.match(captureAuthority, /\$authorizedWriters\.Contains\(\$rule\.IdentityReference\.Value\)/u); + assert.match(captureAuthority, /function Initialize-PrivilegedCaptureFile/u); + assert.match(captureAuthority, /GetSecurityDescriptorSddlForm\(\$sections\)/u); + assert.match( + captureAuthority, + /SecurityDescriptor = \(Get-CaptureAuthorityDescriptor \$Path\)[\s\S]*?Get-CaptureAuthorityDescriptor \$Authority\.Path\) -cne \$Authority\.SecurityDescriptor/u, + ); + assert.match(captureAuthority, /SetAccessRuleProtection\(\$true, \$false\)/u); + assert.match(captureAuthority, /SetOwner\(\$CapturePrivilegedSid\)/u); + assert.match( + captureAuthority, + /foreach \(\$identity in @\(\$CapturePrivilegedSid, \$administratorsSid, \$systemSid\)\)/u, + ); + assert.match( + captureAuthority, + /\[IO\.FileStream\]::new\([\s\S]*?FileMode\]::CreateNew[\s\S]*?\$captureAcl/u, + ); + assert.doesNotMatch(captureAuthority, /S-1-1-0|S-1-5-11|S-1-5-32-545/u); + assert.match(captureAuthority, /GetLinkCount\(\$captureHandle\) -ne 1/u); + assert.match(captureAuthority, /GetIdentity\(\$captureHandle\)[\s\S]*?GetIdentity\(\$captureReopenHandle\)/u); + assert.match(captureAuthority, /ReadBounded\(\$captureReopenHandle, 65536\)/u); + assert.doesNotMatch(captureAuthority, /ReadAllBytes\(\$Path\)/u); + assert.match( + captureAuthority, + /\$privilegedSid\.Value, \$administratorsSid\.Value, 'S-1-5-18'[\s\S]*?-cnotcontains \$parentOwner\.Value[\s\S]*?\$TestOnlyExpectedParentOwnerSid[\s\S]*?\$parentOwner\.Value -cne \$TestOnlyExpectedParentOwnerSid\.Value/u, + ); + const topLevelParameters = orchestrator.slice(0, orchestrator.indexOf('$ErrorActionPreference')); + assert.doesNotMatch(topLevelParameters, /TestOnlyExpectedParentOwnerSid/u); + const captureParserTestMode = orchestrator.slice( + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-parser')"), + orchestrator.indexOf("if ($LifecycleTestMode -eq 'diagnostic-subphase')"), + ); + assert.match( + captureParserTestMode, + /foreign-parent-owner'[\s\S]*?\$captureExpectedParentOwnerSid = \[Security\.Principal\.SecurityIdentifier\]::new\([\s\S]*?-TestOnlyExpectedParentOwnerSid \$captureExpectedParentOwnerSid/u, + ); + assert.doesNotMatch( + captureParserTestMode, + /\[IO\.Directory\]::SetAccessControl\(\$authenticatedRunnerTemp|\$parentAcl\.SetOwner/u, + ); + const captureReadOpen = orchestrator.slice( + orchestrator.indexOf('public static SafeFileHandle OpenCapture'), + orchestrator.indexOf('public static SafeFileHandle OpenRedirectCaptureAuthority'), + ); + assert.match( + captureReadOpen, + /lockAuthority\s*\? FILE_SHARE_READ\s*:\s*FILE_SHARE_READ \| FILE_SHARE_WRITE \| FILE_SHARE_DELETE/u, + ); + assert.match(captureReadOpen, /GENERIC_READ \| READ_CONTROL/u); + const redirectCaptureAuthorityOpen = orchestrator.slice( + orchestrator.indexOf('public static SafeFileHandle OpenRedirectCaptureAuthority'), + orchestrator.indexOf('public static string GetIdentity'), + ); + assert.match( + redirectCaptureAuthorityOpen, + /FILE_READ_ATTRIBUTES \| READ_CONTROL,[\s\S]*?FILE_SHARE_READ \| FILE_SHARE_WRITE,[\s\S]*?OPEN_EXISTING/u, + ); + assert.doesNotMatch(redirectCaptureAuthorityOpen, /GENERIC_READ/u); + assert.match(orchestrator, /public static uint GetLinkCount/u); + assert.match( + orchestrator, + /Initialize-PrivilegedCaptureFile \$stdout \$privilegedSid[\s\S]*?Initialize-PrivilegedCaptureFile \$stderr \$privilegedSid[\s\S]*?Start-Process/u, + ); + assert.match( + orchestrator, + /Start-Process[\s\S]*?Assert-PrivilegedCaptureIdentity \$stdoutAuthority \$privilegedSid[\s\S]*?Assert-PrivilegedCaptureIdentity \$stderrAuthority \$privilegedSid/u, + ); + const captureRedirectionTestMode = orchestrator.slice( + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-redirection')"), + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-parser')"), + ); + const captureProducerOutputClassifier = orchestrator.slice( + orchestrator.indexOf('function Get-TestOnlyCaptureProducerOutputState'), + orchestrator.indexOf('function Set-LifecycleFailureSubphase'), + ); + assert.match( + captureProducerOutputClassifier, + /\$captureReadHandle = \[ProprHostLauncherNative\]::OpenCapture\(\$Authority\.Path, \$true\)/u, + ); + assert.doesNotMatch( + captureProducerOutputClassifier, + /(?:GetLength|ReadBounded)\(\s*\$Authority\.Handle/u, + ); + assert.match( + captureProducerOutputClassifier, + /\$maximumAttributedBytes = 256[\s\S]*?GetLength\(\$captureReadHandle\)[\s\S]*?\$length -le \$maximumAttributedBytes[\s\S]*?ReadBounded\(\s*\$captureReadHandle, \$maximumAttributedBytes\s*\)/u, + ); + assert.equal( + (captureProducerOutputClassifier.match(/GetIdentity\(\$Authority\.Handle\)/gu) ?? []).length, + 2, + 'the retained non-readable authority identity must be unchanged across classification', + ); + assert.equal( + (captureProducerOutputClassifier.match(/Assert-PrivilegedCaptureFile/gu) ?? []).length, + 2, + 'the temporary read handle must be exact-bound before and after classification', + ); + assert.match( + captureProducerOutputClassifier, + /Assert-PrivilegedCaptureFile[\s\S]*?\$Authority\.Identity[\s\S]*?Get-CaptureAuthorityDescriptor \$Authority\.Path\) -cne[\s\S]*?\$Authority\.SecurityDescriptor[\s\S]*?ReadBounded[\s\S]*?Assert-PrivilegedCaptureFile[\s\S]*?GetIdentity\(\$Authority\.Handle\)[\s\S]*?\$Authority\.SecurityDescriptor/u, + ); + assert.match( + captureProducerOutputClassifier, + /finally \{\s*if \(\$null -ne \$captureReadHandle\) \{\s*try \{ \$captureReadHandle\.Dispose\(\) \} catch \{\}\s*\}\s*\}/u, + ); + for (const predicate of [ + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'capture-content', + 'cleanup', + ]) { + assert.match( + captureRedirectionTestMode, + new RegExp(`Set-CaptureAuthorityPredicate '${predicate}'`, 'u'), + ); + } + assert.match( + captureRedirectionTestMode, + /Set-CaptureAuthorityPredicate 'redirect-open'[\s\S]*?Start-Process[\s\S]*?!\(\$redirectionProcess -is \[System\.Diagnostics\.Process\]\)/u, + ); + assert.match( + captureRedirectionTestMode, + /CaptureRedirectionProducerTestCase -ceq 'nonzero'[\s\S]*?\{ 23 \}[\s\S]*?\$captureProducerSource = if[\s\S]*?capture-stdout[\s\S]*?capture-stderr[\s\S]*?exit \$captureProducerExitCode[\s\S]*?\[Text\.Encoding\]::Unicode\.GetBytes\(\$captureProducerSource\)/u, + ); + assert.match( + captureRedirectionTestMode, + /\$captureProducerArguments = \(\s*'-NoLogo -NoProfile -NonInteractive -EncodedCommand "' \+\s*\$captureProducerArgument \+ '"'\s*\)\s*\$redirectionProcess = Start-Process[\s\S]*?-ArgumentList \$captureProducerArguments/u, + ); + assert.doesNotMatch(captureRedirectionTestMode, /-ArgumentList @\(|StartInfo\.Arguments/u); + assert.match( + captureRedirectionTestMode, + /\$redirectionProcessHandle = \$redirectionProcess\.Handle[\s\S]*?Set-CaptureAuthorityPredicate 'redirect-timeout'[\s\S]*?WaitForExit\(\$terminationTimeoutMilliseconds\)[\s\S]*?Assert-PrivilegedCaptureIdentity[\s\S]*?Assert-PrivilegedCaptureIdentity[\s\S]*?Get-TestOnlyCaptureProducerOutputState/u, + ); + assert.match( + captureRedirectionTestMode, + /Get-TestOnlyCaptureProducerOutputState\s*`\s*\$stdoutAuthority \$privilegedSid 'capture-stdout'[\s\S]*?Get-TestOnlyCaptureProducerOutputState\s*`\s*\$stderrAuthority \$privilegedSid 'capture-stderr'/u, + ); + assert.match( + captureRedirectionTestMode, + /CaptureRedirectionProducerTestCase -cne 'success' -or\s*\$captureProducerExitBucket -cne 'zero'[\s\S]*?\$captureProducerStdoutState -cne 'exact-expected' -or\s*\$captureProducerStderrState -cne 'exact-expected'[\s\S]*?\$redirectionAccepted = \$true/u, + ); + assert.doesNotMatch( + captureRedirectionTestMode.slice( + captureRedirectionTestMode.indexOf('WaitForExit($terminationTimeoutMilliseconds)'), + captureRedirectionTestMode.indexOf('Assert-PrivilegedCaptureIdentity'), + ), + /ReadAllText|ReadAllBytes|ReadBounded/u, + ); + assert.doesNotMatch(captureRedirectionTestMode, /start-process-launch/u); + assert.match( + captureRedirectionTestMode, + /-TestOnlyIdentityPredicate 'post-redirection-identity'/u, + ); + assert.match( + captureRedirectionTestMode, + /\$primaryFailure = 'artifact-type'[\s\S]*?\$primaryPhase = 'capture-parse'[\s\S]*?\$primarySubphase = 'capture-authority'[\s\S]*?Set-CaptureAuthorityPredicate \$redirectionFailurePredicate/u, + ); + assert.match(captureParser, /Set-LifecycleFailureSubphase \$failureRecord\.category[\s\S]*?return 'spawn-failed'/u); + assert.doesNotMatch(captureParser, /lastMilestone/u); + assert.match(captureParser, /\$script:failurePhase = \$failureRecord\.phase/u); + assert.match( + orchestrator, + /Read-PackagedConnectSmokeFailure[\s\S]*?-Path \$stderr[\s\S]*?-ExpectedCaptureIdentity \$stderrAuthority\.Identity[\s\S]*?Stop-PackagedConnect \$childFailureCategory/u, + ); + assert.match(orchestrator, /catch \{\s*Set-PrimaryFailureFromException \$_\.Exception\s*\}/u); + assert.match(orchestrator, /\$primaryPhase -ceq 'ordinary-user-preflight'[\s\S]*?\$primarySubphase = 'host-state-contract'/u); + assert.match(orchestrator, /\$subphaseEvidence = ":subphase=\$primarySubphase"/u); + assert.match(orchestrator, /PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=\$primaryFailure`:phase=\$primaryPhase\$subphaseEvidence`:cleanup=\$cleanupSecondary/u); + + const cleanupFinally = orchestrator.slice(orchestrator.lastIndexOf('} finally {')); + assert.match(cleanupFinally, /\$cleanupResult = Invoke-BoundedCleanup/u); + assert.doesNotMatch(cleanupFinally, /Get-ChildItem|GetAccessControl|Remove-Item|Test-Path|Remove-LocalUser/u); + assert.match(cleanupFinally, /if \(\$null -eq \$primaryFailure -and \$cleanupSecondary -ne 'none'\)/u); + assert.doesNotMatch( + cleanupFinally.slice(0, cleanupFinally.indexOf("if ($null -eq $primaryFailure")), + /\$primaryFailure\s*=/u, + 'a cleanup timeout must not replace an existing primary failure', + ); + + const preflight = harness.indexOf('const staged = await validateWindowsStagedPackage'); + const spawn = harness.indexOf('const child = spawnPackagedConnectBinary'); + assert.ok(preflight >= 0 && preflight < spawn, 'ordinary-user package preflight must complete before spawn'); + assert.equal((harness.match(/await validateWindowsStagedPackage\(/gu) ?? []).length, 1); + assert.equal((harness.match(/await runPackagedConnectLifecycle\(/gu) ?? []).length, 1); + assert.match(harness, /shell: false/u); + assert.match(harness, /parseWindowsStagedPackageHandoff\(process\.argv\.slice\(2\)\)/u); + assert.match(harness, /delete childEnvironment\.PROPR_DESKTOP_CONNECT_STAGING_PARENT/u); + assert.match(harness, /delete childEnvironment\.PROPR_DESKTOP_CONNECT_STAGING_LEAF/u); + assert.match(harness, /describeWindowsArtifactFailure\(error, packagedConnectPhase\)/u); + assert.match(harness, /packagedConnectArtifactSensitiveNeedles\(\{\s*platform: process\.platform,\s*artifactRoot,\s*binaryPath,/u); + assert.doesNotMatch(harness, /identity, artifactRoot, binaryPath,/u); + assert.doesNotMatch(harness, /child\.once\('error', error/u); + const readyProducer = main.slice( + main.indexOf('const runPackagedConnectDiscoverySmoke'), + main.indexOf('const runPackagedTransportSmoke'), + ); + assert.match(readyProducer, /await window\.webContents\.executeJavaScript/u); + assert.match(readyProducer, /process\.stdout\.write\(`\$\{JSON\.stringify\(\{/u); + assert.match(readyProducer, /const readyFields = \{[\s\S]*?selectedPlatform: process\.platform[\s\S]*?selectedArch: process\.arch[\s\S]*?authorityMechanism:[\s\S]*?rendererSchemaValid: true/u); + assert.match(readyProducer, /timestamp: new Date\(\)\.toISOString\(\)[\s\S]*?level: 'info'[\s\S]*?event: 'desktop\.renderer\.connect_discovery\.ready'[\s\S]*?\.\.\.readyFields/u); + assert.ok( + readyProducer.indexOf("throw new Error('Packaged Connect renderer discovery proof was invalid')") + < readyProducer.indexOf('process.stdout.write'), + 'READY must be emitted only after the renderer discovery proof succeeds', + ); +}); + +windowsTest('the PS5.1 child-failure parser accepts only the two exact bounded producer schemas', async context => { + const runnerTemp = process.env.RUNNER_TEMP; + assert.equal(typeof runnerTemp, 'string'); + const smokeRecord = { + event: 'packaged_connect.smoke_failed', + category: 'timeout-before-ready', + capture: 'complete', + records: [{ + event: 'desktop.renderer.connect_discovery.phase', + phase: 'config-read', + code: 'FAILED', + substep: 'directory-open', + category: 'access-denied', + }], + secondary: ['tree-termination-failed'], + }; + const stagedContractRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-type', + phase: 'staged-contract', + subphase: 'parent-to-runner-binding', + }; + const stagedTreeRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-inaccessible', + phase: 'staged-tree', + }; + const stagedArchitectureRecord = { + event: 'packaged_connect.artifact_failed', + category: 'architecture-mismatch', + phase: 'staged-architecture', + }; + const ordinaryPreflightRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-inaccessible', + phase: 'ordinary-user-preflight', + subphase: 'executable-read', + }; + const smokeLine = `${JSON.stringify(smokeRecord)}\n`; + const artifactLine = `${JSON.stringify(stagedContractRecord)}\n`; + const cases = [ + ['valid-smoke', smokeLine, + 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-record-contained-ready-milestone', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.ready' }], + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-record-contained-proof-milestone', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.proof' }], + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-ready-duplicate', `${JSON.stringify({ + ...smokeRecord, category: 'ready-duplicate', + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=ready-duplicate'], + ['valid-child-remained-alive', `${JSON.stringify({ + ...smokeRecord, category: 'child-remained-alive', + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=child-remained-alive'], + ['valid-staged-contract', artifactLine, + 'category=artifact-type:phase=staged-contract:subphase=parent-to-runner-binding'], + ['valid-staged-tree', `${JSON.stringify(stagedTreeRecord)}\n`, + 'category=artifact-inaccessible:phase=staged-tree'], + ['valid-staged-architecture', `${JSON.stringify(stagedArchitectureRecord)}\n`, + 'category=architecture-mismatch:phase=staged-architecture'], + ['valid-ordinary-user-preflight', `${JSON.stringify(ordinaryPreflightRecord)}\n`, + 'category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=executable-read'], + ['malformed', '{"event":\n', + 'category=artifact-type:phase=capture-parse:subphase=capture-json'], + ['smoke-duplicate-field', smokeLine.replace( + '{"event":"packaged_connect.smoke_failed",', + '{"event":"packaged_connect.smoke_failed","event":"packaged_connect.smoke_failed",', + ), 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-duplicate-field', artifactLine.replace( + '"phase":"staged-contract",', + '"phase":"staged-contract","phase":"staged-contract",', + ), 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-extra-field', `${JSON.stringify({ ...smokeRecord, detail: 'fixed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-top-level-last-milestone', `${JSON.stringify({ + ...smokeRecord, lastMilestone: 'desktop.app.ready', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-extra-field', `${JSON.stringify({ ...stagedContractRecord, detail: 'fixed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-missing-field', `${JSON.stringify({ + event: smokeRecord.event, category: smokeRecord.category, records: smokeRecord.records, + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-missing-field', `${JSON.stringify({ + event: stagedContractRecord.event, + category: stagedContractRecord.category, + phase: stagedContractRecord.phase, + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-cross-schema-phase', `${JSON.stringify({ + ...smokeRecord, phase: 'staged-tree', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-cross-schema-subphase', `${JSON.stringify({ + ...smokeRecord, subphase: 'fixed-parent-leaf', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-capture', `${JSON.stringify({ + ...stagedContractRecord, capture: 'complete', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-records', `${JSON.stringify({ + ...stagedContractRecord, records: [], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-secondary', `${JSON.stringify({ + ...stagedContractRecord, secondary: ['tree-termination-failed'], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-multiline', `${smokeLine}${smokeLine}`, + 'category=artifact-type:phase=capture-parse:subphase=capture-line-cardinality'], + ['artifact-multiline', `${artifactLine}${artifactLine}`, + 'category=artifact-type:phase=capture-parse:subphase=capture-line-cardinality'], + ['oversized', Buffer.alloc(65_537, 0x61), + 'category=artifact-type:phase=capture-parse:subphase=capture-size'], + ['wrong-event', `${JSON.stringify({ ...smokeRecord, event: 'packaged_connect.child_failed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-event-cardinality'], + ['wrong-nested-event', `${JSON.stringify({ + ...smokeRecord, records: [{ event: 'desktop.renderer.connect_discovery.arbitrary' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-event-cardinality'], + ['proof-extra-field', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.proof', milestone: 'connect-proof' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-wrong-category', `${JSON.stringify({ + ...smokeRecord, category: 'arbitrary-runtime-error', + })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-category'], + ['artifact-wrong-category', `${JSON.stringify({ + ...stagedContractRecord, category: 'artifact-inaccessible', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-category'], + ['artifact-wrong-phase', `${JSON.stringify({ + ...stagedContractRecord, phase: 'application-runtime', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-phase'], + ['artifact-wrong-required-subphase', `${JSON.stringify({ + ...stagedContractRecord, subphase: 'executable-read', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-subphase'], + ['artifact-forbidden-subphase', `${JSON.stringify({ + ...stagedTreeRecord, subphase: 'executable-read', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-wrong-record-category', `${JSON.stringify({ + ...smokeRecord, + records: [{ ...smokeRecord.records[0], category: 'arbitrary-category' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-subphase'], + ['smoke-sensitive', `${JSON.stringify({ ...smokeRecord, category: 'environment-secret-SENTINEL' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-redaction'], + ['artifact-sensitive', `${JSON.stringify({ + ...stagedContractRecord, subphase: 'environment-secret-SENTINEL', + })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-redaction'], + ['invalid-utf8', Buffer.from([0xc3, 0x28, 0x0a]), + 'category=artifact-type:phase=capture-parse:subphase=capture-utf8'], + ]; + + for (let index = 0; index < cases.length; index += 1) { + const [name, content, evidence] = cases[index]; + const capturePath = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await writeFile(capturePath, content, { flag: 'wx' }); + context.after(() => rm(capturePath, { force: true })); + const result = runCaptureParserTest(capturePath); + assert.ifError(result.error, name); + assert.equal(result.signal, null, name); + assert.equal(result.status, 1, name); + assert.equal(result.stdout.length, 0, name); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:${evidence}:cleanup=none`, + name, + ); + assert.ok(diagnostic.length <= 256, name); + assertNoHostileDiagnosticEvidence(diagnostic); + assert.doesNotMatch(diagnostic, /SENTINEL|arbitrary|fixed/iu, name); + } +}); + +windowsTest('the PS5.1 capture parser enforces native owner ACL path and identity authority', async context => { + const runnerTemp = process.env.RUNNER_TEMP; + assert.equal(typeof runnerTemp, 'string'); + const content = `${JSON.stringify({ + event: 'packaged_connect.artifact_failed', + category: 'artifact-type', + phase: 'staged-contract', + subphase: 'parent-to-runner-binding', + })}\n`; + const expectedAccepted = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=staged-contract:subphase=parent-to-runner-binding:cleanup=none'; + const expectedRejected = predicate => 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + `:phase=capture-parse:subphase=capture-authority:predicate=${predicate}:cleanup=none`; + const trackedPaths = []; + context.after(async () => { + await Promise.all(trackedPaths.map(path => rm(path, { force: true, recursive: true }))); + }); + const newCapturePath = async (parent = runnerTemp, leaf) => { + const path = join( + parent, + leaf ?? `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await writeFile(path, content, { flag: 'wx' }); + trackedPaths.push(path); + return path; + }; + const assertResult = (name, result, expected) => { + assert.ifError(result.error, name); + assert.equal(result.signal, null, name); + assert.equal(result.status, 1, name); + assert.equal(result.stdout.length, 0, name); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal(diagnostic, expected, name); + assertNoHostileDiagnosticEvidence(diagnostic); + }; + + for (const authorityCase of ['current-owner', 'administrators-owner']) { + const path = await newCapturePath(); + assertResult(authorityCase, runCaptureParserTest(path, authorityCase), expectedAccepted); + } + + for (const [authorityCase, predicate] of [ + ['foreign-owner', 'capture-owner'], + ['ordinary-owner', 'capture-owner'], + ['ordinary-write', 'unauthorized-writer'], + ['broad-write', 'unauthorized-writer'], + ['unprotected-dacl', 'dacl-canonicality'], + ]) { + const path = await newCapturePath(); + assertResult(authorityCase, runCaptureParserTest(path, authorityCase), expectedRejected(predicate)); + } + + const isolatedParent = await mkdtemp(join(runnerTemp, 'propr-capture-parent-owner-')); + trackedPaths.push(isolatedParent); + const isolatedParentCapture = await newCapturePath(isolatedParent); + assertResult( + 'foreign-parent-owner', + runCaptureParserTest( + isolatedParentCapture, + 'foreign-parent-owner', + { RUNNER_TEMP: isolatedParent }, + ), + expectedRejected('parent-owner'), + ); + + const wrongLeaf = await newCapturePath( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.txt`, + ); + assertResult('wrong-leaf', runCaptureParserTest(wrongLeaf), expectedRejected('link-path-type')); + + const escapeParent = await mkdtemp(join(runnerTemp, 'propr-capture-escape-')); + trackedPaths.push(escapeParent); + const escapedCapture = await newCapturePath(escapeParent); + assertResult( + 'parent-escape', + runCaptureParserTest(escapedCapture), + expectedRejected('link-path-type'), + ); + + const hardlinkCapture = await newCapturePath(); + const hardlinkAlias = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await link(hardlinkCapture, hardlinkAlias); + trackedPaths.push(hardlinkAlias); + assertResult( + 'hardlink', + runCaptureParserTest(hardlinkAlias, 'existing'), + expectedRejected('link-path-type'), + ); + + const directoryCapture = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await mkdir(directoryCapture); + trackedPaths.push(directoryCapture); + assertResult( + 'non-regular-file', + runCaptureParserTest(directoryCapture), + expectedRejected('link-path-type'), + ); + + const reparseTarget = await newCapturePath( + runnerTemp, + `propr-capture-target-${randomBytes(8).toString('hex')}.txt`, + ); + const reparseCapture = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await symlink(reparseTarget, reparseCapture, 'file'); + trackedPaths.push(reparseCapture); + assertResult( + 'reparse-file', + runCaptureParserTest(reparseCapture, 'existing'), + expectedRejected('link-path-type'), + ); + + const reparseParentTarget = await mkdtemp(join(runnerTemp, 'propr-capture-parent-target-')); + trackedPaths.push(reparseParentTarget); + const reparseParent = join(runnerTemp, `propr-capture-parent-${randomBytes(8).toString('hex')}`); + await symlink(reparseParentTarget, reparseParent, 'junction'); + trackedPaths.push(reparseParent); + const reparseParentCapture = await newCapturePath(reparseParentTarget); + const captureThroughReparseParent = join(reparseParent, reparseParentCapture.slice( + reparseParentTarget.length + 1, + )); + assertResult( + 'reparse-parent', + runCaptureParserTest(captureThroughReparseParent, 'existing', { RUNNER_TEMP: reparseParent }), + expectedRejected('link-path-type'), + ); + + const identityChangeCapture = await newCapturePath(); + trackedPaths.push(`${identityChangeCapture}.propr-replaced`); + assertResult( + 'identity-change', + runCaptureParserTest(identityChangeCapture, 'identity-change'), + expectedRejected('identity-replacement'), + ); +}); + +windowsTest('nominal reaches zero with exact protected stdout and stderr capture', () => { + const result = runCaptureRedirectionTest(); + const accepted = !result.error && result.signal === null && result.status === 0 + && Buffer.isBuffer(result.stdout) && result.stdout.length <= 128 + && captureRedirectionAcceptedPattern.test(result.stdout.toString('utf8')) + && Buffer.isBuffer(result.stderr) && result.stderr.length === 0; + if (!accepted) failCaptureRedirectionTest(result); +}); + +windowsTest('a forced nonzero capture producer maps only to redirect-child-exit', () => { + const result = runCaptureRedirectionTest('nonzero'); + assert.equal(result.error, undefined); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8'); + assert.equal( + diagnostic, + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=forced-23' + + ':out=exact-expected:err=exact-expected:cleanup=none\r\n', + ); + assertNoHostileDiagnosticEvidence(diagnostic); +}); + +windowsTest('empty and hostile producer results map only to fixed bounded buckets', () => { + for (const [producerTestCase, expectedResult] of [ + ['empty', 'exit=other:out=empty:err=empty'], + ['hostile', 'exit=other:out=other-bounded:err=other-bounded'], + ]) { + const result = runCaptureRedirectionTest(producerTestCase); + assert.equal(result.error, undefined, producerTestCase); + assert.equal(result.signal, null, producerTestCase); + assert.equal(result.status, 1, producerTestCase); + assert.equal(result.stdout.length, 0, producerTestCase); + const diagnostic = result.stderr.toString('utf8'); + assert.equal( + diagnostic, + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=redirect-child-exit:${expectedResult}:cleanup=none\r\n`, + producerTestCase, + ); + assertNoHostileDiagnosticEvidence(diagnostic); + } +}); + +windowsTest('each host preflight failure transition emits one fixed redacted subphase', () => { + for (const subphase of fixedHostDiagnosticSubphases) { + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'diagnostic-subphase', + '-DiagnosticTestSubphase', + subphase, + ], { + shell: false, + windowsHide: true, + timeout: 10_000, + }); + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ); + assert.equal((diagnostic.match(/:subphase=/gu) ?? []).length, 1); + assertNoHostileDiagnosticEvidence(diagnostic); + } +}); + +for (const [testCase, subphase] of [ + ['zero', 'host-node-command-cardinality'], + ['duplicate', 'host-node-command-cardinality'], + ['multiple', 'host-node-command-cardinality'], + ['mixed-types', 'host-node-command-cardinality'], + ['case-collision', 'host-node-command-cardinality'], + ['non-application', 'host-node-command-type'], + ['missing-source', 'host-node-source'], + ['non-scalar-source', 'host-node-source'], +]) { + windowsTest(`the PS5.1 host Node producer rejects ${testCase} command evidence`, () => { + const result = runHostNodeProducerTest(testCase); + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ); + assert.equal((diagnostic.match(/:subphase=/gu) ?? []).length, 1); + assertNoHostileDiagnosticEvidence(diagnostic); + }); +} + +windowsTest('the PS5.1 host Node producer returns one validated scalar Source', () => { + const result = runHostNodeProducerTest('positive'); + assertPositiveHostNodeProducer(result); +}); + +windowsTest('the host launcher accepts only a stable final ordinary-file identity', async context => { + const producedRoot = await mkdtemp(join(tmpdir(), 'propr-launcher-authority-')); + context.after(() => rm(producedRoot, { force: true, recursive: true })); + // PowerShell 5.1 expands an existing 8.3 path in GetFullPath, so join fixtures only below this final spelling. + const root = await realpath(producedRoot); + const rootEntry = await lstat(root); + assert.equal(rootEntry.isDirectory(), true); + assert.equal(rootEntry.isSymbolicLink(), false); + assert.equal(await realpath(root), root, 'the native fixture producer must return its canonical root'); + const target = join(root, 'node-target.exe'); + const otherTarget = join(root, 'node-other.exe'); + const alias = join(root, 'node-alias.exe'); + const brokenAlias = join(root, 'node-broken.exe'); + const retargetedAlias = join(root, 'node-retargeted.exe'); + const identityTarget = join(root, 'node-identity.exe'); + const directory = join(root, 'node-directory.exe'); + await Promise.all([ + writeFile(target, Buffer.from('ordinary launcher target')), + writeFile(otherTarget, Buffer.from('other ordinary launcher target')), + writeFile(identityTarget, Buffer.from('identity launcher target')), + ]); + await symlink(target, alias, 'file'); + await symlink(join(root, 'missing-target.exe'), brokenAlias, 'file'); + await symlink(target, retargetedAlias, 'file'); + await mkdir(directory); + + if (producedRoot.toUpperCase() !== root.toUpperCase()) { + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(join(producedRoot, 'node-target.exe')), + 'artifact-type', + 'host-launcher-selected-path-canonical-equality', + ); + } + + for (const [caseName, acceptedPath] of [['normal', target], ['alias', alias]]) { + const result = runLauncherAuthorityTest(acceptedPath, caseName); + assertLauncherAuthorityAccepted(result, caseName); + } + + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(brokenAlias), + 'artifact-missing', + 'host-launcher-source-open', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(retargetedAlias, 'retarget-alias', otherTarget), + 'artifact-type', + 'host-launcher-source-reopen-match', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(identityTarget, 'identity-mismatch'), + 'artifact-type', + 'host-launcher-final-match', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(directory), + 'artifact-type', + 'host-launcher-source-type', + ); + const selectedPathRejections = [ + ['', 'host-launcher-selected-path-input'], + [String.raw`\\.\NUL`, 'host-launcher-selected-path-input'], + [String.raw`\\?\C:\ordinary.exe`, 'host-launcher-selected-path-input'], + [String.raw`\??\C:\ordinary.exe`, 'host-launcher-selected-path-input'], + [`${root}\\${'x'.repeat(260)}`, 'host-launcher-selected-path-input'], + [`${root}\\control-${String.fromCharCode(1)}.exe`, 'host-launcher-selected-path-input'], + [String.raw`C:\invalid|path.exe`, 'host-launcher-selected-path-get-full-path'], + [String.raw`\\server\share`, 'host-launcher-selected-path-absolute-shape'], + [String.raw`C:\ordinary.exe:alternate-stream`, 'host-launcher-selected-path-extra-colon'], + ['node.exe', 'host-launcher-selected-path-canonical-equality'], + [String.raw`C:\ordinary\..\ordinary.exe`, 'host-launcher-selected-path-canonical-equality'], + ]; + for (const [rejectedPath, subphase] of selectedPathRejections) { + assertLauncherAuthorityRejected(runLauncherAuthorityTest(rejectedPath), 'artifact-type', subphase); + } +}); + +test('the bounded cleanup source requires proven child exit and bounded stream closure', async () => { + const orchestrator = await readFile(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url), 'utf8'); + const boundedCleanup = orchestrator.slice( + orchestrator.indexOf('function Invoke-BoundedCleanup'), + orchestrator.indexOf('$authenticatedRunnerTemp = $null'), + ); + assert.match(boundedCleanup, /\$cleanupProcess=\[Diagnostics\.Process\]::new\(\)/u); + assert.match( + boundedCleanup, + /if\(!\$cleanupProcess\.WaitForExit\(\$cleanupTimeoutMilliseconds\)\)\{[\s\S]*?\$cleanupProcess\.Kill\(\)[\s\S]*?if\(!\$cleanupProcess\.WaitForExit\(\$terminationTimeoutMilliseconds\)\)\{return 'failed'\}[\s\S]*?Task\]::WaitAll[\s\S]*?return 'timeout'/u, + ); + assert.match(boundedCleanup, /\$cleanupOutputClose=\$cleanupProcess\.StandardOutput\.BaseStream\.CopyToAsync/u); + assert.match(boundedCleanup, /\$cleanupErrorClose=\$cleanupProcess\.StandardError\.BaseStream\.CopyToAsync/u); +}); + +windowsTest('the native timeout path terminates an actual child and descendant tree', async context => { + const { root, descendantProcessId } = await startNativeNodeTree(); + context.after(() => terminateTreeAfterTest(root.pid)); + context.after(() => terminateTreeAfterTest(descendantProcessId)); + assert.equal(processExists(root.pid), true); + assert.equal(processExists(descendantProcessId), true); + + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'terminate-tree', + '-LifecycleTestProcessId', + String(root.pid), + ], { + shell: false, + windowsHide: true, + timeout: 15_000, + }); + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 0); + assert.equal(result.stdout.toString('utf8').trim(), + 'PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:tree-terminated'); + assert.equal(result.stderr.length, 0); + assert.equal(await waitForProcessExit(root.pid), true, 'the native harness root must terminate'); + assert.equal(await waitForProcessExit(descendantProcessId), true, + 'the native harness descendant must terminate'); +}); + +windowsTest('a real never-settling cleanup is bounded, terminated, and remains secondary', () => { + const startedAt = Date.now(); + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'cleanup-timeout', + ], { + shell: false, + windowsHide: true, + timeout: 10_000, + }); + const elapsedMilliseconds = Date.now() - startedAt; + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + assert.equal( + result.stderr.toString('utf8').trim(), + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type:phase=staged-tree:cleanup=cleanup-timeout', + ); + assert.ok(elapsedMilliseconds >= 750, 'the injected cleanup must reach its deadline'); + assert.ok(elapsedMilliseconds < 8_000, 'the cleanup deadline and termination must remain bounded'); +}); diff --git a/apps/desktop/src/connect-discovery.test.ts b/apps/desktop/src/connect-discovery.test.ts new file mode 100644 index 000000000..9fb0c65ea --- /dev/null +++ b/apps/desktop/src/connect-discovery.test.ts @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; +import { DesktopConnectDiscoveryService } from './connect-discovery'; + +const readyStatus = (endpoint = 'https://t-discovered123.propr.dev'): ConnectStatusDocument => ({ + schemaVersion: 1, + status: 'ready', + canonicalEndpoint: endpoint, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + configured: true, + enabled: true, + sidecarRunning: true, + apiReady: true, + restartRequired: false, + compatibility: '2026-08-01', + version: '0.8.15', + reasonCodes: [], +}); + +const 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 }; +}; + +describe('desktop fixed-root Connect discovery', () => { + it('projects only a stable opaque profile and canonical endpoint', async () => { + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => readyStatus(), + }); + + const unclaimed = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(unclaimed.status, 'unclaimed'); + assert.equal(unclaimed.isCurrent(), true); + const candidates = await service.discover(); + assert.deepEqual(candidates, [{ + id: 'propr-connect-discovered', + label: 'ProPR Connect', + apiBaseUrl: 'https://t-discovered123.propr.dev', + }]); + const serialized = JSON.stringify(candidates); + assert.doesNotMatch(serialized, /123e4567|root|path|environment|executable|credential|authority/i); + const claim = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(claim.status, 'claimed'); + if (claim.status === 'claimed') { + assert.equal(claim.publicInstanceIdentity, readyStatus().publicInstanceIdentity); + assert.equal(claim.isCurrent(), true); + } + assert.equal(unclaimed.isCurrent(), false); + }); + + it('fences rediscovery to an existing managed profile and preserves its id and label', async () => { + const saved = { + id: 'saved-profile', + label: 'Managed workspace', + apiBaseUrl: 'https://t-stale123.propr.dev', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }; + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [saved], activeProfileId: saved.id }), + }, { + supported: true, + discover: async () => readyStatus('https://t-recovered456.propr.dev'), + }); + + assert.deepEqual(await service.rediscover(saved.id), { + id: saved.id, + label: saved.label, + apiBaseUrl: 'https://t-recovered456.propr.dev', + }); + const staleOrigin = service.snapshotIdentityClaim(saved.id, saved.apiBaseUrl); + assert.equal(staleOrigin.status, 'origin-mismatch'); + assert.equal(staleOrigin.isCurrent(), true); + const current = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(current.status, 'claimed'); + if (current.status === 'claimed') { + assert.equal(current.publicInstanceIdentity, readyStatus().publicInstanceIdentity); + assert.equal(current.isCurrent(), true); + } + const firstGeneration = current.status === 'claimed' ? current.generation : -1; + const releaseCommit = current.beginCommit(); + assert.ok(releaseCommit); + let rediscoverySettled = false; + const rediscovery = service.rediscover(saved.id).then(result => { + rediscoverySettled = true; + return result; + }); + await Promise.resolve(); + assert.equal(rediscoverySettled, false); + assert.equal(current.isCurrent(), false); + const pending = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(pending.status, 'pending'); + assert.equal(pending.isCurrent(), false); + assert.equal(pending.beginCommit(), null); + releaseCommit(); + assert.deepEqual(await rediscovery, { + id: saved.id, + label: saved.label, + apiBaseUrl: 'https://t-recovered456.propr.dev', + }); + const rotated = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(rotated.status, 'claimed'); + if (rotated.status === 'claimed') assert.ok(rotated.generation > firstGeneration); + assert.equal(current.isCurrent(), false); + assert.equal(await service.rediscover('missing-profile'), null); + }); + + it('discards rediscovery when the exact saved profile changes while native discovery awaits', async () => { + const saved = { + id: 'saved-profile', label: 'Managed workspace', + apiBaseUrl: 'https://t-stale123.propr.dev', + createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', + }; + const replacements = [ + null, + { ...saved, label: 'Edited workspace', updatedAt: '2026-08-02T00:00:00.000Z' }, + { ...saved, apiBaseUrl: 'https://t-replaced999.propr.dev', updatedAt: '2026-08-02T00:00:00.000Z' }, + { ...saved, createdAt: '2026-08-02T00:00:00.000Z', updatedAt: '2026-08-02T00:00:00.000Z' }, + ]; + for (const replacement of replacements) { + let reads = 0; + let resolveDiscovery!: (status: ConnectStatusDocument) => void; + const discovery = new Promise(resolve => { resolveDiscovery = resolve; }); + const service = new DesktopConnectDiscoveryService({ + list: async () => { + const currentRead = reads++; + return { + profiles: currentRead === 0 ? [saved] : replacement ? [replacement] : [], + activeProfileId: saved.id, + }; + }, + }, { supported: true, discover: () => discovery }); + const result = service.rediscover(saved.id); + await Promise.resolve(); + resolveDiscovery(readyStatus('https://t-recovered456.propr.dev')); + assert.equal(await result, null); + } + }); + + it('fails closed for unsupported hosts and malformed native results', async () => { + const profiles = { list: async () => ({ profiles: [], activeProfileId: null }) }; + await assert.rejects( + new DesktopConnectDiscoveryService(profiles, { + supported: false, + discover: async () => readyStatus(), + }).discover(), + /unavailable/, + ); + assert.deepEqual(await new DesktopConnectDiscoveryService(profiles, { + supported: true, + discover: async () => ({ ...readyStatus(), canonicalEndpoint: 'https://T-bad.propr.dev' }), + }).discover(), []); + }); + + it('generation-conditionally clears failed intents while keeping prior activations fenced', async () => { + const failed = deferred(); + let calls = 0; + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => calls++ === 0 ? readyStatus() : failed.promise, + }); + await service.discover(); + const active = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + const rejected = service.discover(); + assert.equal(active.isCurrent(), false); + assert.equal(service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ).status, 'pending'); + failed.reject(new Error('native discovery failed')); + await assert.rejects(rejected, /native discovery failed/); + const recovered = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(recovered.status, 'claimed'); + assert.equal(recovered.isCurrent(), true); + assert.equal(active.isCurrent(), false); + + const invalid = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => ({ ...readyStatus(), apiReady: false }), + }); + assert.deepEqual(await invalid.discover(), []); + const manual = invalid.snapshotIdentityClaim('manual-profile', 'https://example.test'); + assert.equal(manual.status, 'unclaimed'); + assert.equal(manual.isCurrent(), true); + + const missingOrManual = new DesktopConnectDiscoveryService({ + list: async () => ({ + profiles: [{ + id: 'manual-profile', label: 'Manual', apiBaseUrl: 'https://example.test', + createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', + }], + activeProfileId: null, + }), + }, { supported: true, discover: async () => readyStatus() }); + assert.equal(await missingOrManual.rediscover('missing-profile'), null); + assert.equal(await missingOrManual.rediscover('manual-profile'), null); + for (const profileId of ['missing-profile', 'manual-profile']) { + const claim = missingOrManual.snapshotIdentityClaim(profileId, 'https://example.test'); + assert.equal(claim.status, 'unclaimed'); + assert.equal(claim.isCurrent(), true); + } + }); + + it('scopes discovery freshness per profile and only discards stale same-profile completions', async () => { + const profile = (id: string) => ({ + id, label: id, apiBaseUrl: `https://t-${id}123.propr.dev`, + createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', + }); + const profiles = [profile('alpha'), profile('bravo')]; + const calls: Array>> = []; + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles, activeProfileId: null }), + }, { + supported: true, + discover: () => { + const call = deferred(); + calls.push(call); + return call.promise; + }, + }); + + const alpha = service.rediscover('alpha'); + await Promise.resolve(); + const bravo = service.rediscover('bravo'); + await Promise.resolve(); + calls[1].resolve(readyStatus('https://t-bravo456.propr.dev')); + calls[0].resolve(readyStatus('https://t-alpha456.propr.dev')); + assert.equal((await alpha)?.apiBaseUrl, 'https://t-alpha456.propr.dev'); + assert.equal((await bravo)?.apiBaseUrl, 'https://t-bravo456.propr.dev'); + + const stale = service.rediscover('alpha'); + await Promise.resolve(); + const current = service.rediscover('alpha'); + await Promise.resolve(); + calls[2].resolve(readyStatus('https://t-alpha789.propr.dev')); + assert.equal(await stale, null); + assert.equal(service.snapshotIdentityClaim('alpha', 'https://t-alpha456.propr.dev').status, 'pending'); + calls[3].resolve(readyStatus('https://t-alpha999.propr.dev')); + assert.equal((await current)?.apiBaseUrl, 'https://t-alpha999.propr.dev'); + }); +}); diff --git a/apps/desktop/src/connect-discovery.ts b/apps/desktop/src/connect-discovery.ts new file mode 100644 index 000000000..c7f9462ae --- /dev/null +++ b/apps/desktop/src/connect-discovery.ts @@ -0,0 +1,241 @@ +import { isPublicInstanceIdentity, parseProprConnectEndpoint } from '@propr/shared'; +import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; +import type { ProfileStore } from './profile-store'; +import type { DesktopDiscoveryCandidate } from './shared/contract'; + +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; + +type RediscoveryProfile = Awaited['list']>>['profiles'][number]; + +export type DesktopConnectIdentityClaimSnapshot = Readonly< + | { status: 'unclaimed'; isCurrent(): boolean; beginCommit(): (() => void) | null } + | { + status: 'pending'; + generation: number; + isCurrent(): false; + beginCommit(): null; + } + | { + status: 'origin-mismatch'; + generation: number; + isCurrent(): boolean; + beginCommit(): (() => void) | null; + } + | { + status: 'claimed'; + generation: number; + publicInstanceIdentity: string; + isCurrent(): boolean; + beginCommit(): (() => void) | null; + } +>; + +export interface ConnectDiscoverySource { + readonly supported: boolean; + discover(): Promise; +} + +const candidateFromStatus = (status: ConnectStatusDocument): DesktopDiscoveryCandidate | null => { + const endpoint = status.canonicalEndpoint === null + ? null + : parseProprConnectEndpoint(status.canonicalEndpoint); + if ( + status.status !== 'ready' + || !status.apiReady + || !endpoint + || !isPublicInstanceIdentity(status.publicInstanceIdentity) + ) return null; + return { + // One fixed main-owned CLI configuration selects one native stack root. + // A constant UI identity avoids projecting even a hash of native evidence. + id: 'propr-connect-discovered', + label: 'ProPR Connect', + apiBaseUrl: endpoint.origin, + }; +}; + +const sameRediscoveryProfile = (left: RediscoveryProfile, right: RediscoveryProfile): boolean => + left.id === right.id + && left.label === right.label + && left.apiBaseUrl === right.apiBaseUrl + && left.createdAt === right.createdAt + && left.updatedAt === right.updatedAt; + +export class DesktopConnectDiscoveryService { + readonly #identityClaims = new Map(); + #identityClaimGeneration = 0; + readonly #claimIntentGenerations = new Map(); + readonly #pendingClaimIntents = new Map(); + readonly #claimCommitLocks = new Set(); + readonly #claimCommitWaiters = new Map void>>(); + + constructor( + private readonly profiles: Pick, + private readonly source: ConnectDiscoverySource, + ) {} + + get supported(): boolean { + return this.source.supported; + } + + async discover(): Promise { + if (!this.source.supported) throw new Error('Connect discovery is unavailable'); + const profileId = 'propr-connect-discovered'; + const intentGeneration = this.#beginClaimIntent(profileId); + try { + const pendingCommit = this.#waitForClaimCommit(profileId); + if (pendingCommit) await pendingCommit; + const status = await this.source.discover(); + const candidate = candidateFromStatus(status); + if (!this.#claimIntentIsCurrent(profileId, intentGeneration)) return []; + if (candidate) this.#publishIdentityClaim( + candidate.id, candidate.apiBaseUrl, status.publicInstanceIdentity!, intentGeneration, + ); + return candidate ? [candidate] : []; + } finally { + this.#finishClaimIntent(profileId, intentGeneration); + } + } + + async rediscover(profileId: unknown): Promise { + if (!this.source.supported || typeof profileId !== 'string' || !PROFILE_ID_PATTERN.test(profileId)) { + throw new Error('Connect rediscovery is unavailable'); + } + const intentGeneration = this.#beginClaimIntent(profileId); + try { + const pendingCommit = this.#waitForClaimCommit(profileId); + if (pendingCommit) await pendingCommit; + const current = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); + const currentEndpoint = current ? parseProprConnectEndpoint(current.apiBaseUrl) : null; + if (!current || !currentEndpoint) return null; + const status = await this.source.discover(); + const candidate = candidateFromStatus(status); + if (!candidate) return null; + const revalidated = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); + const revalidatedEndpoint = revalidated ? parseProprConnectEndpoint(revalidated.apiBaseUrl) : null; + if (!revalidated + || !revalidatedEndpoint + || revalidatedEndpoint.origin !== currentEndpoint.origin + || !sameRediscoveryProfile(current, revalidated) + || !this.#claimIntentIsCurrent(profileId, intentGeneration)) return null; + this.#publishIdentityClaim( + current.id, candidate.apiBaseUrl, status.publicInstanceIdentity!, intentGeneration, + ); + return { + id: current.id, + label: current.label, + apiBaseUrl: candidate.apiBaseUrl, + }; + } finally { + this.#finishClaimIntent(profileId, intentGeneration); + } + } + + snapshotIdentityClaim(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot { + const claim = this.#identityClaims.get(profileId); + const intentGeneration = this.#claimIntentGeneration(profileId); + const isCurrent = () => this.#identityClaims.get(profileId) === claim + && this.#claimIntentGeneration(profileId) === intentGeneration + && !this.#pendingClaimIntents.has(profileId); + const beginCommit = () => this.#beginClaimCommit(profileId, isCurrent); + const pendingIntent = this.#pendingClaimIntents.get(profileId); + if (pendingIntent !== undefined) { + return Object.freeze({ + status: 'pending' as const, + generation: pendingIntent, + isCurrent: () => false as const, + beginCommit: () => null, + }); + } + if (!claim) { + return Object.freeze({ + status: 'unclaimed' as const, + isCurrent, + beginCommit, + }); + } + if (claim.origin !== origin) { + return Object.freeze({ + status: 'origin-mismatch' as const, + generation: claim.generation, + isCurrent, + beginCommit, + }); + } + return Object.freeze({ + status: 'claimed' as const, + generation: claim.generation, + publicInstanceIdentity: claim.publicInstanceIdentity, + isCurrent, + beginCommit, + }); + } + + #claimIntentGeneration(profileId: string): number { + return this.#claimIntentGenerations.get(profileId) ?? 0; + } + + #beginClaimIntent(profileId: string): number { + const generation = this.#claimIntentGeneration(profileId) + 1; + this.#claimIntentGenerations.set(profileId, generation); + // Publish pending synchronously before the first await. Existing active + // snapshots become stale immediately, and no later pairing can acquire the + // commit gate while native discovery is unresolved. + this.#pendingClaimIntents.set(profileId, generation); + return generation; + } + + #claimIntentIsCurrent(profileId: string, generation: number): boolean { + return this.#claimIntentGeneration(profileId) === generation + && this.#pendingClaimIntents.get(profileId) === generation; + } + + #finishClaimIntent(profileId: string, generation: number): void { + if (this.#pendingClaimIntents.get(profileId) === generation) { + this.#pendingClaimIntents.delete(profileId); + } + } + + #waitForClaimCommit(profileId: string): Promise | null { + if (!this.#claimCommitLocks.has(profileId)) return null; + return new Promise(resolve => { + const waiters = this.#claimCommitWaiters.get(profileId) ?? []; + waiters.push(resolve); + this.#claimCommitWaiters.set(profileId, waiters); + }); + } + + #beginClaimCommit(profileId: string, isCurrent: () => boolean): (() => void) | null { + if (!isCurrent() || this.#claimCommitLocks.has(profileId)) return null; + this.#claimCommitLocks.add(profileId); + let released = false; + return () => { + if (released) return; + released = true; + this.#claimCommitLocks.delete(profileId); + const waiters = this.#claimCommitWaiters.get(profileId) ?? []; + this.#claimCommitWaiters.delete(profileId); + waiters.forEach(resolve => resolve()); + }; + } + + #publishIdentityClaim( + profileId: string, + origin: string, + publicInstanceIdentity: string, + intentGeneration: number, + ): void { + if (!this.#claimIntentIsCurrent(profileId, intentGeneration) + || this.#claimCommitLocks.has(profileId)) return; + this.#identityClaims.set(profileId, { + origin, + publicInstanceIdentity, + generation: ++this.#identityClaimGeneration, + }); + this.#pendingClaimIntents.delete(profileId); + } +} diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts new file mode 100644 index 000000000..e732d32cc --- /dev/null +++ b/apps/desktop/src/credential-service.pairing-browser.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; +import { openApprovedDesktopPairingUrl } from './pairing-browser'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; + +const pairingId = `dpr_${'A'.repeat(22)}`; +const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); +const origin = 'https://api.example.test'; +const approvalUrl = `${origin}/api/desktop/pairings/${pairingId}/browser`; +const instanceToken = `propr_it_${'T'.repeat(43)}`; +const temporaryDirectories: string[] = []; +const services: DesktopCredentialService[] = []; + +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; + +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + status, headers: { 'Content-Type': 'application/json' }, +}); + +const discovery = { + schemaVersion: 1 as const, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2 as const, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +interface PairingProofOptions { + beforeProvisional?(): void; + onRequest?(request: { url: string; authorization: string | null }): void; +} + +const createService = async ( + openPairingBrowser: (request: DesktopPairingBrowserRequest) => Promise, + proof: PairingProofOptions = {}, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-pairing-sink-')); + temporaryDirectories.push(directory); + let binding: Record = {}; + const service = new DesktopCredentialService({ + profiles: new ProfileStore(directory, encryption), + clientName: 'Pairing sink test', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser, + fetch: async (input, init) => { + const url = input.toString(); + proof.onRequest?.({ + url, + authorization: new Headers(init?.headers).get('Authorization'), + }); + if (url === `${origin}/api/desktop/discovery`) return json(discovery); + if (url === `${origin}/api/desktop/pairings`) { + const request = JSON.parse(String(init?.body)) as Record; + binding = { + instanceId: request.instanceId, + origin: request.origin, + scope: request.scope, + credentialGeneration: request.credentialGeneration, + }; + return json({ + pairingId, deviceSecret: 'D'.repeat(43), approvalUrl, + expiresAt: new Date(pairingNow + 10_000).toISOString(), interval: 1, + }, 201); + } + if (url.endsWith('/poll')) { + proof.beforeProvisional?.(); + return json({ + status: 'provisional', token: instanceToken, tokenType: 'Bearer', + activationTicket: 'K'.repeat(43), + activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding, + }); + } + if (url.endsWith('/activate')) return json({ + status: 'active', receipt: 'R'.repeat(22), + activatedAt: '2026-01-01T00:00:01.000Z', expiresAt: null, + }); + if (url === `${origin}/api/auth/user`) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${instanceToken}`); + return json({ username: 'remote-owner' }); + } + throw new Error('Unexpected pairing request'); + }, + }); + services.push(service); + return service; +}; + +afterEach(async () => { + await Promise.all(services.splice(0).map(service => service.dispose())); + await Promise.all(temporaryDirectories.splice(0).map(path => rm(path, { recursive: true, force: true }))); +}); + +describe('DesktopCredentialService pairing browser sink', () => { + it('pairs through the browser journey and rejects a response URL replacement', async () => { + const opened: string[] = []; + const requests: Array<{ url: string; authorization: string | null }> = []; + let browserApproved = false; + const service = await createService(request => openApprovedDesktopPairingUrl(request, { + openExternal: async url => { + opened.push(url); + // Models the explicit approval click in the independently authenticated + // system browser. The polling fixture refuses to issue a provisional + // credential until this manual browser step has completed. + browserApproved = true; + }, + }), { + beforeProvisional: () => assert.equal(browserApproved, true), + onRequest: request => requests.push(request), + }); + + const profile = { id: 'profile-a', label: 'Remote ProPR', apiBaseUrl: origin }; + const initialProbe = await service.probe(profile); + assert.equal(initialProbe.status, 'authentication-required'); + const paired = await service.pair(profile); + const probed = await service.probe(profile); + assert.equal(probed.status, 'ready'); + if (probed.status !== 'ready') return; + const activated = await service.activate(probed.activationTicket); + + assert.deepEqual(paired, { paired: true }); + assert.deepEqual(opened, [approvalUrl]); + assert.deepEqual(requests.map(request => request.url), [ + `${origin}/api/desktop/discovery`, + `${origin}/api/desktop/discovery`, + `${origin}/api/desktop/pairings`, + `${origin}/api/desktop/pairings/${pairingId}/poll`, + `${origin}/api/desktop/pairings/${pairingId}/activate`, + `${origin}/api/desktop/discovery`, + `${origin}/api/auth/user`, + ]); + assert.deepEqual(requests.map(request => request.authorization), [ + null, null, null, null, null, null, `Bearer ${instanceToken}`, + ]); + assert.deepEqual(service.prepareRequest( + `${origin}/api/tasks`, + { [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope }, + ).requestHeaders, { Authorization: `Bearer ${instanceToken}` }); + assert.equal(JSON.stringify([initialProbe, paired, probed, activated, opened]).includes(instanceToken), false); + + const replacedOpened: string[] = []; + const replacedService = await createService(request => openApprovedDesktopPairingUrl({ + ...request, + approvalUrl: `${origin}/api/desktop/pairings/dpr_${'B'.repeat(22)}/browser`, + }, { openExternal: async url => { replacedOpened.push(url); } })); + + await assert.rejects( + replacedService.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), + /Desktop pairing browser request was rejected/, + ); + assert.deepEqual(replacedOpened, []); + }); +}); diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts new file mode 100644 index 000000000..39b1b00e3 --- /dev/null +++ b/apps/desktop/src/credential-service.test.ts @@ -0,0 +1,2669 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_REVOCATION_BINDING_HEADER, + DESKTOP_TOKEN_REVOCATION_ENDPOINT, + DESKTOP_TOKEN_REVOCATION_SCHEMA, + DESKTOP_TOKEN_REVOCATION_VERSION, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; +import { DesktopCredentialService } from './credential-service'; +import { DesktopConnectDiscoveryService } from './connect-discovery'; +import { ProfileStore, type EncryptionProvider, type StoredCredential } from './profile-store'; + +const temporaryDirectories: string[] = []; +const credentialServices: DesktopCredentialService[] = []; +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, +}); +const testPairingBindings = new Map>(); +const pairingStartResponse = ( + url: string, + init: RequestInit | undefined, + body: Record, + status = 201, +): Response => { + const request = JSON.parse(String(init?.body)) as Record; + testPairingBindings.set(new URL(url).origin, { + instanceId: request.instanceId, + origin: request.origin, + scope: request.scope, + credentialGeneration: request.credentialGeneration, + activationExpiresAt: body.expiresAt, + }); + return json(body, status); +}; +const provisionalPairingResponse = (url: string, credentialToken: string): Response => json({ + status: 'provisional', + token: credentialToken, + tokenType: 'Bearer', + activationTicket: 'T'.repeat(43), + ...testPairingBindings.get(new URL(url).origin), +}); +const pairingActivationReceipt = (): Response => json({ + status: 'active', + receipt: 'R'.repeat(22), + activatedAt: '2026-01-01T00:00:01.000Z', + expiresAt: null, +}); +const terminalRevocationBody = ( + init: RequestInit | undefined, + code: 'TOKEN_NOT_FOUND' | 'INSTANCE_TOKEN_REVOKED' | 'INSTANCE_TOKEN_EXPIRED' = 'TOKEN_NOT_FOUND', +): Record => ({ + schema: DESKTOP_TOKEN_REVOCATION_SCHEMA, + version: DESKTOP_TOKEN_REVOCATION_VERSION, + endpoint: DESKTOP_TOKEN_REVOCATION_ENDPOINT, + terminal: true, + code, + credentialGeneration: new Headers(init?.headers).get(DESKTOP_REVOCATION_BINDING_HEADER), +}); +const terminalRevocation = ( + init: RequestInit | undefined, + code: 'TOKEN_NOT_FOUND' | 'INSTANCE_TOKEN_REVOKED' | 'INSTANCE_TOKEN_EXPIRED' = 'TOKEN_NOT_FOUND', +): Response => json(terminalRevocationBody(init, code), code === 'TOKEN_NOT_FOUND' ? 404 : 401); +const discovery = { + schemaVersion: 1 as const, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2 as const, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; +const token = (character: string) => `propr_it_${character.repeat(43)}`; +const credential = (profileId: string, origin: string, character: string): StoredCredential => ({ + version: 2, + profileId, + origin, + publicInstanceIdentity: discovery.publicInstanceIdentity, + token: token(character), +}); +const connectStatus = ( + endpoint: string, + publicInstanceIdentity: string, +): ConnectStatusDocument => ({ + schemaVersion: 1, + status: 'ready', + canonicalEndpoint: endpoint, + publicInstanceIdentity, + configured: true, + enabled: true, + sidecarRunning: true, + apiReady: true, + restartRequired: false, + compatibility: '2026-08-01', + version: '0.8.15', + reasonCodes: [], +}); +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +}; +const transportHeaders = (transportScope: string, headers: Record = {}) => ({ + ...headers, + 'X-ProPR-Desktop-Transport-Scope': transportScope, +}); + +const createStore = async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + return new ProfileStore(directory, encryption); +}; + +const createCredentialService = ( + dependencies: ConstructorParameters[0], +): DesktopCredentialService => { + const suppliedFetch = dependencies.fetch; + const service = new DesktopCredentialService({ + ...dependencies, + fetch: async (input, init) => { + if (!input.toString().endsWith('/api/desktop/discovery')) return suppliedFetch(input, init); + try { + const response = await suppliedFetch(input, init); + if (response.status === 200 + && response.headers.get('content-type')?.includes('application/json')) return response; + } catch (error) { + if (init?.signal?.aborted) throw error; + // Legacy fixtures below model only the post-discovery operation. They + // still cross the real strict parser using this complete document. + } + return json(discovery); + }, + }); + credentialServices.push(service); + return service; +}; + +afterEach(async () => { + await Promise.all(credentialServices.splice(0).map(service => service.dispose())); + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); +}); + +describe('main-process desktop credential service', () => { + it('fails a relaunched same-origin replacement closed before sending the stored bearer', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-replaced', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const requests: Array<{ url: string; authorization: string | null }> = []; + const replacementDiscovery = { + ...discovery, + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Relaunch identity test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json(replacementDiscovery); + }, + }); + credentialServices.push(service); + + const result = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + + assert.equal(result.status, 'authentication-required'); + assert.ok(requests.length >= 1); + assert.equal(requests[0].url, `${profile.apiBaseUrl}/api/desktop/discovery`); + assert.equal(requests.some(request => request.authorization !== null), false); + assert.equal(await store.readCredential(profile.id), null); + }); + + it('fails malformed identity closed and classifies legacy public-discovery 401 safely', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-malformed', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const authorizations: Array = []; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Malformed relaunch test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => { + authorizations.push(new Headers(init?.headers).get('Authorization')); + const { publicInstanceIdentity: _missing, ...malformed } = discovery; + return json(malformed); + }, + }); + credentialServices.push(service); + + const result = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + + assert.equal(result.status, 'authentication-required'); + assert.equal(authorizations.some(Boolean), false); + assert.equal(await store.readCredential(profile.id), null); + + const legacyStore = await createStore(); + const requests: Array<{ url: string; authorization: string | null }> = []; + const legacyService = new DesktopCredentialService({ + profiles: legacyStore, + clientName: 'Legacy remote test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json({ error: 'Unauthorized' }, 401); + }, + }); + credentialServices.push(legacyService); + + const legacyResult = await legacyService.probe({ + id: 'legacy-remote', + label: 'Legacy remote', + apiBaseUrl: 'https://legacy.example.test', + }); + + assert.deepEqual(legacyResult, { + status: 'incompatible', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', + }); + assert.deepEqual(requests, [{ + url: 'https://legacy.example.test/api/desktop/discovery', + authorization: null, + }]); + assert.doesNotMatch(JSON.stringify(legacyResult), /Unauthorized|AUTHENTICATION_REQUIRED/); + + const rejectedLegacyBodies = [ + '{"error":"Unauthorized","policy":"private policy detail"}', + '{"error":"Unauthorized","error":"Unauthorized"}', + '{"code":"AUTHENTICATION_REQUIRED"}', + ]; + for (const [index, body] of rejectedLegacyBodies.entries()) { + const adversarialStore = await createStore(); + let adversarialRequests = 0; + const adversarialService = new DesktopCredentialService({ + profiles: adversarialStore, + clientName: 'Adversarial legacy remote test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => { + adversarialRequests += 1; + assert.equal(new Headers(init?.headers).get('Authorization'), null); + return new Response(body, { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + credentialServices.push(adversarialService); + + const rejected = await adversarialService.probe({ + id: `rejected-legacy-${index}`, + label: 'Rejected legacy remote', + apiBaseUrl: `https://rejected-${index}.example.test`, + }); + + assert.equal(rejected.status, 'authentication-required'); + assert.equal(adversarialRequests, 1); + assert.doesNotMatch(JSON.stringify(rejected), /private policy detail|Unauthorized|AUTHENTICATION_REQUIRED/); + } + }); + + it('revalidates an old Socket.IO reconnect and sends zero bearer requests after identity rotation', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-socket-rotation', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + let rotated = false; + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Socket rotation test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + requests.push({ url, authorization }); + if (url.endsWith('/api/desktop/discovery')) return json(rotated + ? { ...discovery, publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } + : discovery); + return json({ username: 'octocat' }); + }, + }); + credentialServices.push(service); + const ready = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const active = await service.activate(ready.activationTicket); + rotated = true; + const beforeReconnect = requests.length; + const result = await service.prepareRequestAsync( + `wss://a.example.test/socket.io/?transport=websocket&proprDesktopTransportScope=${active.transportScope}`, + {}, { resourceType: 'webSocket' }, + ); + + assert.deepEqual(result, { cancel: true }); + assert.equal(requests[beforeReconnect].url, `${profile.apiBaseUrl}/api/desktop/discovery`); + assert.equal(requests[beforeReconnect].authorization, null); + assert.equal(requests.slice(beforeReconnect).some(request => request.authorization !== null), false); + assert.equal(await store.readCredential(profile.id), null); + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(active.transportScope), + ), { cancel: true }); + }); + + it('fences old and concurrently rotated Connect claims through pairing, commit, and transport activation', async () => { + const store = await createStore(); + const origins = { + old: 'https://t-old123.propr.dev', + current: 'https://t-current456.propr.dev', + replacement: 'https://t-replacement789.propr.dev', + } as const; + const identities = { + old: discovery.publicInstanceIdentity, + current: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + replacement: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + } as const; + const profile = await store.save({ + id: 'connect-saved', label: 'Saved Connect', apiBaseUrl: origins.old, + }); + const oldCredential: StoredCredential = { + ...credential(profile.id, origins.old, 'A'), + publicInstanceIdentity: identities.old, + }; + await store.writeCredential(oldCredential); + await store.setActive(profile.id); + + let nativeStatus = connectStatus(origins.old, identities.old); + const connect = new DesktopConnectDiscoveryService(store, { + supported: true, + discover: async () => nativeStatus, + }); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }); + const oldClaim = connect.snapshotIdentityClaim(profile.id, origins.old); + assert.equal(oldClaim.status, 'claimed'); + + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const stalePollStarted = deferred(); + const releaseStalePoll = deferred(); + const requests: Array<{ + url: string; + authorization: string | null; + transportScope: string | null; + body: string | null; + }> = []; + let pairingNumber = 0; + const service = createCredentialService({ + profiles: store, + clientName: 'Connect claim test', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + snapshotConnectIdentityClaim: (profileId, origin) => connect.snapshotIdentityClaim(profileId, origin), + fetch: async (input, init) => { + const url = input.toString(); + const headers = new Headers(init?.headers); + requests.push({ + url, + authorization: headers.get('Authorization'), + transportScope: headers.get('X-ProPR-Desktop-Transport-Scope'), + body: typeof init?.body === 'string' ? init.body : null, + }); + const origin = new URL(url).origin; + const identity = origin === origins.old + ? identities.old + : origin === origins.current ? identities.current : identities.replacement; + if (url.endsWith('/api/desktop/discovery')) { + return json({ ...discovery, publicInstanceIdentity: identity }); + } + if (url.endsWith('/api/auth/user')) return json({ username: 'connect-user' }); + if (url.endsWith('/api/desktop/pairings')) { + pairingNumber += 1; + const pairingCharacter = pairingNumber === 1 ? 'B' : pairingNumber === 2 ? 'C' : 'D'; + return pairingStartResponse(url, init, { + pairingId: `dpr_${pairingCharacter.repeat(22)}`, + deviceSecret: pairingCharacter.repeat(43), + approvalUrl: `${origin}/approve`, + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + } + if (url.includes(`/dpr_${'B'.repeat(22)}/poll`)) { + return provisionalPairingResponse(url, token('B')); + } + if (url.includes(`/dpr_${'C'.repeat(22)}/poll`)) { + stalePollStarted.resolve(); + return releaseStalePoll.promise; + } + if (url.includes(`/dpr_${'D'.repeat(22)}/poll`)) { + return provisionalPairingResponse(url, token('D')); + } + if (url.includes('/activate')) return pairingActivationReceipt(); + if (url.includes(`/dpr_${'C'.repeat(22)}/cancel`)) { + return json({ status: 'cancelled', cancelledAt: '2026-01-01T00:00:02.000Z' }); + } + if (url.endsWith('/api/desktop/tokens/current')) { + const committed = await store.readCredential(profile.id); + if (origin === origins.old) { + assert.equal(committed?.origin, origins.current); + assert.equal(committed?.token, token('B')); + assert.equal(headers.get('Authorization'), `Bearer ${oldCredential.token}`); + } else { + assert.equal(origin, origins.current); + assert.equal(committed?.origin, origins.replacement); + assert.equal(committed?.token, token('D')); + assert.equal(headers.get('Authorization'), `Bearer ${token('B')}`); + } + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const oldReady = await service.probe({ + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }); + assert.equal(oldReady.status, 'ready'); + if (oldReady.status !== 'ready') return; + const oldActivation = await service.activate(oldReady.activationTicket); + + nativeStatus = connectStatus(origins.current, identities.current); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: profile.label, apiBaseUrl: origins.current, + }); + const currentClaim = connect.snapshotIdentityClaim(profile.id, origins.current); + assert.equal(currentClaim.status, 'claimed'); + assert.equal(oldClaim.isCurrent(), false); + if (oldClaim.status === 'claimed' && currentClaim.status === 'claimed') { + assert.ok(currentClaim.generation > oldClaim.generation); + } + + const beforeDetachedTransport = requests.length; + assert.deepEqual(service.prepareRequest( + `${origins.old}/api/tasks`, transportHeaders(oldActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(await service.prepareRequestAsync( + `wss://${new URL(origins.old).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${oldActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.equal(requests.slice(beforeDetachedTransport) + .some(request => request.authorization !== null), false); + + const beforeStaleOrigin = requests.length; + await assert.rejects(service.pair({ + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }), /Connect origin changed/i); + assert.equal(requests.length, beforeStaleOrigin); + assert.deepEqual(await store.readCredential(profile.id), oldCredential); + + const currentPairingStart = requests.length; + await service.pair({ id: profile.id, label: 'Current Connect', apiBaseUrl: origins.current }); + await service.awaitIdle(); + const currentBinding = testPairingBindings.get(origins.current); + assert.match(String(currentBinding?.credentialGeneration), /^[A-Za-z0-9_-]{22}$/); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.current, + publicInstanceIdentity: identities.current, + token: token('B'), + }); + const currentIdentityMatch = requests.findIndex((request, index) => index >= currentPairingStart + && request.url === `${origins.current}/api/desktop/discovery`); + const oldRevocation = requests.findIndex(request => request.url === `${origins.old}/api/desktop/tokens/current` + && request.authorization === `Bearer ${oldCredential.token}`); + assert.ok(currentIdentityMatch >= currentPairingStart); + assert.ok(oldRevocation > currentIdentityMatch); + assert.equal(requests.slice(currentPairingStart, currentIdentityMatch + 1) + .some(request => request.authorization !== null), false); + assert.equal(requests.slice(currentPairingStart) + .some(request => request.authorization === `Bearer ${oldCredential.token}` + && !request.url.endsWith('/api/desktop/tokens/current')), false); + + const currentReady = await service.probe({ + id: profile.id, label: 'Current Connect', apiBaseUrl: origins.current, + }); + assert.equal(currentReady.status, 'ready'); + if (currentReady.status !== 'ready') return; + const currentActivation = await service.activate(currentReady.activationTicket); + assert.equal(currentActivation.identityEpoch, currentBinding?.credentialGeneration); + assert.notEqual(currentActivation.identityEpoch, oldActivation.identityEpoch); + assert.notEqual(currentActivation.transportScope, oldActivation.transportScope); + assert.deepEqual(service.prepareRequest( + `${origins.old}/api/tasks`, transportHeaders(oldActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + `wss://${new URL(origins.old).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${oldActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync( + `${origins.current}/api/tasks`, transportHeaders(currentActivation.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + assert.deepEqual((await service.prepareRequestAsync( + `wss://${new URL(origins.current).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${currentActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + + const concurrentPairingStart = requests.length; + const stalePairing = service.pair({ + id: profile.id, label: 'Stale current Connect', apiBaseUrl: origins.current, + }); + await stalePollStarted.promise; + nativeStatus = connectStatus(origins.replacement, identities.replacement); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: 'Current Connect', apiBaseUrl: origins.replacement, + }); + const replacementClaim = connect.snapshotIdentityClaim(profile.id, origins.replacement); + assert.equal(replacementClaim.status, 'claimed'); + assert.equal(currentClaim.isCurrent(), false); + if (currentClaim.status === 'claimed' && replacementClaim.status === 'claimed') { + assert.ok(replacementClaim.generation > currentClaim.generation); + } + releaseStalePoll.resolve(provisionalPairingResponse( + `${origins.current}/api/desktop/pairings/dpr_${'C'.repeat(22)}/poll`, token('C'), + )); + await assert.rejects(stalePairing, /cancelled/i); + await service.awaitIdle(); + const concurrentRequests = requests.slice(concurrentPairingStart); + assert.equal(concurrentRequests.some(request => request.url.includes('/activate')), false); + assert.equal(concurrentRequests.filter(request => request.url.includes(`/dpr_${'C'.repeat(22)}/cancel`)).length, 1); + assert.equal(concurrentRequests.some(request => request.authorization !== null), false); + assert.equal(concurrentRequests.some(request => request.body?.includes(token('B')) + || request.body?.includes(token('C'))), false); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.current, + publicInstanceIdentity: identities.current, + token: token('B'), + }); + assert.deepEqual(await store.pendingRevocations(), []); + + const replacementPairingStart = requests.length; + await service.pair({ + id: profile.id, label: 'Replacement Connect', apiBaseUrl: origins.replacement, + }); + await service.awaitIdle(); + const replacementBinding = testPairingBindings.get(origins.replacement); + assert.match(String(replacementBinding?.credentialGeneration), /^[A-Za-z0-9_-]{22}$/); + assert.notEqual(replacementBinding?.credentialGeneration, currentBinding?.credentialGeneration); + const replacementIdentityMatch = requests.findIndex((request, index) => index >= replacementPairingStart + && request.url === `${origins.replacement}/api/desktop/discovery`); + const currentRevocation = requests.findIndex((request, index) => index >= replacementPairingStart + && request.url === `${origins.current}/api/desktop/tokens/current` + && request.authorization === `Bearer ${token('B')}`); + assert.ok(replacementIdentityMatch >= replacementPairingStart); + assert.ok(currentRevocation > replacementIdentityMatch); + assert.equal(requests.slice(concurrentPairingStart, replacementIdentityMatch + 1) + .some(request => request.authorization !== null), false); + assert.equal(requests.some(request => request.authorization === `Bearer ${token('C')}`), false); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.replacement, + publicInstanceIdentity: identities.replacement, + token: token('D'), + }); + + const replacementReady = await service.probe({ + id: profile.id, label: 'Replacement Connect', apiBaseUrl: origins.replacement, + }); + assert.equal(replacementReady.status, 'ready'); + if (replacementReady.status !== 'ready') return; + const replacementActivation = await service.activate(replacementReady.activationTicket); + assert.equal(replacementActivation.identityEpoch, replacementBinding?.credentialGeneration); + assert.notEqual(replacementActivation.transportScope, currentActivation.transportScope); + assert.deepEqual(service.prepareRequest( + `${origins.current}/api/tasks`, transportHeaders(currentActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + `wss://${new URL(origins.current).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${currentActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync( + `${origins.replacement}/api/tasks`, transportHeaders(replacementActivation.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('D')}` }); + assert.deepEqual((await service.prepareRequestAsync( + `wss://${new URL(origins.replacement).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${replacementActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + )).requestHeaders, { Authorization: `Bearer ${token('D')}` }); + assert.equal(requests.some(request => request.url.includes(oldActivation.transportScope) + || request.url.includes(currentActivation.transportScope) + || request.transportScope === oldActivation.transportScope + || request.transportScope === currentActivation.transportScope), false); + }); + + it('injects the active bearer only for its bound profile origin and strips renderer identity', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const wireRequests: Array<{ url: string; headers: Record }> = []; + let service!: DesktopCredentialService; + service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const requestHeaders: Record = {}; + new Headers(init?.headers).forEach((value, key) => { requestHeaders[key] = value; }); + // Simulate a session cookie Electron might otherwise append after the + // main-process fetch has applied its unforgeable request marker. + requestHeaders.Cookie = 'main-process=session'; + const decision = service.prepareRequest(url, requestHeaders); + assert.equal(decision.cancel, undefined); + wireRequests.push({ url, headers: decision.requestHeaders ?? {} }); + return url.endsWith('/api/desktop/discovery') ? json(discovery) : json({ username: 'octocat' }); + }, + }); + + const result = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(result.status, 'ready'); + if (result.status !== 'ready') return; + assert.match(result.activationTicket, /^[A-Za-z0-9_-]{43}$/); + assert.equal('transportScope' in result, false); + const activated = await service.activate(result.activationTicket); + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { + Cookie: 'legacy=session', Authorization: 'Bearer renderer-controlled', Accept: 'application/json', + }))).requestHeaders, { + Accept: 'application/json', + Authorization: `Bearer ${token('A')}`, + }); + assert.deepEqual(service.prepareRequest('https://attacker.example.test/api/tasks', transportHeaders(activated.transportScope, { + Cookie: 'inactive=session', Authorization: 'Bearer renderer-controlled', + })), { cancel: true }); + assert.deepEqual(service.prepareRequest('https://a.example.test/assets/app.js', transportHeaders(activated.transportScope, { + Cookie: 'active=session', Authorization: 'Bearer renderer-controlled', + })), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync(`wss://a.example.test/socket.io/?transport=websocket&proprDesktopTransportScope=${activated.transportScope}`, { + Cookie: 'socket=session', Authorization: 'Bearer renderer-controlled', + }, { resourceType: 'webSocket' })).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { + Cookie: 'legacy=session', + Authorization: 'Bearer renderer-controlled', + 'X-ProPR-Desktop-Main-Request': 'renderer-forgery', + }))).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + assert.deepEqual(service.prepareRequest('https://a.example.test/api/desktop/pairings', {}), { + cancel: true, + }); + assert.deepEqual(service.prepareRequest('https://a.example.test/api/desktop/tokens/current', {}), { + cancel: true, + }); + assert.deepEqual(service.prepareRequest('http://remote.example.test/api/tasks', {}), { cancel: true }); + assert.deepEqual(service.prepareRequest('http://127.1:3000/api/tasks', {}), { cancel: true }); + assert.deepEqual(service.prepareRequest('http://local%68ost:3000/api/tasks', {}), { cancel: true }); + assert.deepEqual(wireRequests.find(request => request.url.endsWith('/api/auth/user')), { + url: 'https://a.example.test/api/auth/user', + headers: { authorization: `Bearer ${token('A')}` }, + }); + assert.deepEqual(service.sanitizeResponseHeaders('https://a.example.test/api/tasks', { + 'Set-Cookie': ['active=session'], 'X-Test': ['preserved'], + }), { 'X-Test': ['preserved'] }); + assert.deepEqual(service.sanitizeResponseHeaders('https://inactive.example.test/api/tasks', { + 'set-cookie': ['inactive=session'], + }), {}); + assert.deepEqual(service.sanitizeResponseHeaders('wss://inactive.example.test/socket.io/', { + 'SET-COOKIE': ['socket=session'], + }), {}); + assert.deepEqual(await service.discardActivation({ + profileId: profile.id, transportScope: 'wrong-scope', + }), { discarded: false }); + assert.deepEqual(await service.discardActivation(activated), { discarded: true }); + assert.equal((await store.list()).activeProfileId, null); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, profile.apiBaseUrl, 'A')); + assert.deepEqual(service.prepareRequest( + profile.apiBaseUrl + '/api/tasks', transportHeaders(activated.transportScope), + ), { cancel: true }); + }); + + it('uses only the active bearer when profiles share an origin and never a cookie identity', async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://same.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + + assert.equal((await service.probe({ + id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl, + })).status, 'ready'); + const readyB = await service.probe({ + id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl, + }); + assert.equal(readyB.status, 'ready'); + if (readyB.status !== 'ready') return; + const activatedB = await service.activate(readyB.activationTicket); + + assert.deepEqual((await service.prepareRequestAsync('https://same.example.test/api/tasks', transportHeaders(activatedB.transportScope, { + Cookie: 'profile-a=session', Authorization: `Bearer ${token('A')}`, + }))).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + }); + + it('detaches origin and identity mismatches before bearer use or early protocol exits', async () => { + const store = await createStore(); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); + await store.writeCredential(credential(profileB.id, 'https://a.example.test', 'A')); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + requests.push({ url, authorization: new Headers(init?.headers).get('Authorization') }); + return json(discovery); + }, + }); + + const result = await service.probe({ + id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl, + }); + + assert.equal(result.status, 'authentication-required'); + assert.equal('activationTicket' in result, false); + assert.deepEqual(requests, [{ + url: 'https://b.example.test/api/desktop/discovery', + authorization: null, + }]); + assert.equal(requests.some(request => request.url.startsWith('https://a.example.test/')), false); + assert.equal(await store.readCredential(profileB.id), null); + assert.equal((await store.list()).activeProfileId, null); + + const replacementIdentity = '123e4567-e89b-42d3-a456-426614174001'; + for (const [name, replacementDiscovery, expectedStatus] of [ + ['incompatible', { + ...discovery, + version: '99.0.0', + apiCompatibility: '9999-12-31', + publicInstanceIdentity: replacementIdentity, + }, 'incompatible'], + ['capability', { + ...discovery, + publicInstanceIdentity: replacementIdentity, + desktopAuthentication: { + ...discovery.desktopAuthentication, + socketIoBearerAuthentication: false, + }, + }, 'authentication-required'], + ] as const) { + const store = await createStore(); + const profile = await store.save({ + id: `identity-${name}`, label: name, apiBaseUrl: `https://${name}.example.test`, + }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Identity early-exit test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json(replacementDiscovery); + }, + }); + + const result = await service.probe({ + id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl, + }); + assert.equal(result.status, expectedStatus); + assert.equal(await store.readCredential(profile.id), null); + assert.ok(requests.length >= 1); + assert.equal(requests.every(request => request.url === `${profile.apiBaseUrl}/api/desktop/discovery` + && request.authorization === null), true); + } + }); + + it('does not mint a ticket when a delayed B probe observes credential replacement with origin A', async () => { + const store = await createStore(); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const response = deferred(); + const authenticatedRequestStarted = deferred(); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + requests.push({ url, authorization }); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + authenticatedRequestStarted.resolve(); + return response.promise; + }, + }); + + const probe = service.probe({ + id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl, + }); + await authenticatedRequestStarted.promise; + const replacement = credential(profileB.id, 'https://a.example.test', 'A'); + await store.writeCredential(replacement); + response.resolve(json({ username: 'b' })); + const result = await probe; + + assert.equal(result.status, 'offline'); + assert.match(result.message, /connection changed/i); + assert.equal('activationTicket' in result, false); + assert.equal(requests.some(request => request.url.startsWith('https://a.example.test/')), false); + assert.deepEqual(requests.at(-1), { + url: 'https://b.example.test/api/auth/user', + authorization: `Bearer ${token('B')}`, + }); + assert.deepEqual(await store.readCredential(profileB.id), replacement); + assert.equal((await store.list()).activeProfileId, null); + }); + + it('atomically rejects a ticket when delayed activation races with profile B credential A', async () => { + const store = await createStore(); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const activationStarted = deferred(); + const releaseActivation = deferred(); + const delayedProfiles = new Proxy(store, { + get(target, property, receiver) { + if (property === 'activateProfile') { + return async (...args: Parameters) => { + activationStarted.resolve(); + await releaseActivation.promise; + return target.activateProfile(...args); + }; + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: delayedProfiles, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + requests.push({ url, authorization: new Headers(init?.headers).get('Authorization') }); + return url.endsWith('/api/desktop/discovery') ? json(discovery) : json({ username: 'b' }); + }, + }); + const ready = await service.probe({ + id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl, + }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + + const activation = service.activate(ready.activationTicket); + await activationStarted.promise; + const staleCredential = credential(profileB.id, 'https://a.example.test', 'A'); + await store.writeCredential(staleCredential); + releaseActivation.resolve(); + + await assert.rejects(activation, /expired/i); + assert.equal(requests.some(request => request.url.startsWith('https://a.example.test/')), false); + assert.deepEqual(await store.readCredential(profileB.id), staleCredential); + assert.equal((await store.list()).activeProfileId, null); + }); + + it('keeps a slow successful same-origin A probe status-only after fast B activates', async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://same.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const releaseA = deferred(); + const startedA = deferred(); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + const authorization = new Headers(init?.headers).get('Authorization'); + if (authorization === `Bearer ${token('A')}`) { + startedA.resolve(); + return releaseA.promise; + } + assert.equal(authorization, `Bearer ${token('B')}`); + return json({ username: 'b' }); + }, + }); + + const slowA = service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); + await startedA.promise; + const readyB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(readyB.status, 'ready'); + if (readyB.status !== 'ready') return; + const activatedB = await service.activate(readyB.activationTicket); + releaseA.resolve(json({ username: 'a' })); + const staleA = await slowA; + + assert.equal(staleA.status, 'offline'); + assert.match(staleA.message, /connection changed/i); + assert.deepEqual((await service.prepareRequestAsync( + 'https://same.example.test/api/tasks', + transportHeaders(activatedB.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + }); + + it('keeps A active while B is only probed and if B selection persistence fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + let failActivationState = false; + const store = new ProfileStore(directory, encryption, { + afterDurabilityStep: step => { + if (failActivationState && step === 'state-fsynced') throw new Error('injected activation persistence failure'); + }, + }); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://same.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const probeA = await service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); + assert.equal(probeA.status, 'ready'); + if (probeA.status !== 'ready') return; + const activeA = await service.activate(probeA.activationTicket); + const probeB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(probeB.status, 'ready'); + if (probeB.status !== 'ready') return; + + assert.equal((await store.list()).activeProfileId, profileA.id); + assert.deepEqual((await service.prepareRequestAsync( + profileA.apiBaseUrl + '/api/tasks', transportHeaders(activeA.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + + failActivationState = true; + await assert.rejects(service.activate(probeB.activationTicket)); + failActivationState = false; + assert.notEqual((await store.list()).activeProfileId, profileB.id); + assert.deepEqual((await service.prepareRequestAsync( + profileA.apiBaseUrl + '/api/tasks', transportHeaders(activeA.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + }); + + it('keeps B active during a direct same-origin A probe and rejects replayed activation tickets', async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://same.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const probeB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(probeB.status, 'ready'); + if (probeB.status !== 'ready') return; + const activeB = await service.activate(probeB.activationTicket); + await assert.rejects(service.activate(probeB.activationTicket), /expired/i); + + const probeA = await service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); + assert.equal(probeA.status, 'ready'); + assert.equal((await store.list()).activeProfileId, profileB.id); + assert.deepEqual((await service.prepareRequestAsync( + profileB.apiBaseUrl + '/api/tasks', transportHeaders(activeB.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + }); + + it('rejects activation after candidate removal, selection drift, or exact credential replacement', async () => { + for (const race of ['remove', 'selection', 'credential', 'credential-origin'] as const) { + const store = await createStore(); + const profileA = await store.save({ id: `profile-a-${race}`, label: 'A', apiBaseUrl: 'https://a.example.test' }); + const profileB = await store.save({ id: `profile-b-${race}`, label: 'B', apiBaseUrl: 'https://b.example.test' }); + await store.setActive(profileA.id); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const probeB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(probeB.status, 'ready'); + if (probeB.status !== 'ready') continue; + if (race === 'remove') await service.removeProfile(profileB.id); + else if (race === 'selection') await store.setActive(null); + else if (race === 'credential') { + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'C')); + } else { + await store.writeCredential(credential(profileB.id, profileA.apiBaseUrl, 'A')); + } + + await assert.rejects(service.activate(probeB.activationTicket), /expired/i); + assert.notEqual((await store.list()).activeProfileId, profileB.id); + if (race === 'credential') { + assert.deepEqual(await store.readCredential(profileB.id), credential(profileB.id, profileB.apiBaseUrl, 'C')); + } else if (race === 'credential-origin') { + assert.deepEqual(await store.readCredential(profileB.id), credential(profileB.id, profileA.apiBaseUrl, 'A')); + } + } + }); + + it('binds REST and Socket.IO work to one fresh scope and rejects stale or malformed markers', async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://same.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const readyA = await service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); + assert.equal(readyA.status, 'ready'); + if (readyA.status !== 'ready') return; + const activatedA = await service.activate(readyA.activationTicket); + const capturedRestA = transportHeaders(activatedA.transportScope, { + Cookie: 'renderer=session', + Authorization: 'Bearer renderer', + }); + const capturedSocketA = `wss://same.example.test/socket.io/?EIO=4&transport=websocket&proprDesktopTransportScope=${activatedA.transportScope}`; + + const readyB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(readyB.status, 'ready'); + if (readyB.status !== 'ready') return; + const activatedB = await service.activate(readyB.activationTicket); + + assert.deepEqual(service.prepareRequest('https://same.example.test/api/side-effect', capturedRestA), { cancel: true }); + assert.deepEqual(service.prepareRequest( + 'https://same.example.test/api/planner/drafts/draft-a/attachments/image-a', capturedRestA, + ), { cancel: true }); + assert.deepEqual(service.prepareRequest(capturedSocketA, { Cookie: 'socket=a' }, { resourceType: 'webSocket' }), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync( + 'https://same.example.test/api/side-effect', + transportHeaders(activatedB.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + const currentSocket = `wss://same.example.test/socket.io/?EIO=4&transport=websocket&proprDesktopTransportScope=${activatedB.transportScope}`; + assert.equal((await service.prepareRequestAsync(currentSocket, {}, { resourceType: 'webSocket' })).cancel, undefined); + assert.equal((await service.prepareRequestAsync(currentSocket, {}, { resourceType: 'webSocket' })).cancel, undefined); + assert.deepEqual(service.prepareRequest('wss://same.example.test/socket.io/?transport=websocket', {}, { + resourceType: 'webSocket', + }), { cancel: true }); + assert.deepEqual(service.prepareRequest(`${currentSocket}&proprDesktopTransportScope=${activatedB.transportScope}`, {}, { + resourceType: 'webSocket', + }), { cancel: true }); + assert.deepEqual(service.prepareRequest( + 'https://same.example.test/api/tasks', + { 'X-ProPR-Desktop-Transport-Scope': ['bad', activatedB.transportScope], Cookie: 'x', Authorization: 'Bearer x' }, + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + 'https://same.example.test/api/tasks', + { 'X-ProPR-Desktop-Transport-Scope': 'not-a-scope', Cookie: 'x', Authorization: 'Bearer x' }, + ), { cancel: true }); + assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', { + Cookie: 'x', Authorization: 'Bearer x', Accept: 'application/json', + }).requestHeaders, { Accept: 'application/json' }); + assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', transportHeaders(activatedB.transportScope, { + Cookie: 'x', Authorization: 'Bearer x', + 'Access-Control-Request-Headers': 'x-propr-desktop-transport-scope,content-type', + }), { method: 'OPTIONS' }).requestHeaders, { + 'Access-Control-Request-Headers': 'x-propr-desktop-transport-scope,content-type', + }); + }); + + it('passes through a realistic packaged-origin CORS preflight without renderer identity or bearer injection', () => { + const service = createCredentialService({ + profiles: { awaitIdle: async () => undefined } as unknown as ProfileStore, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async () => { throw new Error('Network is not expected'); }, + }); + + assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', { + Origin: DESKTOP_RENDERER_ORIGIN, + Cookie: 'renderer=session', + Authorization: 'Bearer renderer-controlled', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-ProPR-Desktop-Transport-Scope, Content-Type', + }, { method: 'OPTIONS' }), { + requestHeaders: { + Origin: DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-ProPR-Desktop-Transport-Scope, Content-Type', + }, + }); + }); + + it('rotates scope on every same-profile reprobe and rejects a cold reconnect from the old activation', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:3000' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const first = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(first.status, 'ready'); + if (first.status !== 'ready') return; + const firstActivation = await service.activate(first.activationTicket); + const second = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(second.status, 'ready'); + if (second.status !== 'ready') return; + const secondActivation = await service.activate(second.activationTicket); + assert.notEqual(firstActivation.transportScope, secondActivation.transportScope); + assert.equal(firstActivation.identityEpoch, secondActivation.identityEpoch); + assert.match(firstActivation.identityEpoch, /^[A-Za-z0-9_-]{22}$/); + assert.match(firstActivation.transportScope, /^[A-Za-z0-9_-]{22}$/); + assert.deepEqual(service.prepareRequest( + 'http://localhost:3000/api/tasks', transportHeaders(firstActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + `ws://localhost:3000/socket.io/?transport=websocket&proprDesktopTransportScope=${firstActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.equal((await service.prepareRequestAsync( + `ws://localhost:3000/socket.io/?transport=websocket&proprDesktopTransportScope=${secondActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + )).requestHeaders?.Authorization, `Bearer ${token('A')}`); + }); + + it('never sends an A-origin bearer after the profile URL is edited to an attacker origin', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const attackerOrigin = 'https://attacker.example.test'; + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + requests.push({ url, authorization: new Headers(init?.headers).get('Authorization') }); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + return new Response(null, { status: 204 }); + }, + }); + + const result = await service.probe({ + id: profile.id, + label: profile.label, + apiBaseUrl: attackerOrigin, + }); + + const attackerRequests = requests.filter(request => new URL(request.url).origin === attackerOrigin); + assert.equal(result.status, 'authentication-required'); + assert.notEqual(attackerRequests.length, 0); + assert.equal(attackerRequests.every(request => request.authorization === null), true); + assert.equal(requests.some(request => request.url === 'https://a.example.test/api/desktop/tokens/current'), false); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, profile.apiBaseUrl, 'A')); + }); + + it('preserves a re-paired credential and current connection after a stale definitive probe response', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const oldCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + const replacement = credential(profile.id, profile.apiBaseUrl, 'B'); + await store.writeCredential(oldCredential); + const oldProbeResponse = deferred(); + const oldProbePending = deferred(); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) return provisionalPairingResponse(url, replacement.token); + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/auth/user') && authorization === `Bearer ${oldCredential.token}`) { + oldProbePending.resolve(); + return oldProbeResponse.promise; + } + if (url.endsWith('/api/auth/user') && authorization === `Bearer ${replacement.token}`) { + return json({ username: 'replacement' }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const staleProbe = service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + await oldProbePending.promise; + await service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + const current = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(current.status, 'ready'); + const currentActivation = current.status === 'ready' ? await service.activate(current.activationTicket) : null; + + oldProbeResponse.resolve(json({ code: 'INVALID_INSTANCE_TOKEN' }, 401)); + const staleResult = await staleProbe; + + assert.equal(staleResult.status, 'offline'); + assert.match(staleResult.message, /connection changed.*try again/i); + assert.deepEqual(await store.readCredential(profile.id), replacement); + if (!currentActivation) return; + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {}))).requestHeaders, { + Authorization: `Bearer ${replacement.token}`, + }); + }); + + it('preserves a replacement credential at a changed origin after a stale definitive probe response', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const oldCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + const replacement = credential(profile.id, 'https://b.example.test', 'B'); + await store.writeCredential(oldCredential); + const oldProbeResponse = deferred(); + const oldProbePending = deferred(); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url === 'https://a.example.test/api/desktop/tokens/current') return new Response(null, { status: 204 }); + if (url.endsWith('/api/auth/user') && authorization === `Bearer ${oldCredential.token}`) { + oldProbePending.resolve(); + return oldProbeResponse.promise; + } + if (url === 'https://b.example.test/api/auth/user' + && authorization === `Bearer ${replacement.token}`) return json({ username: 'replacement' }); + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const staleProbe = service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + await oldProbePending.promise; + const changed = await service.saveProfile({ + id: profile.id, + label: profile.label, + apiBaseUrl: replacement.origin, + }); + await store.writeCredential(replacement); + const current = await service.probe({ id: changed.id, label: changed.label, apiBaseUrl: changed.apiBaseUrl }); + assert.equal(current.status, 'ready'); + const currentActivation = current.status === 'ready' ? await service.activate(current.activationTicket) : null; + + oldProbeResponse.resolve(json({ code: 'INVALID_INSTANCE_TOKEN' }, 401)); + const staleResult = await staleProbe; + + assert.equal(staleResult.status, 'offline'); + assert.match(staleResult.message, /connection changed.*try again/i); + assert.deepEqual(await store.readCredential(profile.id), replacement); + if (!currentActivation) return; + assert.deepEqual((await service.prepareRequestAsync('https://b.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {}))).requestHeaders, { + Authorization: `Bearer ${replacement.token}`, + }); + }); + + for (const failure of ['browser-launch', 'cancellation', 'expiry', 'polling', 'secure-storage'] as const) { + it(`preserves the active profile and credential when an origin edit fails during ${failure}`, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + let rejectReplacementEncryption = false; + const provider: EncryptionProvider = { + ...encryption, + encrypt: value => { + const stored = JSON.parse(value) as StoredCredential; + if (rejectReplacementEncryption && stored.token === token('B')) { + throw new Error('keychain encrypt failed'); + } + return Buffer.from(value, 'utf8'); + }, + }; + const store = new ProfileStore(directory, provider); + const profile = await store.save({ + id: 'profile-a', label: 'Working A', apiBaseUrl: 'https://a.example.test', + }); + const oldCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + await store.writeCredential(oldCredential); + await store.setActive(profile.id); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const requests: Array<{ url: string; authorization: string | null }> = []; + let service!: DesktopCredentialService; + service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => { + if (failure === 'browser-launch') throw new Error('Browser launch failed.'); + if (failure === 'cancellation') service.cancelPairing(profile.id); + }, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + requests.push({ url, authorization }); + if (url === 'https://a.example.test/api/desktop/discovery') return json(discovery); + if (url === 'https://a.example.test/api/auth/user') return json({ username: 'working-a' }); + if (url === 'https://b.example.test/api/desktop/pairings') return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://b.example.test/approve', + expiresAt: new Date(pairingNow + (failure === 'expiry' ? -1 : 10_000)).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + if (failure === 'polling') throw new Error('Pairing poll failed.'); + return provisionalPairingResponse(url, token('B')); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url === 'https://b.example.test/api/desktop/tokens/current') { + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + const ready = await service.probe({ + id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl, + }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const activated = await service.activate(ready.activationTicket); + rejectReplacementEncryption = failure === 'secure-storage'; + + await assert.rejects(service.pair({ + id: profile.id, + label: 'Proposed B', + apiBaseUrl: 'https://b.example.test', + })); + + assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); + assert.deepEqual(await store.readCredential(profile.id), oldCredential); + assert.deepEqual((await service.prepareRequestAsync( + 'https://a.example.test/api/tasks', + transportHeaders(activated.transportScope), + )).requestHeaders, { Authorization: `Bearer ${oldCredential.token}` }); + assert.equal(requests.some(request => request.url === 'https://a.example.test/api/desktop/tokens/current' + && request.authorization === `Bearer ${oldCredential.token}`), false); + }); + } + + it('commits an edited profile and replacement credential before revoking the old token', async () => { + const store = await createStore(); + const profile = await store.save({ + id: 'profile-a', label: 'Working A', apiBaseUrl: 'https://a.example.test', + }); + const oldCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + const replacement = credential(profile.id, 'https://b.example.test', 'B'); + await store.writeCredential(oldCredential); + await store.setActive(profile.id); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const revocationSnapshot = deferred<{ + state: Awaited>; + credential: StoredCredential | null; + }>(); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url === 'https://b.example.test/api/desktop/pairings') return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://b.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) return provisionalPairingResponse(url, replacement.token); + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url === 'https://a.example.test/api/desktop/tokens/current') { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${oldCredential.token}`); + revocationSnapshot.resolve({ + state: await store.list(), + credential: await store.readCredential(profile.id), + }); + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + await service.pair({ + id: profile.id, + label: 'Connected B', + apiBaseUrl: replacement.origin, + }); + + const stateAtRevocation = await revocationSnapshot.promise; + assert.equal(stateAtRevocation.state.profiles[0]?.label, 'Connected B'); + assert.equal(stateAtRevocation.state.profiles[0]?.apiBaseUrl, replacement.origin); + assert.equal(stateAtRevocation.state.activeProfileId, null); + assert.deepEqual(stateAtRevocation.credential, replacement); + assert.deepEqual(await store.readCredential(profile.id), replacement); + }); + + it('durably journals a provisional delivery before server activation and local publication', async () => { + const store = await createStore(); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const replacement = credential('profile-delivery', 'https://a.example.test', 'B'); + let activationChecked = false; + const service = createCredentialService({ + profiles: store, + clientName: 'Delivery ordering test', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }); + if (url.endsWith('/poll')) return provisionalPairingResponse(url, replacement.token); + if (url.endsWith('/activate')) { + const pending = await store.pendingRevocations(); + assert.equal(pending.length, 1); + assert.equal(pending[0]?.deferred, true); + assert.deepEqual(pending[0]?.credential, replacement); + assert.equal(await store.readCredential(replacement.profileId), null); + activationChecked = true; + return pairingActivationReceipt(); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + await service.pair({ + id: replacement.profileId, + label: 'Delivered B', + apiBaseUrl: replacement.origin, + }); + assert.equal(activationChecked, true); + assert.deepEqual(await store.readCredential(replacement.profileId), replacement); + assert.deepEqual(await store.pendingRevocations(), []); + console.log('NATIVE_SCENARIO delivery'); + }); + + it('retries an encrypted pending A revocation across failure, restart, remote success, and local cleanup failure', async () => { + const store = await createStore(); + const profile = await store.save({ + id: 'profile-a', label: 'Working A', apiBaseUrl: 'https://a.example.test', + }); + const credentialA = credential(profile.id, profile.apiBaseUrl, 'A'); + const credentialB = credential(profile.id, profile.apiBaseUrl, 'B'); + await store.writeCredential(credentialA); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const diagnostics: Array<{ code: string; status?: number }> = []; + let expectedProbeToken = credentialA.token; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + reportRevocationFailure: value => diagnostics.push(value), + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) return provisionalPairingResponse(url, credentialB.token); + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${credentialA.token}`); + return json({ error: 'offline' }, 503); + } + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/auth/user')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${expectedProbeToken}`); + return json({ username: 'credential-b' }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const readyA = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(readyA.status, 'ready'); + if (readyA.status !== 'ready') return; + const activeA = await service.activate(readyA.activationTicket); + await service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.deepEqual(await store.readCredential(profile.id), credentialB); + assert.equal((await store.pendingRevocations()).length, 1); + assert.deepEqual(diagnostics, [{ code: 'http', status: 503 }]); + assert.equal(JSON.stringify(diagnostics).includes(credentialA.token), false); + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(activeA.transportScope), + ), { cancel: true }); + expectedProbeToken = credentialB.token; + const ready = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const activeB = await service.activate(ready.activationTicket); + assert.deepEqual((await service.prepareRequestAsync( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(activeB.transportScope), + )).requestHeaders, { Authorization: `Bearer ${credentialB.token}` }); + + const offlineDiagnostics: Array<{ code: string; status?: number }> = []; + const offlineRestart = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + reportRevocationFailure: value => offlineDiagnostics.push(value), + fetch: async () => { throw new Error('offline'); }, + }); + await offlineRestart.initialize(); + assert.deepEqual(offlineDiagnostics, [{ code: 'network' }]); + assert.equal((await store.pendingRevocations()).length, 1); + + let failCleanup = true; + const cleanupFailingProfiles = new Proxy(store, { + get(target, property) { + if (property === 'completePendingRevocation') return async () => { + if (failCleanup) { + failCleanup = false; + throw new Error('injected cleanup failure'); + } + return false; + }; + const value = Reflect.get(target, property); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const cleanupDiagnostics: Array<{ code: string; status?: number }> = []; + const remoteSucceeded = createCredentialService({ + profiles: cleanupFailingProfiles, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + reportRevocationFailure: value => cleanupDiagnostics.push(value), + fetch: async () => new Response(null, { status: 204 }), + }); + await remoteSucceeded.initialize(); + assert.deepEqual(cleanupDiagnostics, [{ code: 'local-cleanup' }]); + assert.equal((await store.pendingRevocations()).length, 1); + + let terminalRetries = 0; + const onlineRestart = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); + terminalRetries += 1; + return terminalRevocation(init); + }, + }); + await onlineRestart.initialize(); + await onlineRestart.initialize(); + assert.equal(terminalRetries, 1); + assert.deepEqual(await store.pendingRevocations(), []); + assert.deepEqual(await store.readCredential(profile.id), credentialB); + + const uncertainDirectory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(uncertainDirectory); + let failCommitFlush = false; + let armedCommitFlushes = 0; + const uncertainStore = new ProfileStore(uncertainDirectory, encryption, { + beforeIO: operation => { + if (failCommitFlush && operation === 'journal-commit-flush') { + armedCommitFlushes += 1; + if (armedCommitFlushes === 2) throw new Error('injected journal commit flush failure'); + } + }, + }); + const uncertainProfile = await uncertainStore.save({ + id: 'profile-uncertain', label: 'A', apiBaseUrl: 'https://a.example.test', + }); + const uncertainA = credential(uncertainProfile.id, uncertainProfile.apiBaseUrl, 'A'); + const uncertainB = credential(uncertainProfile.id, uncertainProfile.apiBaseUrl, 'B'); + await uncertainStore.writeCredential(uncertainA); + const uncertainRevocations: string[] = []; + const uncertainService = createCredentialService({ + profiles: uncertainStore, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) return provisionalPairingResponse(url, uncertainB.token); + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) { + uncertainRevocations.push(new Headers(init?.headers).get('Authorization') ?? ''); + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + failCommitFlush = true; + await assert.rejects( + uncertainService.pair({ + id: uncertainProfile.id, + label: 'B', + apiBaseUrl: uncertainProfile.apiBaseUrl, + }), + /injected journal commit flush failure/, + ); + failCommitFlush = false; + assert.deepEqual(uncertainRevocations, [], 'verified B must not be revoked after C becomes observable'); + const uncertainRestart = new ProfileStore(uncertainDirectory, encryption); + assert.deepEqual(await uncertainRestart.readCredential(uncertainProfile.id), uncertainB); + assert.equal((await uncertainRestart.pendingRevocations()).length, 1); + }); + + const nativeRevocationCrashModes = ['during-revoke', 'after-remote-success'] as const; + assert.equal(nativeRevocationCrashModes.length, 2); + for (const crashMode of nativeRevocationCrashModes) { + it(`recovers B and retries idempotently after a real process crash ${crashMode}`, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + const setup = new ProfileStore(directory, encryption); + const profile = await setup.save({ + id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test', + }); + const credentialA = credential(profile.id, profile.apiBaseUrl, 'A'); + const credentialB = credential(profile.id, profile.apiBaseUrl, 'B'); + await setup.writeCredential(credentialA); + const baseline = await setup.readProfileCredential(profile.id); + await setup.commitPairedProfile( + { id: profile.id, label: 'B', apiBaseUrl: profile.apiBaseUrl }, + credentialB, baseline, () => true, + ); + assert.equal((await setup.pendingRevocations()).length, 1); + + const child = spawn(process.execPath, [ + '--import', 'tsx', join(import.meta.dirname, 'pending-revocation-crash-fixture.ts'), + directory, crashMode, + ], { stdio: 'ignore' }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal( + result.signal === 'SIGKILL' || (process.platform === 'win32' && result.code !== 0), + true, + `${crashMode}: child did not terminate at the requested revocation boundary`, + ); + + const restarted = new ProfileStore(directory, encryption); + assert.deepEqual(await restarted.readCredential(profile.id), credentialB); + assert.equal((await restarted.pendingRevocations()).length, 1); + let retries = 0; + const retryingService = createCredentialService({ + profiles: restarted, + clientName: 'Restarted desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); + retries += 1; + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${credentialA.token}`); + return terminalRevocation(init); + }, + }); + await retryingService.initialize(); + await retryingService.initialize(); + assert.equal(retries, 1); + assert.deepEqual(await restarted.pendingRevocations(), []); + assert.deepEqual(await restarted.readCredential(profile.id), credentialB); + console.log('NATIVE_SCENARIO revocation-crash'); + }); + } + + for (const [name, response] of [ + ['204 success', (_init: RequestInit | undefined) => new Response(null, { status: 204 })], + ['404 TOKEN_NOT_FOUND', (init: RequestInit | undefined) => terminalRevocation(init)], + ['401 INSTANCE_TOKEN_REVOKED', (init: RequestInit | undefined) => terminalRevocation(init, 'INSTANCE_TOKEN_REVOKED')], + ['401 INSTANCE_TOKEN_EXPIRED', (init: RequestInit | undefined) => terminalRevocation(init, 'INSTANCE_TOKEN_EXPIRED')], + ] as const) { + it(`cleans durable retry material only for endpoint-bound terminal ${name}`, async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-terminal', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const old = credential(profile.id, profile.apiBaseUrl, 'A'); + await store.writeCredential(old); + await store.removeCredential(profile.id); + const pending = await store.pendingRevocations(); + assert.equal(pending.length, 1); + const service = createCredentialService({ + profiles: store, + clientName: 'Terminal contract test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + assert.equal(input.toString(), `${old.origin}${DESKTOP_TOKEN_REVOCATION_ENDPOINT}`); + assert.equal(new Headers(init?.headers).get(DESKTOP_REVOCATION_BINDING_HEADER), pending[0].credentialGeneration); + return response(init); + }, + }); + await service.initialize(); + assert.deepEqual(await store.pendingRevocations(), []); + }); + } + + const retryableRevocationResponses: ReadonlyArray<[ + string, + (init: RequestInit | undefined) => Response, + ]> = [ + ['empty 401', () => new Response(null, { status: 401 })], + ['empty 404', () => new Response(null, { status: 404 })], + ['HTML route 404', () => new Response('

not found

', { status: 404, headers: { 'Content-Type': 'text/html' } })], + ['malformed JSON', () => new Response('{', { status: 404, headers: { 'Content-Type': 'application/json' } })], + ['wrong content type', init => new Response(JSON.stringify(terminalRevocationBody(init)), { + status: 404, headers: { 'Content-Type': 'text/plain' }, + })], + ['wrong schema version', init => json({ ...terminalRevocationBody(init), version: 2 }, 404)], + ['wrong credential generation', init => json({ + ...terminalRevocationBody(init), credentialGeneration: 'Z'.repeat(22), + }, 404)], + ['unknown terminal code', init => json({ ...terminalRevocationBody(init), code: 'INVALID_INSTANCE_TOKEN' }, 404)], + ['status/code mismatch', init => json(terminalRevocationBody(init), 401)], + ['redirect', () => Response.redirect('https://proxy.example.test/moved', 302)], + ['redirected 204', () => { + const result = new Response(null, { status: 204 }); + Object.defineProperty(result, 'redirected', { value: true }); + return result; + }], + ['wrong endpoint 204', () => { + const result = new Response(null, { status: 204 }); + Object.defineProperty(result, 'url', { value: 'https://proxy.example.test/api/desktop/tokens/current' }); + return result; + }], + ['server failure', () => json({ code: 'DESKTOP_AUTH_FAILED' }, 503)], + ['oversized JSON', init => json({ ...terminalRevocationBody(init), padding: 'x'.repeat(2_048) }, 404)], + ]; + for (const [name, response] of retryableRevocationResponses) { + it(`retains encrypted retry material for ${name}`, async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-retryable', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + await store.removeCredential(profile.id); + const diagnostics: Array<{ code: string; status?: number }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Retryable contract test', + openPairingBrowser: async () => undefined, + reportRevocationFailure: diagnostic => diagnostics.push(diagnostic), + fetch: async (_input, init) => response(init), + }); + await service.initialize(); + assert.equal((await store.pendingRevocations()).length, 1); + assert.deepEqual(diagnostics, [{ code: 'http', status: response(undefined).status }]); + assert.equal(JSON.stringify(diagnostics).includes(token('A')), false); + }); + } + + const streamingRevocationCases: ReadonlyArray<[ + string, + boolean, + (init: RequestInit | undefined) => Response, + ]> = [ + ['chunked 2048-byte terminal JSON', true, init => { + const jsonBody = JSON.stringify(terminalRevocationBody(init)); + const body = new TextEncoder().encode(jsonBody + ' '.repeat(2_048 - Buffer.byteLength(jsonBody))); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(body.slice(0, 1_024)); + controller.enqueue(body.slice(1_024)); + controller.close(); + }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }); + }], + ['chunked 2049-byte terminal JSON', false, init => { + const jsonBody = JSON.stringify(terminalRevocationBody(init)); + const body = new TextEncoder().encode(jsonBody + ' '.repeat(2_049 - Buffer.byteLength(jsonBody))); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(body.slice(0, 2_048)); + controller.enqueue(body.slice(2_048)); + controller.close(); + }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }); + }], + ['terminal JSON without Content-Length', true, init => { + const body = new TextEncoder().encode(JSON.stringify(terminalRevocationBody(init))); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(body.slice(0, 7)); + controller.enqueue(body.slice(7)); + controller.close(); + }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }); + }], + ['deceptive short Content-Length', false, init => { + const body = JSON.stringify(terminalRevocationBody(init)); + return new Response(body, { + status: 404, + headers: { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(body) - 1) }, + }); + }], + ['extra chunk after declared Content-Length', false, init => { + const body = new TextEncoder().encode(JSON.stringify(terminalRevocationBody(init))); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.enqueue(new TextEncoder().encode(' ')); + controller.close(); + }, + }), { + status: 404, + headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.byteLength) }, + }); + }], + ['malformed UTF-8', false, () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([0xc3, 0x28])); + controller.close(); + }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } })], + ['premature body error', false, () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + controller.error(new Error('injected body failure')); + }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } })], + ]; + + for (const [name, completes, response] of streamingRevocationCases) { + it(`${completes ? 'accepts' : 'retains'} encrypted retry material for ${name}`, async () => { + const store = await createStore(); + const profile = await store.save({ + id: 'profile-streaming', label: 'A', apiBaseUrl: 'https://a.example.test', + }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + await store.removeCredential(profile.id); + const service = createCredentialService({ + profiles: store, + clientName: 'Streaming terminal contract test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => response(init), + }); + + const initialized = await service.initialize(); + + assert.equal((await store.pendingRevocations()).length, completes ? 0 : 1); + assert.equal(initialized.status, completes ? 'ready' : 'degraded'); + }); + } + + it('bounds a one-byte slowloris body and retains its encrypted retry material', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-slowloris', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + await store.removeCredential(profile.id); + let bodyCancelled = false; + const service = createCredentialService({ + profiles: store, + clientName: 'Slowloris terminal contract test', + openPairingBrowser: async () => undefined, + revocationDeadlines: { headerMs: 50, bodyMs: 25, recordMs: 75, aggregateMs: 100 }, + fetch: async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('{')); }, + cancel() { bodyCancelled = true; }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }), + }); + + const initialized = await service.initialize(); + + assert.deepEqual(initialized, { status: 'degraded', retryPending: true }); + assert.equal(bodyCancelled, true); + assert.equal((await store.pendingRevocations()).length, 1); + }); + + it('dispose aborts a stalled header fetch, deduplicates its generation, and leaves no later activity', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-dispose-fetch', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + await store.removeCredential(profile.id); + const fetchStarted = deferred(); + let fetchCalls = 0; + let fetchAborted = false; + const service = createCredentialService({ + profiles: store, + clientName: 'Dispose fetch barrier test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => await new Promise((_resolve, reject) => { + fetchCalls += 1; + fetchStarted.resolve(); + const signal = init?.signal; + assert.ok(signal); + const abort = () => { + fetchAborted = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }), + }); + + const first = service.initialize(); + const duplicate = service.initialize(); + await fetchStarted.promise; + await service.dispose(); + await Promise.all([first, duplicate]); + const callsAtDispose = fetchCalls; + await new Promise(resolve => setTimeout(resolve, 20)); + + assert.equal(fetchAborted, true); + assert.equal(fetchCalls, 1); + assert.equal(fetchCalls, callsAtDispose); + assert.equal((await store.pendingRevocations()).length, 1); + await assert.rejects( + service.removeProfile(profile.id), + /credential service is closed/i, + ); + }); + + it('dispose cancels a headers-then-stall body and retains exact encrypted material', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-dispose-body', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const old = credential(profile.id, profile.apiBaseUrl, 'A'); + await store.writeCredential(old); + await store.removeCredential(profile.id); + const bodyStarted = deferred(); + let bodyCancelled = false; + let networkCalls = 0; + const service = createCredentialService({ + profiles: store, + clientName: 'Dispose body barrier test', + openPairingBrowser: async () => undefined, + fetch: async () => { + networkCalls += 1; + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + }, + pull() { + bodyStarted.resolve(); + }, + cancel() { bodyCancelled = true; }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }); + }, + }); + + const initialization = service.initialize(); + await bodyStarted.promise; + await service.dispose(); + await initialization; + const callsAtDispose = networkCalls; + await new Promise(resolve => setTimeout(resolve, 20)); + + const pending = await store.pendingRevocations(); + assert.equal(bodyCancelled, true); + assert.equal(networkCalls, callsAtDispose); + assert.equal(pending.length, 1); + assert.deepEqual(pending[0].credential, old); + }); + + it('dispose waits for terminal journal cleanup and no file operation runs afterward', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + const journalWriteStarted = deferred(); + const releaseJournalWrite = deferred(); + let barrierArmed = false; + let ioOperations = 0; + const store = new ProfileStore(directory, encryption, { + beforeIO: operation => { + ioOperations += 1; + if (barrierArmed && operation === 'journal-write') { + barrierArmed = false; + journalWriteStarted.resolve(); + return releaseJournalWrite.promise; + } + }, + }); + const profile = await store.save({ id: 'profile-dispose-journal', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + await store.removeCredential(profile.id); + barrierArmed = true; + let networkCalls = 0; + const service = createCredentialService({ + profiles: store, + clientName: 'Dispose journal barrier test', + openPairingBrowser: async () => undefined, + fetch: async () => { + networkCalls += 1; + return new Response(null, { status: 204 }); + }, + }); + + const initialization = service.initialize(); + await journalWriteStarted.promise; + let disposed = false; + const disposal = service.dispose().then(() => { disposed = true; }); + await Promise.resolve(); + assert.equal(disposed, false); + releaseJournalWrite.resolve(); + await Promise.all([initialization, disposal]); + const ioAtDispose = ioOperations; + const networkAtDispose = networkCalls; + await new Promise(resolve => setTimeout(resolve, 20)); + + assert.equal(ioOperations, ioAtDispose); + assert.equal(networkCalls, networkAtDispose); + assert.deepEqual(await store.pendingRevocations(), []); + console.log('NATIVE_SCENARIO dispose'); + }); + + it('bounds aggregate startup across stalled records and recovers all encrypted records later', async () => { + const store = await createStore(); + for (const [id, character] of [['profile-startup-a', 'A'], ['profile-startup-b', 'B']] as const) { + const profile = await store.save({ id, label: id, apiBaseUrl: `https://${character.toLowerCase()}.example.test` }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, character)); + await store.removeCredential(profile.id); + } + let stalledCalls = 0; + const offline = createCredentialService({ + profiles: store, + clientName: 'Bounded startup test', + openPairingBrowser: async () => undefined, + revocationDeadlines: { headerMs: 100, bodyMs: 50, recordMs: 125, aggregateMs: 500 }, + fetch: async (_input, init) => await new Promise((_resolve, reject) => { + stalledCalls += 1; + const signal = init?.signal; + assert.ok(signal); + const abort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }), + }); + const startedAt = Date.now(); + + const initialization = await offline.initialize(); + + assert.deepEqual(initialization, { status: 'degraded', retryPending: true }); + assert.ok(Date.now() - startedAt < 1_500); + assert.equal(stalledCalls, 2); + assert.equal((await store.pendingRevocations()).length, 2); + await offline.dispose(); + + let recoveryCalls = 0; + const online = createCredentialService({ + profiles: store, + clientName: 'Later online recovery test', + openPairingBrowser: async () => undefined, + fetch: async () => { + recoveryCalls += 1; + return new Response(null, { status: 204 }); + }, + }); + assert.deepEqual(await online.initialize(), { status: 'ready', retryPending: false }); + assert.equal(recoveryCalls, 4, 'each revocation is preceded by one unauthenticated discovery'); + assert.deepEqual(await store.pendingRevocations(), []); + }); + + it('retries a crash-left provisional pairing credential on startup', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-provisional', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const provisional = await store.journalPendingRevocation( + credential(profile.id, profile.apiBaseUrl, 'C'), + ); + assert.equal('stored' in provisional, false); + if ('stored' in provisional) return; + assert.equal(provisional.deferred, true); + let calls = 0; + const restarted = createCredentialService({ + profiles: store, + clientName: 'Restarted after provisional crash', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); + calls += 1; + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${token('C')}`); + return new Response(null, { status: 204 }); + }, + }); + await restarted.initialize(); + assert.equal(calls, 1); + assert.deepEqual(await store.pendingRevocations(), []); + console.log('NATIVE_SCENARIO transient-revocation'); + console.log('NATIVE_SCENARIO provisional'); + }); + + it('ignores delayed A invalidation after B connects and preserves tokens for authorization/transient codes', async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); + await store.writeCredential(credential(profileA.id, profileA.apiBaseUrl, 'A')); + await store.writeCredential(credential(profileB.id, profileB.apiBaseUrl, 'B')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const readyA = await service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); + const activatedA = readyA.status === 'ready' ? await service.activate(readyA.activationTicket) : null; + const readyB = await service.probe({ id: profileB.id, label: profileB.label, apiBaseUrl: profileB.apiBaseUrl }); + assert.equal(readyA.status, 'ready'); + assert.equal(readyB.status, 'ready'); + if (readyA.status !== 'ready' || readyB.status !== 'ready') return; + const activatedB = await service.activate(readyB.activationTicket); + if (!activatedA) return; + + assert.deepEqual(await service.invalidate({ + profileId: profileA.id, + transportScope: activatedA.transportScope, + code: 'INVALID_INSTANCE_TOKEN', + }), { invalidated: false }); + assert.deepEqual(await service.invalidate({ + profileId: profileB.id, + transportScope: activatedB.transportScope, + code: 'AUTHORIZATION_CHANGED', + }), { invalidated: false }); + assert.deepEqual(await service.invalidate({ + profileId: profileB.id, + transportScope: activatedB.transportScope, + code: 'AUTHENTICATION_FAILED', + }), { invalidated: false }); + assert.ok(await store.readCredential(profileA.id)); + assert.ok(await store.readCredential(profileB.id)); + + assert.deepEqual(await service.invalidate({ + profileId: profileB.id, + transportScope: activatedB.transportScope, + code: 'INVALID_INSTANCE_TOKEN', + }), { invalidated: true }); + await service.initialize(); + assert.ok(await store.readCredential(profileA.id)); + assert.equal(await store.readCredential(profileB.id), null); + }); + + it('preserves a replacement written while an old transient token revocation is pending', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + let service!: DesktopCredentialService; + let cancelOldPairingOnWrite = true; + const cancellingEncryption: EncryptionProvider = { + ...encryption, + encrypt: value => { + const stored = JSON.parse(value) as StoredCredential; + if (cancelOldPairingOnWrite && stored.token === token('C')) { + cancelOldPairingOnWrite = false; + service.cancelPairing(stored.profileId); + } + return Buffer.from(value, 'utf8'); + }, + }; + const store = new ProfileStore(directory, cancellingEncryption); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const revocationStarted = deferred(); + const releaseRevocation = deferred(); + let pairingNumber = 0; + let currentPairing = 0; + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) { + currentPairing = ++pairingNumber; + return pairingStartResponse(url, init, { + pairingId: `dpr_${String.fromCharCode(64 + currentPairing).repeat(22)}`, + deviceSecret: String.fromCharCode(66 + currentPairing).repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + } + if (url.endsWith('/poll')) { + const character = currentPairing === 1 ? 'C' : 'D'; + return provisionalPairingResponse(url, token(character)); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${token('C')}`); + revocationStarted.resolve(); + return releaseRevocation.promise; + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const oldPairing = assert.rejects( + service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }), + /cancelled/i, + ); + await revocationStarted.promise; + await service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + releaseRevocation.resolve(new Response(null, { status: 204 })); + await oldPairing; + + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, profile.apiBaseUrl, 'D')); + }); + + it('keeps an exactly persisted cancelled pairing token pending when revocation fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + let service!: DesktopCredentialService; + const cancellingEncryption: EncryptionProvider = { + ...encryption, + encrypt: value => { + const stored = JSON.parse(value) as StoredCredential; + if (stored.token === token('C')) service.cancelPairing(stored.profileId); + return Buffer.from(value, 'utf8'); + }, + }; + const store = new ProfileStore(directory, cancellingEncryption); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + return provisionalPairingResponse(url, token('C')); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) return json({ error: 'unavailable' }, 500); + throw new Error(`Unexpected request: ${url}`); + }, + }); + + await assert.rejects( + service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }), + /cancelled/i, + ); + assert.equal(await store.readCredential(profile.id), null); + const pending = await store.pendingRevocations(); + assert.equal(pending.length, 1); + assert.equal(pending[0].credential.token, token('C')); + console.log('NATIVE_SCENARIO transient-revocation'); + }); + + it('detaches a removed profile locally before deferred revoke and preserves a later replacement', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const storedCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + await store.writeCredential(storedCredential); + await store.setActive(profile.id); + const revocationStarted = deferred(); + const releaseRevocation = deferred(); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/auth/user')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${storedCredential.token}`); + return json({ username: 'octocat' }); + } + if (url.endsWith('/api/desktop/tokens/current')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${storedCredential.token}`); + revocationStarted.resolve(); + return releaseRevocation.promise; + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const active = await service.activate(ready.activationTicket); + const pending = await service.probe(profile); + assert.equal(pending.status, 'ready'); + if (pending.status !== 'ready') return; + + let rendererSuccessPublished = false; + let removalError: unknown; + const failedRemoval = service.removeProfile(profile.id, async origin => { + assert.equal(origin, profile.apiBaseUrl); + throw new Error('origin storage clear failed'); + }).then(result => { + rendererSuccessPublished = true; + return result; + }); + await assert.rejects(failedRemoval, error => { + removalError = error; + return error instanceof Error && /origin storage clear failed/.test(error.message); + }); + + assert.equal(rendererSuccessPublished, false); + assert.doesNotMatch(String(removalError), new RegExp(storedCredential.token)); + assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + assert.deepEqual(await store.pendingRevocations(), []); + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(active.transportScope), + ), { cancel: true }); + await assert.rejects( + service.activate(pending.activationTicket), + /Desktop activation expired/, + ); + + const reconstructedReady = await service.probe(profile); + assert.equal(reconstructedReady.status, 'ready'); + if (reconstructedReady.status !== 'ready') return; + const reconstructed = await service.activate(reconstructedReady.activationTicket); + assert.equal(reconstructed.profileId, profile.id); + assert.notEqual(reconstructed.transportScope, active.transportScope); + assert.equal('token' in reconstructed, false); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + + const removal = service.removeProfile(profile.id); + await revocationStarted.promise; + assert.equal((await store.list()).profiles.some(item => item.id === profile.id), false); + assert.equal(await store.readCredential(profile.id), null); + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(reconstructed.transportScope), + ), { cancel: true }); + + const replacementProfile = await service.saveProfile({ + id: profile.id, + label: 'Replacement', + apiBaseUrl: profile.apiBaseUrl, + }); + const replacementCredential = credential(profile.id, profile.apiBaseUrl, 'B'); + await store.writeCredential(replacementCredential); + releaseRevocation.resolve(new Response(null, { status: 204 })); + await removal; + // Drain the serialized retry queue before the test removes its keychain + // directory; removeProfile intentionally does not wait on the network. + await service.initialize(); + + assert.equal((await store.list()).profiles.find(item => item.id === profile.id)?.label, replacementProfile.label); + assert.deepEqual(await store.readCredential(profile.id), replacementCredential); + }); + + it('never lets a delayed A-to-B revoke overwrite a later C save, pairing, selection, or credential', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.setActive(profile.id); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const revokeStarted = deferred(); + const releaseRevoke = deferred(); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url === 'https://a.example.test/api/desktop/tokens/current') { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${token('A')}`); + revokeStarted.resolve(); + return releaseRevoke.promise; + } + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://c.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + return provisionalPairingResponse(url, token('C')); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/auth/user')) return json({ username: 'c' }); + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const staleBSave = service.saveProfile({ + id: profile.id, label: 'B', apiBaseUrl: 'https://b.example.test', + }); + await revokeStarted.promise; + const profileC = await service.saveProfile({ + id: profile.id, label: 'C', apiBaseUrl: 'https://c.example.test', + }); + await service.pair({ id: profile.id, label: 'C', apiBaseUrl: profileC.apiBaseUrl }); + const probeC = await service.probe({ id: profile.id, label: 'C', apiBaseUrl: profileC.apiBaseUrl }); + assert.equal(probeC.status, 'ready'); + if (probeC.status !== 'ready') return; + await service.activate(probeC.activationTicket); + + releaseRevoke.resolve(new Response(null, { status: 204 })); + await staleBSave; + + const finalState = await store.list(); + assert.equal(finalState.profiles.find(item => item.id === profile.id)?.label, 'C'); + assert.equal(finalState.profiles.find(item => item.id === profile.id)?.apiBaseUrl, 'https://c.example.test'); + assert.equal(finalState.activeProfileId, profile.id); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, 'https://c.example.test', 'C')); + }); + + it('returns connection-changed and preserves a re-paired credential for an old ready invalidation', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const oldCredential = credential(profile.id, profile.apiBaseUrl, 'A'); + const replacement = credential(profile.id, profile.apiBaseUrl, 'B'); + await store.writeCredential(oldCredential); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/auth/user')) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${oldCredential.token}`); + return json({ username: 'old-user' }); + } + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + return provisionalPairingResponse(url, replacement.token); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + throw new Error(`Unexpected request: ${url}`); + }, + }); + const ready = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const activated = await service.activate(ready.activationTicket); + + await service.pair({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.deepEqual(await service.invalidate({ + profileId: profile.id, + transportScope: activated.transportScope, + code: 'INVALID_INSTANCE_TOKEN', + }), { invalidated: false }); + + assert.deepEqual(await store.readCredential(profile.id), replacement); + }); + + for (const race of ['delete', 'switch'] as const) { + it(`revokes a transient completion instead of persisting when pairing races with ${race}`, async () => { + const store = await createStore(); + const profileA = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); + let service!: DesktopCredentialService; + let raced = false; + let raceOperation: Promise = Promise.resolve(); + const revocations: string[] = []; + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + let listCalls = 0; + const profiles = { + list: async () => { + const result = await store.list(); + listCalls += 1; + if (listCalls === 2 && !raced) { + raced = true; + raceOperation = race === 'delete' + ? service.removeProfile(profileA.id) + : service.setActiveProfile(profileB.id); + } + return result; + }, + saveAndDetachCredential: (input: Parameters[0]) => + store.saveAndDetachCredential(input), + commitPairedProfile: (...args: Parameters) => { + if (!raced) { + raced = true; + raceOperation = race === 'delete' + ? service.removeProfile(profileA.id) + : service.setActiveProfile(profileB.id); + } + return store.commitPairedProfile(...args); + }, + detachProfile: (profileId: string) => store.detachProfile(profileId), + setActive: (profileId: string | null) => store.setActive(profileId), + activateProfile: (...args: Parameters) => store.activateProfile(...args), + security: () => store.security(), + readCredential: (profileId: string) => store.readCredential(profileId), + readProfileCredential: (profileId: string) => store.readProfileCredential(profileId), + writeCredential: (value: StoredCredential) => store.writeCredential(value), + removeCredential: (profileId: string) => store.removeCredential(profileId), + removeCredentialIfCurrent: (...args: Parameters) => + store.removeCredentialIfCurrent(...args), + journalPendingRevocation: (value: StoredCredential) => store.journalPendingRevocation(value), + releasePendingRevocation: (...args: Parameters) => + store.releasePendingRevocation(...args), + pendingRevocations: () => store.pendingRevocations(), + completePendingRevocation: (...args: Parameters) => + store.completePendingRevocation(...args), + awaitIdle: () => store.awaitIdle(), + }; + service = createCredentialService({ + profiles, + clientName: 'Test desktop', + pairingTiming: { + now: () => pairingNow, + sleep: async () => undefined, + }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + return provisionalPairingResponse(url, token('C')); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) { + revocations.push(new Headers(init?.headers).get('Authorization') ?? ''); + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + await assert.rejects( + service.pair({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }), + /cancelled/i, + ); + await raceOperation; + assert.equal(await store.readCredential(profileA.id), null); + assert.deepEqual(revocations, [`Bearer ${token('C')}`]); + console.log('NATIVE_SCENARIO transient-revocation'); + }); + } + + const pairedPublishBoundaries = ['state-written', 'state-fsynced'] as const; + const pairedPublishRaces = ['cancel', 'switch'] as const; + assert.equal(pairedPublishBoundaries.length * pairedPublishRaces.length, 4); + for (const boundary of pairedPublishBoundaries) { + for (const race of pairedPublishRaces) { + it(`keeps durable A when ${race} linearizes at paired ${boundary} before publish`, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-credential-service-')); + temporaryDirectories.push(directory); + const reached = deferred(); + const release = deferred(); + let armed = false; + const store = new ProfileStore(directory, encryption, { + afterDurabilityStep: async step => { + if (!armed || step !== boundary) return; + armed = false; + reached.resolve(); + await release.promise; + }, + }); + const profileA = await store.save({ + id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test', + }); + const profileB = await store.save({ + id: 'profile-b', label: 'Other', apiBaseUrl: 'https://b.example.test', + }); + const credentialA = credential(profileA.id, profileA.apiBaseUrl, 'A'); + await store.writeCredential(credentialA); + await store.setActive(profileA.id); + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const revocations: string[] = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/api/desktop/pairings')) return pairingStartResponse(url, init, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'C'.repeat(43), + approvalUrl: 'https://a.example.test/approve', + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + if (url.endsWith('/poll')) { + return provisionalPairingResponse(url, token('C')); + } + if (url.endsWith('/activate')) return pairingActivationReceipt(); + if (url.endsWith('/api/desktop/tokens/current')) { + revocations.push(new Headers(init?.headers).get('Authorization') ?? ''); + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + armed = true; + const pairing = service.pair({ + id: profileA.id, label: 'Proposed B', apiBaseUrl: profileA.apiBaseUrl, + }); + await reached.promise; + const raced = race === 'cancel' + ? Promise.resolve(service.cancelPairing(profileA.id)) + : service.setActiveProfile(profileB.id); + release.resolve(); + + await assert.rejects(pairing, /cancelled/i); + await raced; + const restarted = new ProfileStore(directory, encryption); + const snapshot = await restarted.readProfileCredential(profileA.id); + assert.equal(snapshot.profile?.label, 'A'); + assert.deepEqual(snapshot.credential, credentialA); + assert.equal((await restarted.list()).activeProfileId, race === 'cancel' ? profileA.id : profileB.id); + assert.deepEqual(revocations, [`Bearer ${token('C')}`]); + assert.deepEqual(service.prepareRequest( + `${profileA.apiBaseUrl}/api/tasks`, transportHeaders('AAAAAAAAAAAAAAAAAAAAAA'), + ), { cancel: true }); + console.log('NATIVE_SCENARIO cancellation-switch'); + }); + } + } +}); diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts new file mode 100644 index 000000000..9e6aa97d2 --- /dev/null +++ b/apps/desktop/src/credential-service.ts @@ -0,0 +1,1519 @@ +import { randomBytes } from 'node:crypto'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClient, + ProprClientError, + type PairingProtocolRequestOptions, + type ProprDesktopPairingOptions, +} from '@propr/client'; +import { + DESKTOP_REVOCATION_BINDING_HEADER, + DESKTOP_TOKEN_REVOCATION_ENDPOINT, + DESKTOP_TOKEN_REVOCATION_SCHEMA, + DESKTOP_TOKEN_REVOCATION_VERSION, + DESKTOP_TOKEN_TERMINAL_CODES, + DESKTOP_TRANSPORT_SCOPE_HEADER, + DESKTOP_TRANSPORT_SCOPE_QUERY, + canonicalProprHttpUrlOrigin, + isPublicInstanceIdentity, +} from '@propr/shared'; +import { + type DesktopProfileInput, + type DesktopConnectionResult, + type DesktopActivatedConnection, + type DesktopAccessInvalidation, + type DesktopConnectionScope, +} from './shared/contract'; +import { normalizeApiBaseUrl } from './security'; +import type { PendingCredentialRevocation, ProfileStore, StoredCredential } from './profile-store'; +import type { DesktopConnectIdentityClaimSnapshot } from './connect-discovery'; + +const DEFINITIVE_INVALID_CODES = new Set([ + 'INVALID_INSTANCE_TOKEN', + 'INSTANCE_TOKEN_EXPIRED', + 'INSTANCE_TOKEN_REVOKED', +]); + +export interface CredentialServiceDependencies { + profiles: Pick; + fetch: typeof globalThis.fetch; + openPairingBrowser(request: DesktopPairingBrowserRequest): Promise; + clientName: string; + /** Deterministic pairing timing for protocol tests. Production uses the client defaults. */ + pairingTiming?: Pick; + /** Deterministic service/native lifecycle proof; production uses fixed protocol defaults. */ + pairingProtocol?: PairingProtocolRequestOptions; + /** Tests may shorten, but never enlarge, the production revocation deadlines. */ + revocationDeadlines?: Partial; + reportRevocationFailure?(diagnostic: { + code: 'network' | 'http' | 'local-cleanup'; + status?: number; + }): void; + /** Main-owned Connect evidence; renderer input can never provide this snapshot. */ + snapshotConnectIdentityClaim?(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot; +} + +export interface DesktopPairingBrowserRequest { + apiBaseUrl: string; + pairingId: string; + approvalUrl: string; +} + +export interface CredentialServiceInitialization { + status: 'ready' | 'degraded'; + retryPending: boolean; +} + +interface RevocationDeadlines { + headerMs: number; + bodyMs: number; + recordMs: number; + aggregateMs: number; +} + +interface ActiveCredential extends StoredCredential { + identityEpoch: string; + profileGeneration: number; + selectionGeneration: number; + transportScope: string; + connectClaim: DesktopConnectIdentityClaimSnapshot; +} + +interface PendingActivation { + ticket: string; + probeTicket: number; + profileId: string; + origin: string; + profileGeneration: number; + selectionGeneration: number; + activeProfileId: string | null; + credential: StoredCredential; + identityEpoch: string; + connectClaim: DesktopConnectIdentityClaimSnapshot; +} + +type RequestHeaders = Record; +export interface DesktopRequestDecision { + cancel?: true; + requestHeaders?: RequestHeaders; +} + +const headerName = (headers: RequestHeaders, name: string): string | undefined => + Object.keys(headers).find(key => key.toLowerCase() === name.toLowerCase()); + +const removeHeader = (headers: RequestHeaders, name: string): void => { + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing]; + } +}; + +const headerValues = (headers: RequestHeaders, name: string): string[] => { + const values: string[] = []; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== name.toLowerCase()) continue; + if (Array.isArray(value)) values.push(...value); + else values.push(value); + } + return values; +}; + +const TRANSPORT_SCOPE_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const MAX_REVOCATION_RESPONSE_BYTES = 2_048; +const TERMINAL_REVOCATION_CODES = new Set(DESKTOP_TOKEN_TERMINAL_CODES); +const REVOCATION_DEADLINES: RevocationDeadlines = { + headerMs: 8_000, + bodyMs: 2_000, + recordMs: 10_000, + aggregateMs: 12_000, +}; + +const boundedRevocationDeadlines = ( + requested: Partial | undefined, +): RevocationDeadlines => Object.fromEntries( + Object.entries(REVOCATION_DEADLINES).map(([key, maximum]) => { + const value = requested?.[key as keyof RevocationDeadlines] ?? maximum; + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new Error('Invalid desktop revocation deadline'); + } + return [key, value]; + }), +) as unknown as RevocationDeadlines; + +const linkedAbortController = (signals: readonly AbortSignal[]): { + controller: AbortController; + dispose: () => void; +} => { + const controller = new AbortController(); + const onAbort = (event: Event): void => { + const signal = event.target as AbortSignal; + if (!controller.signal.aborted) controller.abort(signal.reason); + }; + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + break; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + return { + controller, + dispose: () => signals.forEach(signal => signal.removeEventListener('abort', onAbort)), + }; +}; + +const requestOrigin = (value: string): { origin: string; pathname: string; url: URL } | null => { + try { + const httpValue = value.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'); + const url = new URL(value); + if (url.protocol === 'ws:') url.protocol = 'http:'; + if (url.protocol === 'wss:') url.protocol = 'https:'; + if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) return null; + if (canonicalProprHttpUrlOrigin(httpValue) !== url.origin) return null; + return { origin: url.origin, pathname: url.pathname, url }; + } catch { + return null; + } +}; + +const parseCode = async (response: Response): Promise => { + try { + const value = await response.clone().json() as { code?: unknown }; + return typeof value.code === 'string' ? value.code : undefined; + } catch { + return undefined; + } +}; + +const isEndpointBoundTerminalRevocation = async ( + response: Response, + credential: StoredCredential, + credentialGeneration: string, + signal: AbortSignal, + abortNetwork: () => void, + bodyDeadlineMs: number, +): Promise => { + if (response.redirected) return false; + if (response.url) { + try { + const url = new URL(response.url); + if (url.href !== `${credential.origin}${DESKTOP_TOKEN_REVOCATION_ENDPOINT}`) return false; + } catch { + return false; + } + } + if (response.ok) return true; + if (response.status !== 401 && response.status !== 404) return false; + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (contentType !== 'application/json') return false; + const declaredLength = response.headers.get('content-length'); + if (declaredLength !== null + && (!/^(?:0|[1-9][0-9]*)$/.test(declaredLength) + || Number(declaredLength) > MAX_REVOCATION_RESPONSE_BYTES)) return false; + if (!response.body) return false; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + let deadline: ReturnType | undefined; + let rejectAbort!: (reason: unknown) => void; + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; }); + const onAbort = (): void => rejectAbort(signal.reason ?? new Error('Desktop revocation body was cancelled')); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + deadline = setTimeout(() => { + abortNetwork(); + rejectAbort(new Error('Desktop revocation body timed out')); + }, bodyDeadlineMs); + let text: string; + try { + while (true) { + const part = await Promise.race([reader.read(), aborted]); + if (part.done) break; + if (!(part.value instanceof Uint8Array) || part.value.byteLength === 0) { + abortNetwork(); + return false; + } + received += part.value.byteLength; + if (received > MAX_REVOCATION_RESPONSE_BYTES) { + abortNetwork(); + return false; + } + chunks.push(Uint8Array.from(part.value)); + } + if (declaredLength !== null && Number(declaredLength) !== received) { + abortNetwork(); + return false; + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + abortNetwork(); + return false; + } finally { + if (deadline) clearTimeout(deadline); + signal.removeEventListener('abort', onAbort); + if (signal.aborted) { + // Invoking both primitives is important for native fetch and deterministic + // ReadableStream tests. Network abort is the authoritative bounded wait. + let cancelDeadline: ReturnType | undefined; + try { + await Promise.race([ + reader.cancel(), + new Promise(resolve => { + cancelDeadline = setTimeout(resolve, Math.min(bodyDeadlineMs, 100)); + }), + ]); + } catch { + // The owning network controller is already aborted. + } finally { + if (cancelDeadline) clearTimeout(cancelDeadline); + } + } + try { reader.releaseLock(); } catch { /* A hostile stream may retain a pending read. */ } + } + let raw: unknown; + try { + raw = JSON.parse(text) as unknown; + } catch { + abortNetwork(); + return false; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + abortNetwork(); + return false; + } + const body = raw as Record; + const expectedKeys = [ + 'schema', 'version', 'endpoint', 'terminal', 'code', 'credentialGeneration', + ]; + if (Object.keys(body).length !== expectedKeys.length + || expectedKeys.some(key => !(key in body))) { + abortNetwork(); + return false; + } + if (body.schema !== DESKTOP_TOKEN_REVOCATION_SCHEMA + || body.version !== DESKTOP_TOKEN_REVOCATION_VERSION + || body.endpoint !== DESKTOP_TOKEN_REVOCATION_ENDPOINT + || body.terminal !== true + || body.credentialGeneration !== credentialGeneration + || typeof body.code !== 'string' + || !TERMINAL_REVOCATION_CODES.has(body.code)) { + abortNetwork(); + return false; + } + const terminal = response.status === 404 + ? body.code === 'TOKEN_NOT_FOUND' + : body.code === 'INSTANCE_TOKEN_REVOKED' || body.code === 'INSTANCE_TOKEN_EXPIRED'; + if (!terminal) abortNetwork(); + return terminal; +}; + +const authenticationSummary = (capabilities: { + browserPairing: boolean; + instanceBearerTokens: boolean; + socketIoBearerAuthentication: boolean; +}): string => capabilities.browserPairing + && capabilities.instanceBearerTokens + && capabilities.socketIoBearerAuthentication + ? 'Browser approval · REST and Socket.IO bearer access' + : 'Secure desktop pairing is unavailable'; + +export class DesktopCredentialService { + readonly #profiles: CredentialServiceDependencies['profiles']; + readonly #fetch: typeof globalThis.fetch; + readonly #openPairingBrowser: (request: DesktopPairingBrowserRequest) => Promise; + readonly #clientName: string; + readonly #pairingTiming: Pick; + readonly #pairingProtocol: PairingProtocolRequestOptions; + readonly #reportRevocationFailure: NonNullable; + readonly #revocationDeadlines: RevocationDeadlines; + readonly #snapshotConnectIdentityClaim: NonNullable; + readonly #internalRequestKey = randomBytes(32).toString('base64url'); + readonly #lifecycleController = new AbortController(); + readonly #profileGenerations = new Map(); + readonly #pairingControllers = new Map(); + #selectionGeneration = 0; + #latestProbeTicket = 0; + #pendingActivation: PendingActivation | null = null; + #active: ActiveCredential | null = null; + #publishingPair = false; + #publishWaiters: Array<() => void> = []; + #retryRequested = false; + #retryIncludeDeferred = false; + #revocationWorker: Promise | null = null; + readonly #backgroundTasks = new Set>(); + readonly #operationTasks = new Set>(); + readonly #operationControllers = new Set(); + #closed = false; + #disposePromise: Promise | null = null; + + constructor(dependencies: CredentialServiceDependencies) { + this.#profiles = dependencies.profiles; + this.#fetch = dependencies.fetch; + this.#openPairingBrowser = dependencies.openPairingBrowser; + this.#clientName = dependencies.clientName; + this.#pairingTiming = dependencies.pairingTiming ?? {}; + this.#pairingProtocol = dependencies.pairingProtocol ?? {}; + this.#reportRevocationFailure = dependencies.reportRevocationFailure ?? (() => undefined); + this.#revocationDeadlines = boundedRevocationDeadlines(dependencies.revocationDeadlines); + this.#snapshotConnectIdentityClaim = dependencies.snapshotConnectIdentityClaim ?? (() => ({ + status: 'unclaimed', + isCurrent: () => true, + beginCommit: () => () => undefined, + })); + } + + async initialize(): Promise { + const operation = this.#beginOperation(); + try { + const worker = this.#requestPendingRevocationRetry(true); + let startupTimer: ReturnType | undefined; + try { + return await Promise.race([ + worker, + new Promise(resolve => { + startupTimer = setTimeout( + () => resolve({ status: 'degraded', retryPending: true }), + this.#revocationDeadlines.aggregateMs, + ); + }), + ]); + } finally { + if (startupTimer) clearTimeout(startupTimer); + } + } finally { + operation.done(); + } + } + + awaitIdle(): Promise { + return this.#awaitIdle(); + } + + async listProfiles() { + const operation = this.#beginOperation(); + try { + return await this.#profiles.list(); + } finally { + operation.done(); + } + } + + async storageSecurity() { + const operation = this.#beginOperation(); + try { + return this.#profiles.security(); + } finally { + operation.done(); + } + } + + async retryPendingRevocations(): Promise { + const operation = this.#beginOperation(); + try { + return await this.#requestPendingRevocationRetry(true); + } finally { + operation.done(); + } + } + + dispose(): Promise { + if (this.#disposePromise) return this.#disposePromise; + this.#closed = true; + this.#active = null; + this.#pendingActivation = null; + this.#lifecycleController.abort(new Error('Desktop credential service disposed')); + for (const controller of this.#operationControllers) controller.abort(new Error('Desktop credential service disposed')); + for (const controller of this.#pairingControllers.values()) controller.abort(); + this.#pairingControllers.clear(); + this.#disposePromise = (async () => { + await this.#awaitIdle(); + await this.#profiles.awaitIdle(); + })(); + return this.#disposePromise; + } + + async saveProfile( + input: DesktopProfileInput, + beforeOriginChangeCommit?: (previousOrigin: string, nextOrigin: string) => Promise, + ) { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + const before = input.id + ? (await this.#profiles.list()).profiles.find(profile => profile.id === input.id) + : undefined; + const nextOrigin = normalizeApiBaseUrl(input.apiBaseUrl ?? ''); + if (!nextOrigin) throw new Error('Invalid desktop API URL'); + let invalidatedBeforeSave = false; + if (before && before.apiBaseUrl !== nextOrigin) { + this.#invalidateProfileOperations(before.id); + invalidatedBeforeSave = true; + } + const transaction = await this.#profiles.saveAndDetachCredential(input, beforeOriginChangeCommit); + if (transaction.originChanged && !invalidatedBeforeSave) { + this.#invalidateProfileOperations(transaction.profile.id); + } + if (transaction.detachedCredential) this.#clearActiveIfCredential(transaction.detachedCredential); + if (transaction.originChanged && this.#active?.profileId === transaction.profile.id) this.#active = null; + this.#schedulePendingRevocationRetry(); + return transaction.profile; + } finally { + operation.done(); + } + } + + async removeProfile( + profileId: string, + beforeCommit?: (origin: string) => Promise, + ): Promise { + const operation = this.#beginOperation(); + try { + if (this.#publishingPair) await this.#waitForPairPublish(); + this.#invalidateProfileOperations(profileId); + this.#schedulePendingRevocationRetry(); + const detached = await this.#profiles.detachProfile(profileId, beforeCommit); + if (!detached) return null; + if (detached.credential) this.#clearActiveIfCredential(detached.credential); + this.#schedulePendingRevocationRetry(); + return detached.profile.apiBaseUrl; + } finally { + operation.done(); + } + } + + async setActiveProfile(profileId: string | null): Promise { + const operation = this.#beginOperation(); + try { + if (this.#publishingPair) await this.#waitForPairPublish(); + this.#selectionGeneration += 1; + this.#latestProbeTicket += 1; + this.#pendingActivation = null; + for (const controller of this.#pairingControllers.values()) controller.abort(); + this.#pairingControllers.clear(); + this.#active = null; + this.#schedulePendingRevocationRetry(); + await this.#profiles.setActive(profileId); + } finally { + operation.done(); + } + } + + async cancelPairing(profileId: string): Promise { + const operation = this.#beginOperation(); + try { + if (this.#publishingPair) await this.#waitForPairPublish(); + this.#cancelPairingNow(profileId); + } finally { + operation.done(); + } + } + + #cancelPairingNow(profileId: string): void { + const generation = this.#bumpGeneration(profileId); + // Cancelling an in-progress edit must not disable the still-committed + // credential for an active profile. + if (this.#active?.profileId === profileId) this.#active.profileGeneration = generation; + this.#pairingControllers.get(profileId)?.abort(); + this.#pairingControllers.delete(profileId); + } + + async pair(input: DesktopProfileInput): Promise<{ paired: true }> { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + if (!input.id) throw new Error('Desktop profile id is required'); + if (!this.#profiles.security().available) { + throw new Error('OS-backed secure storage is required for desktop pairing.'); + } + const origin = normalizeApiBaseUrl(input.apiBaseUrl ?? ''); + if (!origin) throw new Error('Invalid desktop API URL'); + const label = input.label?.trim(); + if (!label || label.length > 80) throw new Error('Profile label must contain 1 to 80 characters'); + const proposed = { ...input, id: input.id, label, apiBaseUrl: origin }; + const connectClaim = this.#snapshotConnectIdentityClaim(proposed.id, proposed.apiBaseUrl); + if (connectClaim.status === 'origin-mismatch' || connectClaim.status === 'pending') { + throw new Error('The ProPR Connect origin changed. Use the currently discovered instance.'); + } + const baseline = await this.#profiles.readProfileCredential(proposed.id); + this.#cancelPairingNow(proposed.id); + if (this.#pendingActivation?.profileId === proposed.id) this.#pendingActivation = null; + const controller = new AbortController(); + this.#pairingControllers.set(proposed.id, controller); + const profileGeneration = this.#generation(proposed.id); + const selectionGeneration = this.#selectionGeneration; + const credentialGeneration = randomBytes(16).toString('base64url'); + let transient: StoredCredential | null = null; + let transientRevocation: PendingCredentialRevocation | null = null; + let provisional: Awaited> | null = null; + let publicationStarted = false; + const client = this.#client(proposed.apiBaseUrl); + + try { + const discovery = await client.discoverDesktop(8_000, controller.signal); + if (!discovery.compatibility.compatible + || !discovery.desktopAuthentication.browserPairing + || !discovery.desktopAuthentication.instanceBearerTokens + || !discovery.desktopAuthentication.socketIoBearerAuthentication + || (connectClaim.status === 'claimed' + && connectClaim.publicInstanceIdentity !== discovery.publicInstanceIdentity)) { + throw new Error('The ProPR instance identity or desktop protocol changed. Approve the new instance again.'); + } + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + const completed = await client.pairDesktop(this.#clientName, { + ...this.#pairingTiming, + binding: { + instanceId: proposed.id, + origin: proposed.apiBaseUrl, + scope: 'desktop-instance', + credentialGeneration, + }, + signal: controller.signal, + onApprovalRequired: async (approvalUrl, _expiresAt, pairingId) => { + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + await this.#openPairingBrowser({ + apiBaseUrl: proposed.apiBaseUrl, + pairingId, + approvalUrl, + }); + }, + }); + provisional = completed; + transient = { + version: 2, + profileId: proposed.id, + origin: proposed.apiBaseUrl, + publicInstanceIdentity: discovery.publicInstanceIdentity, + token: completed.token, + }; + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + const journaled = await this.#profiles.journalPendingRevocation(transient, credentialGeneration); + if ('stored' in journaled) { + throw new Error('OS-backed secure storage is required for desktop pairing.'); + } + transientRevocation = journaled; + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + let activationError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + await client.activateDesktopPairing(completed, controller.signal); + activationError = undefined; + break; + } catch (error) { + activationError = error; + if (controller.signal.aborted) break; + } + } + if (activationError) throw activationError; + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); + const committed = await this.#profiles.commitPairedProfile( + proposed, + transient, + baseline, + () => !controller.signal.aborted + && this.#generation(proposed.id) === profileGeneration + && this.#selectionGeneration === selectionGeneration + && connectClaim.isCurrent(), + () => this.#beginPairPublish( + proposed.id, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ), + () => { + publicationStarted = true; + if (this.#active?.profileId === proposed.id) this.#active = null; + }, + transientRevocation.id, + ); + if (committed && 'stored' in committed) { + throw new Error('OS-backed secure storage is required for desktop pairing.'); + } + if (!committed) throw new ProprClientError('Desktop pairing was cancelled.', { kind: 'aborted' }); + transient = null; + transientRevocation = null; + this.#schedulePendingRevocationRetry(); + return { paired: true }; + } catch (error) { + if (transient && !transientRevocation && !publicationStarted) { + try { + const journaled = await this.#profiles.journalPendingRevocation(transient, credentialGeneration); + if (!('stored' in journaled)) transientRevocation = journaled; + } catch { + // Preserve the original pairing/storage error. A retry is attempted + // below whenever durable material was established. + } + } + if (transientRevocation && !publicationStarted) { + let cancelled = false; + if (provisional) { + try { + await client.cancelDesktopPairing(provisional, operation.signal); + cancelled = await this.#profiles.completePendingRevocation( + transientRevocation.id, + transientRevocation.credential, + transientRevocation.credentialGeneration, + ); + } catch { + // The encrypted rollback remains authoritative until either exact + // cancellation or the endpoint-bound revocation worker confirms it. + } + } + if (!cancelled) { + const released = await this.#profiles.releasePendingRevocation( + transientRevocation.id, + transientRevocation.credentialGeneration, + ); + if (released) await this.#requestPendingRevocationRetry(); + } + } + if (controller.signal.aborted || operation.signal.aborted + || (error instanceof ProprClientError && error.kind === 'aborted')) { + throw new Error('Desktop pairing was cancelled.'); + } + throw error; + } finally { + if (this.#pairingControllers.get(proposed.id) === controller) this.#pairingControllers.delete(proposed.id); + } + } finally { + operation.done(); + } + } + + async probe(input: DesktopProfileInput): Promise { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + if (!input.id) throw new Error('Desktop profile id is required'); + const origin = normalizeApiBaseUrl(input.apiBaseUrl ?? ''); + if (!origin || origin !== input.apiBaseUrl) throw new Error('Invalid desktop API URL'); + const connectClaim = this.#snapshotConnectIdentityClaim(input.id, origin); + if (connectClaim.status === 'origin-mismatch' || connectClaim.status === 'pending') { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + }; + } + const probeTicket = ++this.#latestProbeTicket; + this.#pendingActivation = null; + const operationGeneration = this.#generation(input.id); + const operationSelection = this.#selectionGeneration; + const discoveryClient = this.#client(origin); + let discovery; + try { + discovery = await discoveryClient.discoverDesktop(8_000, operation.signal); + } catch (error) { + // Only the client's typed signal for the exact credential-free public + // discovery request is actionable here. Generic HTTP 401s, malformed + // identity, redirects, and authenticated operation failures stay strict. + if (error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED) { + return { + status: 'incompatible', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', + }; + } + if (error instanceof ProprClientError && error.kind === 'invalid_response') { + try { + const current = await this.#profiles.readProfileCredential(input.id); + if (current.profile?.apiBaseUrl === origin && current.credential?.origin === origin) { + const removed = await this.#detachIdentityFailedCredential( + current.credential, + operationGeneration, + operationSelection, + probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + } + } catch { + return { status: 'offline', message: 'ProPR could not safely invalidate this instance credential.' }; + } + return { + status: 'authentication-required', + message: 'This endpoint returned invalid identity metadata. Approve it again to continue.', + }; + } + return { + status: 'offline', + message: error instanceof Error + ? `ProPR could not discover this instance. ${error.message}` + : 'ProPR could not discover this instance.', + }; + } + const authentication = authenticationSummary(discovery.desktopAuthentication); + if (!connectClaim.isCurrent()) { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + version: discovery.version, + authentication, + }; + } + const initial = await this.#profiles.readProfileCredential(input.id); + if (this.#generation(input.id) !== operationGeneration + || this.#selectionGeneration !== operationSelection + || this.#latestProbeTicket !== probeTicket) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + const identityMismatched = initial.profile?.apiBaseUrl === origin + && initial.credential?.origin === origin + && (!isPublicInstanceIdentity(initial.credential.publicInstanceIdentity) + || initial.credential.publicInstanceIdentity !== discovery.publicInstanceIdentity); + if (identityMismatched) { + const removed = await this.#detachIdentityFailedCredential( + initial.credential!, + operationGeneration, + operationSelection, + probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + } + if (!discovery.compatibility.compatible) { + return { status: 'incompatible', message: discovery.compatibility.message, version: discovery.version }; + } + if (!discovery.desktopAuthentication.browserPairing + || !discovery.desktopAuthentication.instanceBearerTokens + || !discovery.desktopAuthentication.socketIoBearerAuthentication) { + return { + status: 'authentication-required', + message: 'This instance does not support the complete secure desktop authentication protocol.', + version: discovery.version, + authentication, + }; + } + if (!this.#profiles.security().available) { + return { + status: 'authentication-required', + message: 'OS-backed secure storage is unavailable. Enable your system keychain before pairing.', + version: discovery.version, + authentication, + }; + } + + if (connectClaim.status === 'claimed' + && connectClaim.publicInstanceIdentity !== discovery.publicInstanceIdentity) { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + version: discovery.version, + authentication, + }; + } + if (identityMismatched) { + return { + status: 'authentication-required', + message: 'This endpoint now identifies as a different ProPR instance. Approve it again to continue.', + version: discovery.version, + authentication, + }; + } + if (initial.profile?.apiBaseUrl !== origin) { + return { + status: 'authentication-required', + message: discovery.desktopAuthentication.browserPairing + ? 'Approve this desktop in your browser to continue.' + : 'This instance does not support secure desktop pairing.', + version: discovery.version, + authentication, + }; + } + const credential = initial.credential; + if (!credential) { + return { + status: 'authentication-required', + message: discovery.desktopAuthentication.browserPairing + ? 'Approve this desktop in your browser to continue.' + : 'This instance does not support secure desktop pairing.', + version: discovery.version, + authentication, + }; + } + if (credential.origin !== origin) { + const removed = await this.#profiles.removeCredentialIfCurrent( + credential, + origin, + () => this.#generation(input.id!) === operationGeneration + && this.#selectionGeneration === operationSelection + && this.#latestProbeTicket === probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + this.#clearActiveIfCredential(credential); + this.#schedulePendingRevocationRetry(); + return { + status: 'authentication-required', + message: discovery.desktopAuthentication.browserPairing + ? 'Approve this desktop in your browser to continue.' + : 'This instance does not support secure desktop pairing.', + version: discovery.version, + authentication, + }; + } + let response: Response; + try { + if (!connectClaim.isCurrent()) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + response = await this.#authenticatedFetch( + credential, '/api/auth/user', { cache: 'no-store', signal: operation.signal }, 8_000, + ); + } catch { + return { status: 'offline', message: 'The instance was discovered but authentication could not be checked.' }; + } + if (response.ok) { + const current = await this.#profiles.readProfileCredential(input.id); + if (this.#generation(input.id) !== operationGeneration + || this.#selectionGeneration !== operationSelection + || this.#latestProbeTicket !== probeTicket + || !connectClaim.isCurrent() + || current.profile?.apiBaseUrl !== origin + || current.credential?.origin !== origin) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + if (!current.credential + || current.credential.version !== credential.version + || current.credential.profileId !== credential.profileId + || current.credential.origin !== credential.origin + || current.credential.publicInstanceIdentity !== credential.publicInstanceIdentity + || current.credential.token !== credential.token) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + const activationTicket = randomBytes(32).toString('base64url'); + this.#pendingActivation = { + ticket: activationTicket, + probeTicket, + profileId: input.id, + origin, + profileGeneration: operationGeneration, + selectionGeneration: operationSelection, + activeProfileId: current.activeProfileId, + credential: { ...credential }, + identityEpoch: current.identityEpoch!, + connectClaim, + }; + return { status: 'ready', version: discovery.version, authentication, activationTicket }; + } + + const code = await parseCode(response); + if (code && DEFINITIVE_INVALID_CODES.has(code)) { + const removed = await this.#profiles.removeCredentialIfCurrent( + credential, + origin, + () => this.#generation(input.id!) === operationGeneration + && this.#selectionGeneration === operationSelection + && this.#latestProbeTicket === probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + this.#clearActiveIfCredential(credential); + this.#schedulePendingRevocationRetry(); + return { + status: 'authentication-required', + message: 'Access to this instance was revoked or expired. Pair again to continue.', + version: discovery.version, + authentication, + }; + } + if (response.status === 401 || response.status === 403) { + return { + status: 'offline', + message: 'The credential is still paired, but current authorization could not be confirmed. Try again.', + }; + } + return { status: 'offline', message: `The instance returned HTTP ${response.status} while checking authentication.` }; + } finally { + operation.done(); + } + } + + async activate(activationTicket: unknown): Promise { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + if (typeof activationTicket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(activationTicket)) { + throw new Error('Invalid desktop activation ticket'); + } + const pending = this.#pendingActivation; + // Consume before awaiting so concurrent calls and replays can never share a + // credential-bearing activation decision. + this.#pendingActivation = null; + if (!pending || pending.ticket !== activationTicket || !this.#pendingIsCurrent(pending)) { + throw new Error('Desktop activation expired. Check the connection again.'); + } + + const activated = await this.#profiles.activateProfile( + pending.credential, + pending.identityEpoch, + pending.origin, + pending.activeProfileId, + () => this.#pendingIsCurrent(pending), + ); + if (activated !== pending.identityEpoch || !this.#pendingIsCurrent(pending)) { + this.#active = null; + throw new Error('Desktop activation expired. Check the connection again.'); + } + + const transportScope = randomBytes(16).toString('base64url'); + this.#selectionGeneration += 1; + for (const controller of this.#pairingControllers.values()) controller.abort(); + this.#pairingControllers.clear(); + this.#active = { + ...pending.credential, + identityEpoch: pending.identityEpoch, + profileGeneration: pending.profileGeneration, + selectionGeneration: this.#selectionGeneration, + transportScope, + connectClaim: pending.connectClaim, + }; + return { + status: 'ready', + profileId: pending.profileId, + transportScope, + identityEpoch: pending.identityEpoch, + }; + } finally { + operation.done(); + } + } + + async invalidate(value: DesktopAccessInvalidation): Promise<{ invalidated: boolean }> { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + if (!DEFINITIVE_INVALID_CODES.has(value.code)) return { invalidated: false }; + const active = this.#active; + if (!active || active.profileId !== value.profileId + || active.transportScope !== value.transportScope + || this.#generation(active.profileId) !== active.profileGeneration + || this.#selectionGeneration !== active.selectionGeneration) return { invalidated: false }; + this.#active = null; + const invalidationGeneration = this.#bumpGeneration(active.profileId); + const removed = await this.#profiles.removeCredentialIfCurrent( + active, + active.origin, + () => this.#generation(active.profileId) === invalidationGeneration, + ); + if (removed) this.#schedulePendingRevocationRetry(); + return { invalidated: removed }; + } finally { + operation.done(); + } + } + + async discardActivation(value: DesktopConnectionScope): Promise<{ discarded: boolean }> { + const operation = this.#beginOperation(); + try { + await this.#waitForPairPublish(); + this.#schedulePendingRevocationRetry(); + const active = this.#active; + if (!active || typeof value?.profileId !== 'string' || typeof value?.transportScope !== 'string' + || active.profileId !== value.profileId || active.transportScope !== value.transportScope + || this.#generation(active.profileId) !== active.profileGeneration + || this.#selectionGeneration !== active.selectionGeneration) return { discarded: false }; + this.#active = null; + this.#selectionGeneration += 1; + this.#latestProbeTicket += 1; + this.#pendingActivation = null; + await this.#profiles.setActive(null); + return { discarded: true }; + } finally { + operation.done(); + } + } + + /** Whether main still owns the complete binding required by renderer transport and LNA. */ + hasActiveRendererBinding(): boolean { + const active = this.#active; + return active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + } + + prepareRequest( + url: string, + originalHeaders: RequestHeaders, + details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {}, + verifiedSocketCredential?: ActiveCredential, + ): DesktopRequestDecision { + if (this.#closed) return { cancel: true }; + const headers = { ...originalHeaders }; + if (/^(?:https?|wss?):/i.test(url)) { + const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'); + if (!canonicalProprHttpUrlOrigin(httpUrl)) return { cancel: true }; + } + const internalHeader = headerName(headers, 'x-propr-desktop-main-request'); + const trustedMainRequest = internalHeader !== undefined + && headers[internalHeader] === this.#internalRequestKey; + if (internalHeader) delete headers[internalHeader]; + + const scopeValues = headerValues(headers, DESKTOP_TRANSPORT_SCOPE_HEADER); + removeHeader(headers, DESKTOP_TRANSPORT_SCOPE_HEADER); + + // The packaged renderer has no cookie identity on any remote HTTP(S) or + // WS(S) origin. It also cannot supply its own bearer. Main-process bearer + // requests are distinguished by the per-process secret marker above. + removeHeader(headers, 'cookie'); + if (!trustedMainRequest) removeHeader(headers, 'authorization'); + + const target = requestOrigin(url); + if (target && target.url.protocol === 'http:' && !normalizeApiBaseUrl(target.origin)) { + return { cancel: true }; + } + if (trustedMainRequest) return { requestHeaders: headers }; + + const markedRestRequest = scopeValues.length > 0; + if (markedRestRequest && (scopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(scopeValues[0]))) { + return { cancel: true }; + } + if (!trustedMainRequest && target + && (target.pathname.startsWith('/api/desktop/pairings') + || target.pathname.startsWith('/api/desktop/tokens'))) return { cancel: true }; + + const active = this.#active; + const activeIsCurrent = active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + const isApiRequest = target?.pathname.startsWith('/api/') === true; + const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? []; + const isSocketCandidate = target?.pathname === '/socket.io/' || socketScopeValues.length > 0; + const isSocketUpgrade = target?.pathname === '/socket.io/' + && target.url.searchParams.get('transport') === 'websocket' + && (details.resourceType === 'webSocket' + || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); + + // Session security supplies this ownership bit at the actual WebContents + // boundary. A foreign renderer may load only unmarked credentialless + // resources; it can never exercise a REST/Socket scope or receive a bearer. + if (details.rendererOwned === false) { + if (markedRestRequest || isSocketCandidate) return { cancel: true }; + return { requestHeaders: headers }; + } + + // Chromium can cache Local Network Access after activation is discarded. + // The live main renderer must therefore remain pinned to the exact current + // origin even for sanitized traffic that does not carry a transport scope. + if (details.rendererOwned === true && target + && (!activeIsCurrent || target.origin !== active.origin)) return { cancel: true }; + + if (isSocketUpgrade && target) { + if (socketScopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0]) + || !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin + || socketScopeValues[0] !== active.transportScope) return { cancel: true }; + headers.Authorization = `Bearer ${active.token}`; + return { requestHeaders: headers }; + } + + if (!markedRestRequest) return { requestHeaders: headers }; + if (!target || !isApiRequest || !activeIsCurrent || target.origin !== active.origin + || scopeValues[0] !== active.transportScope) return { cancel: true }; + if (details.method?.toUpperCase() === 'OPTIONS') return { requestHeaders: headers }; + headers.Authorization = `Bearer ${active.token}`; + return { requestHeaders: headers }; + } + + /** Socket reconnects cross a fresh asynchronous identity gate before main attaches a bearer. */ + async prepareRequestAsync( + url: string, + originalHeaders: RequestHeaders, + details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {}, + ): Promise { + const target = requestOrigin(url); + const isSocketUpgrade = target?.pathname === '/socket.io/' + && target.url.searchParams.get('transport') === 'websocket' + && (details.resourceType === 'webSocket' + || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); + if (!isSocketUpgrade || details.rendererOwned === false) { + return this.prepareRequest(url, originalHeaders, details); + } + const active = this.#active; + if (!active || target.origin !== active.origin) return this.prepareRequest(url, originalHeaders, details); + try { + const discovery = await this.#client(active.origin).discoverDesktop(8_000, this.#lifecycleController.signal); + const stillCurrent = this.#active === active + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + if (!stillCurrent) return { cancel: true }; + const supportsRequest = discovery.compatibility.compatible + && discovery.desktopAuthentication.instanceBearerTokens + && discovery.desktopAuthentication.socketIoBearerAuthentication; + if (discovery.publicInstanceIdentity !== active.publicInstanceIdentity || !supportsRequest) { + await this.#detachIdentityFailedCredential( + active, + active.profileGeneration, + active.selectionGeneration, + ); + return { cancel: true }; + } + return this.prepareRequest(url, originalHeaders, details, active); + } catch (error) { + if (error instanceof ProprClientError && error.kind === 'invalid_response' && this.#active === active) { + await this.#detachIdentityFailedCredential( + active, + active.profileGeneration, + active.selectionGeneration, + ).catch(() => undefined); + } + return { cancel: true }; + } + } + + authorizeRequest(url: string, originalHeaders: RequestHeaders): RequestHeaders { + return this.prepareRequest(url, originalHeaders).requestHeaders ?? {}; + } + + sanitizeResponseHeaders(url: string, originalHeaders: RequestHeaders): RequestHeaders { + const headers = { ...originalHeaders }; + const target = requestOrigin(url); + if (target) removeHeader(headers, 'set-cookie'); + return headers; + } + + #client(origin: string): ProprClient { + return new ProprClient({ + baseUrl: origin, + authentication: { type: 'none' }, + fetch: this.#mainFetch, + defaultTimeoutMs: 8_000, + pairingProtocol: this.#pairingProtocol, + }); + } + + #authenticatedFetch( + credential: StoredCredential, + path: string, + init: RequestInit, + timeoutMs: number, + ): Promise { + const client = new ProprClient({ + baseUrl: credential.origin, + authentication: { type: 'bearer', getAccessToken: () => credential.token }, + fetch: this.#mainFetch, + }); + return client.fetch(client.url(path), { ...init, redirect: 'manual' }, { timeoutMs }); + } + + readonly #mainFetch: typeof globalThis.fetch = (input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-ProPR-Desktop-Main-Request', this.#internalRequestKey); + return this.#fetch(input, { ...init, headers }); + }; + + #schedulePendingRevocationRetry(includeDeferred = false): void { + this.#requestPendingRevocationRetry(includeDeferred); + } + + #requestPendingRevocationRetry( + includeDeferred = false, + ): Promise { + if (this.#closed) return Promise.resolve({ status: 'degraded', retryPending: true }); + this.#retryRequested = true; + this.#retryIncludeDeferred ||= includeDeferred; + if (this.#revocationWorker) return this.#revocationWorker; + const worker = this.#runPendingRevocationWorker(); + this.#revocationWorker = worker; + this.#backgroundTasks.add(worker); + const settled = (): void => { + this.#backgroundTasks.delete(worker); + if (this.#revocationWorker === worker) this.#revocationWorker = null; + }; + worker.then(settled, settled); + return worker; + } + + async #runPendingRevocationWorker(): Promise { + const aggregate = linkedAbortController([this.#lifecycleController.signal]); + const aggregateTimer = setTimeout( + () => aggregate.controller.abort(new Error('Desktop revocation aggregate deadline exceeded')), + this.#revocationDeadlines.aggregateMs, + ); + const attemptedGenerations = new Set(); + let retryPending = false; + try { + while (this.#retryRequested && !this.#closed && !aggregate.controller.signal.aborted) { + this.#retryRequested = false; + const includeDeferred = this.#retryIncludeDeferred; + this.#retryIncludeDeferred = false; + let pending: PendingCredentialRevocation[]; + try { + pending = await this.#profiles.pendingRevocations(includeDeferred); + } catch { + retryPending = true; + this.#reportFixedRevocationFailure({ code: 'local-cleanup' }); + continue; + } + for (const entry of pending) { + if (attemptedGenerations.has(entry.credentialGeneration)) continue; + if (this.#closed || aggregate.controller.signal.aborted) { + retryPending = true; + this.#reportFixedRevocationFailure({ code: 'network' }); + break; + } + attemptedGenerations.add(entry.credentialGeneration); + const result = await this.#retryPendingRevocation(entry, aggregate.controller.signal); + if (result === 'complete') continue; + retryPending = true; + if (result === 'network') { + this.#reportFixedRevocationFailure({ code: 'network' }); + } else if (typeof result === 'object') { + this.#reportFixedRevocationFailure({ code: 'http', status: result.status }); + } else { + this.#reportFixedRevocationFailure({ code: 'local-cleanup' }); + } + } + } + if (aggregate.controller.signal.aborted || this.#closed) retryPending = true; + return { status: retryPending ? 'degraded' : 'ready', retryPending }; + } finally { + clearTimeout(aggregateTimer); + aggregate.dispose(); + } + } + + async #retryPendingRevocation( + entry: PendingCredentialRevocation, + aggregateSignal: AbortSignal, + ): Promise<'complete' | 'network' | 'local-cleanup' | { status: number; type: 'http' }> { + const record = linkedAbortController([ + this.#lifecycleController.signal, + aggregateSignal, + ]); + const recordTimer = setTimeout( + () => record.controller.abort(new Error('Desktop revocation record deadline exceeded')), + this.#revocationDeadlines.recordMs, + ); + try { + try { + const discovery = await this.#client(entry.credential.origin) + .discoverDesktop(Math.min(8_000, this.#revocationDeadlines.recordMs), record.controller.signal); + if (discovery.publicInstanceIdentity !== entry.credential.publicInstanceIdentity) return 'network'; + } catch { + return 'network'; + } + const headers = new Headers({ + Authorization: `Bearer ${entry.credential.token}`, + [DESKTOP_REVOCATION_BINDING_HEADER]: entry.credentialGeneration, + }); + let response: Response; + const headerTimer = setTimeout( + () => record.controller.abort(new Error('Desktop revocation header deadline exceeded')), + this.#revocationDeadlines.headerMs, + ); + try { + response = await this.#mainFetch( + `${entry.credential.origin}${DESKTOP_TOKEN_REVOCATION_ENDPOINT}`, + { + method: 'DELETE', + headers, + credentials: 'omit', + cache: 'no-store', + redirect: 'manual', + signal: record.controller.signal, + }, + ); + } catch { + return 'network'; + } finally { + clearTimeout(headerTimer); + } + if (!await isEndpointBoundTerminalRevocation( + response, + entry.credential, + entry.credentialGeneration, + record.controller.signal, + () => record.controller.abort(new Error('Desktop revocation response rejected')), + this.#revocationDeadlines.bodyMs, + )) { + return { type: 'http', status: response.status }; + } + record.controller.abort(); + try { + const completed = await this.#profiles.completePendingRevocation( + entry.id, entry.credential, entry.credentialGeneration, + ); + return completed ? 'complete' : 'local-cleanup'; + } catch { + return 'local-cleanup'; + } + } finally { + record.controller.abort(); + clearTimeout(recordTimer); + record.dispose(); + } + } + + async #awaitIdle(): Promise { + while (this.#backgroundTasks.size > 0 || this.#operationTasks.size > 0) { + await Promise.allSettled([...this.#backgroundTasks, ...this.#operationTasks]); + } + } + + #reportFixedRevocationFailure(diagnostic: { + code: 'network' | 'http' | 'local-cleanup'; + status?: number; + }): void { + try { + this.#reportRevocationFailure(diagnostic); + } catch { + // Diagnostics must never alter durable retry state or task settlement. + } + } + + #assertOpen(): void { + if (this.#closed) throw new Error('Desktop credential service is closed'); + } + + #beginOperation(): { signal: AbortSignal; done: () => void } { + this.#assertOpen(); + const linked = linkedAbortController([this.#lifecycleController.signal]); + const controller = linked.controller; + let settle!: () => void; + const task = new Promise(resolve => { settle = resolve; }); + this.#operationTasks.add(task); + this.#operationControllers.add(controller); + let finished = false; + return { + signal: controller.signal, + done: () => { + if (finished) return; + finished = true; + linked.dispose(); + this.#operationControllers.delete(controller); + this.#operationTasks.delete(task); + settle(); + }, + }; + } + + #beginPairPublish( + profileId: string, + profileGeneration: number, + selectionGeneration: number, + signal: AbortSignal, + connectClaim: DesktopConnectIdentityClaimSnapshot, + ): (() => void) | null { + if (this.#publishingPair || signal.aborted + || this.#generation(profileId) !== profileGeneration + || this.#selectionGeneration !== selectionGeneration + || !connectClaim.isCurrent()) return null; + const releaseConnectClaim = connectClaim.beginCommit(); + if (!releaseConnectClaim) return null; + this.#publishingPair = true; + let released = false; + return () => { + if (released) return; + released = true; + this.#publishingPair = false; + releaseConnectClaim(); + const waiters = this.#publishWaiters.splice(0); + waiters.forEach(waiter => waiter()); + }; + } + + #waitForPairPublish(): Promise { + if (!this.#publishingPair) return Promise.resolve(); + return new Promise(resolve => this.#publishWaiters.push(resolve)); + } + + #generation(profileId: string): number { + return this.#profileGenerations.get(profileId) ?? 0; + } + + #pendingIsCurrent(pending: PendingActivation): boolean { + return this.#latestProbeTicket === pending.probeTicket + && this.#generation(pending.profileId) === pending.profileGeneration + && this.#selectionGeneration === pending.selectionGeneration + && pending.connectClaim.isCurrent(); + } + + #clearActiveIfCredential(credential: StoredCredential): void { + if (this.#active?.profileId === credential.profileId + && this.#active.origin === credential.origin + && this.#active.token === credential.token) this.#active = null; + } + + async #detachIdentityFailedCredential( + credential: StoredCredential, + expectedProfileGeneration: number, + expectedSelectionGeneration: number, + expectedProbeTicket?: number, + ): Promise { + if (this.#generation(credential.profileId) !== expectedProfileGeneration + || this.#selectionGeneration !== expectedSelectionGeneration + || (expectedProbeTicket !== undefined && this.#latestProbeTicket !== expectedProbeTicket)) return false; + this.#invalidateProfileOperations(credential.profileId); + const invalidationGeneration = this.#generation(credential.profileId); + const removed = await this.#profiles.removeCredentialIfCurrent( + credential, + credential.origin, + () => this.#generation(credential.profileId) === invalidationGeneration + && this.#selectionGeneration === expectedSelectionGeneration + && (expectedProbeTicket === undefined || this.#latestProbeTicket === expectedProbeTicket), + ); + if (removed) this.#schedulePendingRevocationRetry(); + return removed; + } + + #bumpGeneration(profileId: string): number { + const generation = this.#generation(profileId) + 1; + this.#profileGenerations.set(profileId, generation); + return generation; + } + + #invalidateProfileOperations(profileId: string): void { + this.#bumpGeneration(profileId); + if (this.#pendingActivation?.profileId === profileId) this.#pendingActivation = null; + if (this.#active?.profileId === profileId) this.#active = null; + this.#pairingControllers.get(profileId)?.abort(); + this.#pairingControllers.delete(profileId); + } + + #assertPairingCurrent( + profileId: string, + origin: string, + profileGeneration: number, + selectionGeneration: number, + signal: AbortSignal, + connectClaim: DesktopConnectIdentityClaimSnapshot, + ): void { + if (signal.aborted || this.#generation(profileId) !== profileGeneration + || this.#selectionGeneration !== selectionGeneration + || !connectClaim.isCurrent()) { + throw new ProprClientError('Desktop pairing was cancelled.', { kind: 'aborted' }); + } + if (normalizeApiBaseUrl(origin) !== origin) throw new Error('Invalid desktop API URL'); + } + +} diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts new file mode 100644 index 000000000..099fc4755 --- /dev/null +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; + +describe('desktop deep-link delivery', () => { + const createWindow = (sent: Array<{ channel: string; value: string }>): DeepLinkWindow => ({ + isDestroyed: () => false, + webContents: { + isLoading: () => false, + send: (channel, value) => sent.push({ channel, value }), + }, + }); + + it('queues links received after did-finish-load until the ready window is registered', () => { + const sent: Array<{ channel: string; value: string }> = []; + const window = createWindow(sent); + const delivery = new DeepLinkDelivery('desktop:deep-link', ['propr://open?task=initial']); + + delivery.didFinishLoad(window); + delivery.deliver('propr://open?task=between'); + + assert.deepEqual(sent, []); + + delivery.setWindow(window); + + assert.deepEqual(sent, [ + { channel: 'desktop:deep-link', value: 'propr://open?task=initial' }, + { channel: 'desktop:deep-link', value: 'propr://open?task=between' }, + ]); + }); + + it('delivers a queued initial Connect URL before packaged smoke asserts it and only once', () => { + const main = readFileSync(new URL('./main.ts', import.meta.url), 'utf8'); + const preloadReady = main.indexOf("throw new Error('Desktop preload bridge was not exposed to the renderer')"); + const readyWindowRegistration = main.indexOf('deepLinkDelivery.setWindow(window);'); + const packagedSmokeStart = main.indexOf('const smokeProfileApiUrl ='); + assert.ok(preloadReady < readyWindowRegistration); + assert.ok(readyWindowRegistration < packagedSmokeStart); + assert.equal(main.match(/deepLinkDelivery\.setWindow\(/g)?.length, 1); + + const sent: Array<{ channel: string; value: string }> = []; + const window = createWindow(sent); + const connectUrl = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; + const delivery = new DeepLinkDelivery('desktop:deep-link', [connectUrl]); + + delivery.didFinishLoad(window); + assert.deepEqual(sent, []); + + delivery.setWindow(window); + const assertPackagedSmokeDeepLink = () => { + assert.deepEqual(sent.filter(({ value }) => value === connectUrl), [ + { channel: 'desktop:deep-link', value: connectUrl }, + ]); + }; + assertPackagedSmokeDeepLink(); + + delivery.didFinishLoad(window); + delivery.setWindow(window); + + assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); + }); +}); diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts new file mode 100644 index 000000000..aaf9ead26 --- /dev/null +++ b/apps/desktop/src/deep-link-delivery.ts @@ -0,0 +1,44 @@ +export interface DeepLinkWindow { + isDestroyed(): boolean; + webContents: { + isLoading(): boolean; + send(channel: string, value: string): void; + }; +} + +/** Coordinates protocol delivery across the window creation/load boundary. */ +export class DeepLinkDelivery { + private window: TWindow | null = null; + + constructor( + private readonly channel: string, + private readonly pending: string[] = [], + ) {} + + deliver(value: string): void { + if (!this.window || this.window.isDestroyed() || this.window.webContents.isLoading()) { + this.pending.push(value); + return; + } + this.window.webContents.send(this.channel, value); + } + + didFinishLoad(window: TWindow): void { + if (this.window === window) this.flush(window); + } + + setWindow(window: TWindow): void { + this.window = window; + this.flush(window); + } + + clearWindow(window: TWindow): void { + if (this.window === window) this.window = null; + } + + private flush(window: TWindow): void { + if (window.isDestroyed() || window.webContents.isLoading()) return; + const linksToDeliver = this.pending.splice(0); + linksToDeliver.forEach(value => window.webContents.send(this.channel, value)); + } +} diff --git a/apps/desktop/src/desktop-session.ts b/apps/desktop/src/desktop-session.ts new file mode 100644 index 000000000..0e0eadfb2 --- /dev/null +++ b/apps/desktop/src/desktop-session.ts @@ -0,0 +1,36 @@ +import type { Session } from 'electron'; +import { normalizeApiBaseUrl } from './security'; + +export const logoutDesktopSession = async ( + desktopSession: Pick, + apiBaseUrl: unknown, +): Promise => { + if (typeof apiBaseUrl !== 'string') throw new Error('Invalid desktop API URL'); + const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); + if (!normalizedApiBaseUrl || normalizedApiBaseUrl !== apiBaseUrl) throw new Error('Invalid desktop API URL'); + const response = await desktopSession.fetch(`${normalizedApiBaseUrl}/api/auth/logout`, { + credentials: 'include', + redirect: 'manual', + }); + if (!response.ok && (response.status < 300 || response.status >= 400)) { + throw new Error(`Desktop logout failed with HTTP ${response.status}`); + } +}; + +/** Remove legacy browser identity/state so named bearer profiles cannot inherit it. */ +export const clearDesktopInstanceCookies = async ( + desktopSession: Pick, + apiBaseUrls: readonly unknown[], +): Promise => { + const origins = new Set(); + for (const value of apiBaseUrls) { + if (typeof value !== 'string') throw new Error('Invalid desktop API URL'); + const normalized = normalizeApiBaseUrl(value); + if (!normalized || normalized !== value) throw new Error('Invalid desktop API URL'); + origins.add(normalized); + } + await Promise.all([...origins].map(origin => desktopSession.clearStorageData({ + origin, + storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'], + }))); +}; diff --git a/apps/desktop/src/discovery-ipc.test.ts b/apps/desktop/src/discovery-ipc.test.ts new file mode 100644 index 000000000..2fb5ff64c --- /dev/null +++ b/apps/desktop/src/discovery-ipc.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; +import type { DesktopCredentialService } from './credential-service'; +import { registerIpcHandlers } from './ipc'; +import type { LocalLifecycleController } from './lifecycle'; +import type { DesktopLogger } from './logger'; +import { createDesktopBridge, type PreloadIpc } from './preload-bridge'; +import type { ProfileStore } from './profile-store'; + +const rendererUrl = 'propr-app://renderer/renderer.html'; + +describe('main-to-preload Connect discovery IPC', () => { + it('returns only typed candidates and redacts underlying discovery failures', async () => { + const handlers = new Map unknown>(); + let fail = false; + const registered = registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials: {} as DesktopCredentialService, + connectDiscovery: { + discover: async () => { + if (fail) throw new Error('token-sentinel at /private/native/root'); + return [{ + id: 'connect-candidate', + label: 'ProPR Connect', + apiBaseUrl: 'https://t-discovered123.propr.dev', + }]; + }, + rediscover: async profileId => ({ + id: String(profileId), + label: 'Saved connection', + apiBaseUrl: 'https://t-recovered456.propr.dev', + }), + }, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: rendererUrl, + openExternal: async () => undefined, + }); + const event = { senderFrame: { url: rendererUrl } } as unknown as IpcMainInvokeEvent; + const ipc: PreloadIpc = { + invoke: (channel, ...args) => Promise.resolve(handlers.get(channel)!(event, ...args)), + on: () => undefined, + removeListener: () => undefined, + }; + const bridge = createDesktopBridge(ipc, true); + + assert.deepEqual(await bridge.discovery.discover(), [{ + id: 'connect-candidate', + label: 'ProPR Connect', + apiBaseUrl: 'https://t-discovered123.propr.dev', + }]); + assert.deepEqual(await bridge.discovery.rediscover('saved-profile'), { + id: 'saved-profile', + label: 'Saved connection', + apiBaseUrl: 'https://t-recovered456.propr.dev', + }); + fail = true; + await assert.rejects( + bridge.discovery.discover(), + (error: unknown) => String(error) === 'Error: Desktop operation failed [IPC_OPERATION_FAILED]', + ); + registered.dispose(); + }); +}); diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts new file mode 100644 index 000000000..4276db789 --- /dev/null +++ b/apps/desktop/src/global.d.ts @@ -0,0 +1,6 @@ +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; +declare const MAIN_WINDOW_VITE_NAME: string; +declare const __PROPR_DESKTOP_UPDATE_MANIFEST_URL__: string; +declare const __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__: string; +declare const __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__: string; +declare const __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__: readonly string[]; diff --git a/apps/desktop/src/ipc-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts new file mode 100644 index 000000000..bf5b2a599 --- /dev/null +++ b/apps/desktop/src/ipc-lifecycle.test.ts @@ -0,0 +1,866 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; +import type { DesktopCredentialService } from './credential-service'; +import { registerIpcHandlers } from './ipc'; +import type { LocalLifecycleController } from './lifecycle'; +import type { DesktopLogger } from './logger'; +import type { ProfileStore } from './profile-store'; +import { rendererContentSecurityPolicy } from './security'; +import { IPC_CHANNELS } from './shared/contract'; +import { createDesktopShutdownCoordinator } from './shutdown'; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((settle, fail) => { + resolve = settle; + reject = fail; + }); + return { promise, resolve, reject }; +}; + +const connectDiscovery = { + discover: async () => [], + rediscover: async () => null, +}; + +describe('desktop IPC shutdown gate', () => { + it('clears old and new origin storage through the real save IPC before a same-ID URL commit', async () => { + const handlers = new Map unknown>(); + const cleared: Array[0]> = []; + let cleanupObservedBeforeSave = false; + let reconciledOrigin: string | null | undefined; + const credentials = { + saveProfile: async ( + input: { id: string; label: string; apiBaseUrl: string }, + beforeCommit: (previousOrigin: string, nextOrigin: string) => Promise, + ) => { + await beforeCommit('https://old.example.test', input.apiBaseUrl); + cleanupObservedBeforeSave = cleared.length === 2; + return input; + }, + listProfiles: async () => ({ + profiles: [{ + id: 'profile-a', label: 'A edited', apiBaseUrl: 'http://localhost:4100', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:01:00.000Z', + }], + activeProfileId: null, + }), + } as unknown as DesktopCredentialService; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: { + clearStorageData: async (options: Parameters[0]) => { cleared.push(options); }, + } as unknown as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + await Promise.resolve(handlers.get(IPC_CHANNELS.profilesSave)!(event, { + id: 'profile-a', label: 'A edited', apiBaseUrl: 'http://localhost:4100', + })); + + assert.equal(cleanupObservedBeforeSave, true); + assert.equal(reconciledOrigin, null); + assert.deepEqual(cleared, [ + { + origin: 'https://old.example.test', + storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'], + }, + { + origin: 'http://localhost:4100', + storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'], + }, + ]); + }); + + it('clears the renderer policy when the authoritative profile read fails after a save commit', async () => { + const handlers = new Map unknown>(); + let saveCommitted = false; + const credentials = { + saveProfile: async (input: { id: string; label: string; apiBaseUrl: string }) => { + saveCommitted = true; + return input; + }, + listProfiles: async () => { throw new Error('post-save profile read failed'); }, + } as unknown as DesktopCredentialService; + const reconciledOrigins: Array = []; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigins.push(origin); }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + await assert.rejects( + Promise.resolve(handlers.get(IPC_CHANNELS.profilesSave)!(event, { + id: 'profile-a', label: 'A edited', apiBaseUrl: 'http://localhost:4100', + })), + /Desktop operation failed \[IPC_OPERATION_FAILED\]/, + ); + + assert.equal(saveCommitted, true); + assert.deepEqual(reconciledOrigins, [null]); + }); + + for (const staleReadOutcome of ['resolve', 'reject'] as const) { + it(`does not publish a stale reconciliation when its profile read ${staleReadOutcome}s`, async () => { + const handlers = new Map unknown>(); + const firstRead = deferred<{ + profiles: Array<{ + id: string; + label: string; + apiBaseUrl: string; + createdAt: string; + updatedAt: string; + }>; + activeProfileId: string | null; + }>(); + const firstReadStarted = deferred(); + const oldProfile = { + id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:4000', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }; + const currentProfile = { + id: 'profile-b', label: 'B', apiBaseUrl: 'http://127.0.0.1:4100', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:01:00.000Z', + }; + let listCalls = 0; + const credentials = { + saveProfile: async (input: typeof oldProfile) => input, + listProfiles: async () => { + listCalls += 1; + if (listCalls === 1) { + firstReadStarted.resolve(undefined); + return firstRead.promise; + } + return { profiles: [currentProfile], activeProfileId: currentProfile.id }; + }, + } as unknown as DesktopCredentialService; + const reconciledOrigins: Array = []; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigins.push(origin); }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const save = (profile: typeof oldProfile) => Promise.resolve( + handlers.get(IPC_CHANNELS.profilesSave)!(event, profile), + ); + + const staleSave = save(oldProfile); + await firstReadStarted.promise; + await save(currentProfile); + assert.deepEqual(reconciledOrigins, [currentProfile.apiBaseUrl]); + + if (staleReadOutcome === 'resolve') { + firstRead.resolve({ profiles: [oldProfile], activeProfileId: oldProfile.id }); + await staleSave; + } else { + firstRead.reject(new Error('stale profile read failed')); + await assert.rejects(staleSave, /Desktop operation failed \[IPC_OPERATION_FAILED\]/); + } + + assert.equal(listCalls, 2); + assert.deepEqual(reconciledOrigins, [currentProfile.apiBaseUrl]); + }); + } + + it('clears the renderer policy after re-pairing an active profile at a changed local origin', async () => { + const handlers = new Map unknown>(); + const activeProfile = { + id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:4000', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }; + const pairedProfile = { + id: 'profile-a', label: 'A edited', apiBaseUrl: 'http://localhost:4100', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:01:00.000Z', + }; + let profiles = [activeProfile]; + let activeProfileId: string | null = activeProfile.id; + let listCalls = 0; + let pairedInput: { id: string; label: string; apiBaseUrl: string } | undefined; + const credentials = { + pair: async (input: { id: string; label: string; apiBaseUrl: string }) => { + pairedInput = input; + profiles = [pairedProfile]; + activeProfileId = null; + return { paired: true as const }; + }, + listProfiles: async () => { + listCalls += 1; + return { profiles, activeProfileId }; + }, + } as unknown as DesktopCredentialService; + let reconciledOrigin: string | null | undefined; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + const result = await Promise.resolve(handlers.get(IPC_CHANNELS.authenticationPair)!(event, { + id: pairedProfile.id, label: pairedProfile.label, apiBaseUrl: pairedProfile.apiBaseUrl, + })); + + assert.deepEqual(result, { paired: true }); + assert.deepEqual(pairedInput, { + id: pairedProfile.id, label: pairedProfile.label, apiBaseUrl: pairedProfile.apiBaseUrl, + }); + assert.equal(listCalls, 1); + assert.equal(reconciledOrigin, null); + }); + + it('reconciles the renderer policy after setting a different active profile', async () => { + const handlers = new Map unknown>(); + const profiles = [ + { + id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:4000', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + { + id: 'profile-b', label: 'B', apiBaseUrl: 'http://127.0.0.1:4100', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + ]; + let activeProfileId: string | null = 'profile-a'; + const credentials = { + listProfiles: async () => ({ profiles, activeProfileId }), + setActiveProfile: async (profileId: string | null) => { activeProfileId = profileId; }, + } as unknown as DesktopCredentialService; + let reconciledOrigin: string | null | undefined; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: { clearStorageData: async () => undefined } as unknown as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + await Promise.resolve(handlers.get(IPC_CHANNELS.profilesSetActive)!(event, 'profile-b')); + + assert.equal(reconciledOrigin, profiles[1].apiBaseUrl); + }); + + it('clears the renderer policy after removing the active profile', async () => { + const handlers = new Map unknown>(); + let removed = false; + const credentials = { + removeProfile: async ( + _profileId: string, + beforeCommit: (origin: string) => Promise, + ) => { + await beforeCommit('http://localhost:4000'); + removed = true; + return 'http://localhost:4000'; + }, + listProfiles: async () => ({ profiles: [], activeProfileId: null }), + } as unknown as DesktopCredentialService; + let reconciledOrigin: string | null | undefined; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: { clearStorageData: async () => undefined } as unknown as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + await Promise.resolve(handlers.get(IPC_CHANNELS.profilesRemove)!(event, 'profile-a')); + + assert.equal(removed, true); + assert.equal(reconciledOrigin, null); + }); + + it('removes cleartext renderer sources after discarding the active loopback connection', async () => { + const handlers = new Map unknown>(); + const profile = { + id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:4000', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }; + let activeProfileId: string | null = profile.id; + let policy = rendererContentSecurityPolicy(false, [profile.apiBaseUrl]); + let listCalls = 0; + const credentials = { + discardActivation: async () => { + activeProfileId = null; + return { discarded: true }; + }, + listProfiles: async () => { + listCalls += 1; + return { profiles: [profile], activeProfileId }; + }, + } as unknown as DesktopCredentialService; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { + policy = rendererContentSecurityPolicy(false, origin ? [origin] : []); + }, + }); + const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent; + + const result = await Promise.resolve(handlers.get(IPC_CHANNELS.connectionDiscard)!(event, { + profileId: profile.id, + transportScope: 'scope-a', + })); + + assert.deepEqual(result, { discarded: true }); + assert.equal(listCalls, 1); + assert.equal(policy.includes(profile.apiBaseUrl), false); + assert.equal(policy.includes('ws://localhost:4000'), false); + }); + + it('clears both origins when activation edits the active profile URL without changing its ID', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const before = { + id: 'profile-a', label: 'A', apiBaseUrl: 'https://old.example.test', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }; + const after = { + ...before, + apiBaseUrl: 'https://new.example.test', + updatedAt: '2026-08-30T00:01:00.000Z', + }; + let listCalls = 0; + const credentials = { + listProfiles: async () => ({ + profiles: [listCalls++ === 0 ? before : after], + activeProfileId: 'profile-a', + }), + activate: async () => ({ + status: 'ready', profileId: 'profile-a', transportScope: 'scope-b', identityEpoch: 'B'.repeat(22), + }), + } as unknown as DesktopCredentialService; + const cleared: Array[0]> = []; + const desktopSession = { + clearStorageData: async (options: Parameters[0]) => { + cleared.push(options); + }, + } as unknown as Session; + let reconciledOrigin: string | null | undefined; + registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + const activated = await Promise.resolve( + handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43)), + ); + + assert.deepEqual(activated, { + status: 'ready', profileId: 'profile-a', transportScope: 'scope-b', identityEpoch: 'B'.repeat(22), + }); + assert.equal(listCalls, 3); + assert.equal(reconciledOrigin, after.apiBaseUrl); + assert.deepEqual(cleared, [ + { + origin: 'https://old.example.test', + storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'], + }, + { + origin: 'https://new.example.test', + storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'], + }, + ]); + }); + + it('publishes the current active profile when another mutation completes during activation cleanup', async () => { + const handlers = new Map unknown>(); + const profiles = [ + { + id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:4000', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + { + id: 'profile-b', label: 'B', apiBaseUrl: 'http://127.0.0.1:4100', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + { + id: 'profile-c', label: 'C', apiBaseUrl: 'http://[::1]:4200', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + ]; + let activeProfileId: string | null = profiles[0].id; + const credentials = { + listProfiles: async () => ({ profiles, activeProfileId }), + activate: async () => { + activeProfileId = profiles[1].id; + return { + status: 'ready', profileId: profiles[1].id, + transportScope: 'scope-b', identityEpoch: 'B'.repeat(22), + }; + }, + setActiveProfile: async (profileId: string | null) => { activeProfileId = profileId; }, + } as unknown as DesktopCredentialService; + const activationCleanupStarted = deferred(); + const finishActivationCleanup = deferred(); + let profileBClearCalls = 0; + const desktopSession = { + clearStorageData: async (options: Parameters[0]) => { + if (options?.origin !== profiles[1].apiBaseUrl || ++profileBClearCalls !== 1) return; + activationCleanupStarted.resolve(undefined); + await finishActivationCleanup.promise; + }, + } as unknown as Session; + const reconciledOrigins: Array = []; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigins.push(origin); }, + }); + const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent; + + const activation = Promise.resolve( + handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43)), + ); + await activationCleanupStarted.promise; + await Promise.resolve(handlers.get(IPC_CHANNELS.profilesSetActive)!(event, profiles[2].id)); + finishActivationCleanup.resolve(undefined); + await activation; + + assert.deepEqual(reconciledOrigins, [profiles[2].apiBaseUrl, profiles[2].apiBaseUrl]); + }); + + it('rejects activation and discards its exact scope when origin storage clearing fails', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const profiles = [ + { + id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + { + id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test', + createdAt: '2026-08-30T00:00:00.000Z', updatedAt: '2026-08-30T00:00:00.000Z', + }, + ]; + let listCalls = 0; + let activeProfileId: string | null = 'profile-a'; + const discarded: Array<{ profileId: string; transportScope: string }> = []; + const credentials = { + listProfiles: async () => { + listCalls += 1; + return { profiles, activeProfileId }; + }, + activate: async () => { + activeProfileId = 'profile-b'; + return { + status: 'ready', profileId: 'profile-b', transportScope: 'scope-b', identityEpoch: 'B'.repeat(22), + }; + }, + discardActivation: async (scope: { profileId: string; transportScope: string }) => { + discarded.push(scope); + activeProfileId = null; + return { discarded: true }; + }, + } as unknown as DesktopCredentialService; + let clearCalls = 0; + const desktopSession = { + clearStorageData: async () => { + clearCalls += 1; + if (clearCalls === 2) throw new Error('storage clear failed'); + }, + } as unknown as Session; + let reconciledOrigin: string | null | undefined; + registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + onRendererActiveProfileChanged: origin => { reconciledOrigin = origin; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + + await assert.rejects( + Promise.resolve(handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43))), + /Desktop operation failed \[IPC_OPERATION_FAILED\]/, + ); + assert.equal(clearCalls, 2); + assert.equal(listCalls, 3); + assert.deepEqual(discarded, [{ profileId: 'profile-b', transportScope: 'scope-b' }]); + assert.equal(reconciledOrigin, null); + }); + + it('discards the exact activation when the post-commit profile read fails', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + let listCalls = 0; + let discardCalls = 0; + const discardActivation = async (scope: { profileId: string; transportScope: string }) => { + discardCalls += 1; + assert.deepEqual(scope, { profileId: 'profile-b', transportScope: 'scope-b' }); + return { discarded: true }; + }; + const credentials = { + listProfiles: async () => { + listCalls += 1; + if (listCalls === 2) throw new Error('post-activation profile read failed'); + return { profiles: [], activeProfileId: null }; + }, + activate: async () => ({ + status: 'ready', profileId: 'profile-b', transportScope: 'scope-b', identityEpoch: 'B'.repeat(22), + }), + discardActivation, + } as unknown as DesktopCredentialService; + const desktopSession = { + clearStorageData: async () => { throw new Error('storage clearing should not start'); }, + } as unknown as Session; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + }); + const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent; + + await assert.rejects( + Promise.resolve(handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43))), + /Desktop operation failed \[IPC_OPERATION_FAILED\]/, + ); + assert.equal(listCalls, 2); + assert.equal(discardCalls, 1); + }); + + it('clears a profile origin before committing removal and retains it when cleanup fails', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + let removalCommitted = false; + const credentials = { + removeProfile: async ( + _profileId: string, + beforeCommit: (origin: string) => Promise, + ) => { + await beforeCommit('https://a.example.test'); + removalCommitted = true; + return 'https://a.example.test'; + }, + } as unknown as DesktopCredentialService; + const desktopSession = { + clearStorageData: async () => { throw new Error('origin storage clear failed'); }, + } as unknown as Session; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + }); + const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent; + + await assert.rejects( + Promise.resolve(handlers.get(IPC_CHANNELS.profilesRemove)!(event, 'profile-a')), + /Desktop operation failed \[IPC_OPERATION_FAILED\]/, + ); + assert.equal(removalCommitted, false); + }); + + it('replaces every handler with a fixed closing failure and drains admitted work before disposal', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const listResult = deferred<{ profiles: []; activeProfileId: null }>(); + let listCalls = 0; + const credentials = { + listProfiles: async () => { + listCalls += 1; + return listResult.promise; + }, + } as unknown as DesktopCredentialService; + const registered = registerIpcHandlers({ + app: { + getName: () => 'ProPR', + getVersion: () => '0.8.15', + isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const invoke = (channel: string) => Promise.resolve(handlers.get(channel)!(event)); + + const admitted = invoke(IPC_CHANNELS.profilesList); + await Promise.resolve(); + registered.close(); + await assert.rejects(invoke(IPC_CHANNELS.profilesList), /DESKTOP_CLOSING/); + assert.equal(listCalls, 1); + + let idle = false; + const draining = registered.awaitIdle().then(() => { idle = true; }); + await Promise.resolve(); + assert.equal(idle, false); + listResult.resolve({ profiles: [], activeProfileId: null }); + await admitted; + await draining; + + registered.dispose(); + assert.equal(handlers.size, 0); + }); + + for (const category of ['profile', 'pairing', 'session'] as const) { + it(`runs an admitted ${category} handler through the production before-quit drain`, async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const barrier = deferred(); + const started = deferred(); + let underlyingCalls = 0; + const begin = (): Promise => { + underlyingCalls += 1; + started.resolve(undefined); + return barrier.promise; + }; + const credentials = { + listProfiles: category === 'profile' ? begin : async () => ({ profiles: [], activeProfileId: null }), + pair: category === 'pairing' ? begin : async () => ({ paired: true }), + dispose: async () => undefined, + } as unknown as DesktopCredentialService; + const desktopSession = { + fetch: category === 'session' + ? async () => await begin() as Response + : async () => new Response(null, { status: 204 }), + } as unknown as Session; + const registered = registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const invoke = (channel: string, ...args: unknown[]) => + Promise.resolve(handlers.get(channel)!(event, ...args)); + const channel = category === 'profile' + ? IPC_CHANNELS.profilesList + : category === 'pairing' + ? IPC_CHANNELS.authenticationPair + : IPC_CHANNELS.authLogout; + const args = category === 'pairing' + ? [{ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }] + : category === 'session' ? ['https://a.example.test'] : []; + const admitted = invoke(channel, ...args); + await started.promise; + + const order: string[] = []; + const shutdown = createDesktopShutdownCoordinator({ + credentials: { dispose: async () => { order.push('credentials-dispose'); } }, + lifecycle: { shutdown: async () => { order.push('lifecycle-shutdown'); } }, + ipc: { + close: () => { order.push('ipc-close'); registered.close(); }, + awaitIdle: () => { order.push('ipc-drain'); return registered.awaitIdle(); }, + dispose: () => { order.push('ipc-dispose'); registered.dispose(); }, + }, + profiles: { close: async () => { order.push('profiles-close'); } }, + sessionSecurity: { + close: () => { order.push('session-close'); }, + dispose: () => { order.push('session-dispose'); }, + }, + disposeRendererProtocol: () => { order.push('protocol-dispose'); }, + getWindow: () => ({ + isDestroyed: () => false, + destroy: () => { order.push('window-destroy'); }, + }), + quit: () => { order.push('app-quit'); }, + onStarted: () => { order.push('shutdown-started'); }, + log: () => undefined, + }); + shutdown.beforeQuit({ preventDefault: () => undefined }); + await assert.rejects(invoke(channel, ...args), /DESKTOP_CLOSING/); + assert.equal(underlyingCalls, 1); + + if (category === 'profile') barrier.resolve({ profiles: [], activeProfileId: null }); + else if (category === 'pairing') barrier.resolve({ paired: true }); + else barrier.resolve(new Response(null, { status: 204 })); + await admitted; + await shutdown.awaitFinished(); + + assert.equal(handlers.size, 0); + assert.equal(order.indexOf('profiles-close') > order.indexOf('ipc-drain'), true); + assert.equal(order.indexOf('session-dispose') > order.indexOf('profiles-close'), true); + assert.deepEqual(order.slice(-3), ['ipc-dispose', 'window-destroy', 'app-quit']); + }); + } +}); diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts new file mode 100644 index 000000000..fea2b754e --- /dev/null +++ b/apps/desktop/src/ipc.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { Session } from 'electron'; +import { clearDesktopInstanceCookies, logoutDesktopSession } from './desktop-session'; + +describe('desktop session IPC operations', () => { + it('logs out through the active Electron session with credentials and without following redirects', async () => { + const requests: Array<{ url: string; init: RequestInit | undefined }> = []; + const desktopSession: Pick = { + fetch: async (input, init) => { + requests.push({ url: input.toString(), init }); + return new Response(null, { status: 302 }); + }, + }; + + await logoutDesktopSession(desktopSession, 'https://propr.example.com'); + + assert.deepEqual(requests, [{ + url: 'https://propr.example.com/api/auth/logout', + init: { credentials: 'include', redirect: 'manual' }, + }]); + }); + + it('rejects untrusted logout endpoints before making a session request', async () => { + let requested = false; + const desktopSession: Pick = { + fetch: async () => { + requested = true; + return new Response(null, { status: 200 }); + }, + }; + + await assert.rejects(logoutDesktopSession(desktopSession, 'https://propr.example.com/base'), /Invalid desktop API URL/); + await assert.rejects(logoutDesktopSession(desktopSession, 'https://user:secret@example.com'), /Invalid desktop API URL/); + assert.equal(requested, false); + }); + + it('clears browser identity and origin storage for normalized profile origins when profiles switch', async () => { + const calls: Array[0]> = []; + const desktopSession: Pick = { + clearStorageData: async options => { calls.push(options ?? {}); }, + }; + + await clearDesktopInstanceCookies(desktopSession, [ + 'https://first.example.test', + 'https://second.example.test', + 'https://first.example.test', + ]); + + assert.deepEqual(calls, [ + { origin: 'https://first.example.test', storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'] }, + { origin: 'https://second.example.test', storages: ['cookies', 'localstorage', 'indexdb', 'cachestorage', 'serviceworkers'] }, + ]); + await assert.rejects( + clearDesktopInstanceCookies(desktopSession, ['http://remote.example.test']), + /Invalid desktop API URL/, + ); + }); +}); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts new file mode 100644 index 000000000..924421b26 --- /dev/null +++ b/apps/desktop/src/ipc.ts @@ -0,0 +1,251 @@ +import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; +import { clearDesktopInstanceCookies, logoutDesktopSession } from './desktop-session'; +import type { DesktopCredentialService } from './credential-service'; +import type { DesktopConnectDiscoveryService } from './connect-discovery'; +import type { DesktopLogger } from './logger'; +import type { LocalLifecycleController } from './lifecycle'; +import type { ProfileStore } from './profile-store'; +import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; +import { IPC_CHANNELS } from './shared/contract'; +import type { DesktopAcceptanceJourneyStage } from './shared/contract'; + +export type DesktopAcceptanceOperation = 'PROFILE_SAVE' | 'PAIR' | 'PROBE' | 'ACTIVATE'; +export type DesktopAcceptanceOperationStatus = + | 'COMPLETED' + | 'READY' + | 'AUTHENTICATION_REQUIRED' + | 'INCOMPATIBLE' + | 'OFFLINE' + | 'REJECTED'; + +interface RegisterIpcOptions { + app: App; + ipcMain: IpcMain; + profiles: ProfileStore; + credentials: DesktopCredentialService; + connectDiscovery: Pick; + lifecycle: LocalLifecycleController; + logger: DesktopLogger; + desktopSession: Session; + devServerUrl: string | undefined; + packagedRendererUrl: string; + openExternal(url: string): Promise; + onRendererActiveProfileChanged?(origin: string | null): void; + /** @internal Deterministic admitted-work accounting for lifecycle proof. */ + observeInvocation?(phase: 'entry' | 'exit', channel: string): void; + /** @internal Fixed, secret-free packaged Connect acceptance evidence. */ + reportAcceptanceJourneyStage?(stage: DesktopAcceptanceJourneyStage): void; + /** @internal Fixed, secret-free packaged Connect operation evidence. */ + reportAcceptanceOperation?( + operation: DesktopAcceptanceOperation, + status: DesktopAcceptanceOperationStatus, + ): void; +} + +type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown; + +export interface RegisteredIpcHandlers { + close(): void; + awaitIdle(): Promise; + dispose(): void; +} + +const closingError = (): Error => new Error('DESKTOP_CLOSING'); +const acceptanceStages = new Set([ + 'AUTHENTICATION_REQUIRED', + 'CREDENTIAL_COMMITTED', + 'AUTHENTICATED_REPROBE_READY', + 'ACTIVATION_COMMITTED', + 'ACTIVATION_PUBLISHED', + 'REACT_CONNECTED', +]); +const acceptanceOperations = new Map([ + [IPC_CHANNELS.profilesSave, 'PROFILE_SAVE'], + [IPC_CHANNELS.authenticationPair, 'PAIR'], + [IPC_CHANNELS.connectionProbe, 'PROBE'], + [IPC_CHANNELS.connectionActivate, 'ACTIVATE'], +]); + +const acceptanceStatus = (result: unknown): DesktopAcceptanceOperationStatus => { + if (!result || typeof result !== 'object' || Array.isArray(result) || !('status' in result)) { + return 'COMPLETED'; + } + const status = (result as { status?: unknown }).status; + if (status === 'ready') return 'READY'; + if (status === 'authentication-required') return 'AUTHENTICATION_REQUIRED'; + if (status === 'incompatible') return 'INCOMPATIBLE'; + if (status === 'offline') return 'OFFLINE'; + return 'COMPLETED'; +}; + +export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcHandlers => { + const channels = new Set(); + const active = new Set>(); + let closing = false; + let rendererActiveProfileReconciliationGeneration = 0; + const trusted = (event: IpcMainInvokeEvent): boolean => { + const senderUrl = event.senderFrame?.url ?? ''; + return isTrustedRendererUrl(senderUrl, options.devServerUrl, options.packagedRendererUrl); + }; + const handle = (channel: string, handler: Handler): void => { + channels.add(channel); + options.ipcMain.handle(channel, async (event, ...args) => { + if (closing) throw closingError(); + if (!trusted(event)) { + options.logger.log('warn', 'desktop.ipc.rejected', { channel }); + throw new Error('Untrusted desktop IPC sender'); + } + options.observeInvocation?.('entry', channel); + const invocation = Promise.resolve().then(() => handler(event, ...args)); + active.add(invocation); + try { + const result = await invocation; + const operation = acceptanceOperations.get(channel); + if (operation) options.reportAcceptanceOperation?.(operation, acceptanceStatus(result)); + return result; + } catch (error) { + const operation = acceptanceOperations.get(channel); + if (operation) options.reportAcceptanceOperation?.(operation, 'REJECTED'); + options.logger.log('error', 'desktop.ipc.failed', { channel, code: 'IPC_OPERATION_FAILED' }); + throw new Error('Desktop operation failed [IPC_OPERATION_FAILED]'); + } finally { + active.delete(invocation); + options.observeInvocation?.('exit', channel); + } + }); + }; + const reconcileRendererActiveProfile = async (): Promise => { + if (!options.onRendererActiveProfileChanged) return; + const generation = ++rendererActiveProfileReconciliationGeneration; + let current; + try { + current = await options.credentials.listProfiles(); + } catch (error) { + if (generation === rendererActiveProfileReconciliationGeneration) { + options.onRendererActiveProfileChanged(null); + } + throw error; + } + const activeOrigin = current.profiles + .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl ?? null; + if (generation === rendererActiveProfileReconciliationGeneration) { + options.onRendererActiveProfileChanged(activeOrigin); + } + }; + + handle(IPC_CHANNELS.appMetadata, () => ({ + name: options.app.getName(), + version: options.app.getVersion(), + platform: process.platform, + arch: process.arch, + packaged: options.app.isPackaged, + })); + handle(IPC_CHANNELS.authLogout, (_event, apiBaseUrl) => logoutDesktopSession(options.desktopSession, apiBaseUrl)); + handle(IPC_CHANNELS.openExternal, async (_event, value: unknown) => { + if (typeof value !== 'string' || !isSafeExternalUrl(value)) throw new Error('External URL is not allowed'); + await options.openExternal(value); + }); + handle(IPC_CHANNELS.storageSecurity, () => options.credentials.storageSecurity()); + handle(IPC_CHANNELS.profilesList, () => options.credentials.listProfiles()); + handle(IPC_CHANNELS.profilesSave, async (_event, input) => { + const profile = await options.credentials.saveProfile( + input, + (previousOrigin, nextOrigin) => clearDesktopInstanceCookies( + options.desktopSession, + [previousOrigin, nextOrigin], + ), + ); + await reconcileRendererActiveProfile(); + return profile; + }); + handle(IPC_CHANNELS.profilesRemove, async (_event, profileId) => { + await options.credentials.removeProfile( + profileId, + origin => clearDesktopInstanceCookies(options.desktopSession, [origin]), + ); + await reconcileRendererActiveProfile(); + }); + handle(IPC_CHANNELS.profilesSetActive, async (_event, profileId) => { + const current = await options.credentials.listProfiles(); + const previous = current.profiles.find(profile => profile.id === current.activeProfileId); + const next = current.profiles.find(profile => profile.id === profileId); + if (profileId !== null && !next) throw new Error('Desktop profile does not exist'); + await clearDesktopInstanceCookies(options.desktopSession, [ + ...(previous ? [previous.apiBaseUrl] : []), + ...(next ? [next.apiBaseUrl] : []), + ]); + await options.credentials.setActiveProfile(profileId); + await reconcileRendererActiveProfile(); + }); + handle(IPC_CHANNELS.authenticationPair, async (_event, profile) => { + const paired = await options.credentials.pair(profile); + await reconcileRendererActiveProfile(); + return paired; + }); + handle(IPC_CHANNELS.authenticationCancel, (_event, profileId) => options.credentials.cancelPairing(profileId)); + handle(IPC_CHANNELS.connectionProbe, (_event, profile) => options.credentials.probe(profile)); + handle(IPC_CHANNELS.connectionActivate, async (_event, activationTicket) => { + const before = await options.credentials.listProfiles(); + const activated = await options.credentials.activate(activationTicket); + try { + const after = await options.credentials.listProfiles(); + const previousOrigin = before.profiles + .find(profile => profile.id === before.activeProfileId)?.apiBaseUrl; + const activatedOrigin = after.profiles + .find(profile => profile.id === after.activeProfileId)?.apiBaseUrl; + const origins = [previousOrigin, activatedOrigin].filter(origin => origin !== undefined); + await clearDesktopInstanceCookies(options.desktopSession, origins); + if (!activatedOrigin) throw new Error('Desktop activation did not establish a renderer origin'); + await reconcileRendererActiveProfile(); + return activated; + } catch (error) { + const discarded = await options.credentials.discardActivation({ + profileId: activated.profileId, + transportScope: activated.transportScope, + }); + if (discarded.discarded) await reconcileRendererActiveProfile(); + throw error; + } + }); + handle(IPC_CHANNELS.connectionDiscard, async (_event, value) => { + const discarded = await options.credentials.discardActivation(value); + if (discarded.discarded) await reconcileRendererActiveProfile(); + return discarded; + }); + handle(IPC_CHANNELS.connectionInvalidate, (_event, value) => options.credentials.invalidate(value)); + handle(IPC_CHANNELS.connectDiscover, (_event, ...args) => { + if (args.length) throw new Error('Invalid Connect discovery request'); + return options.connectDiscovery.discover(); + }); + handle(IPC_CHANNELS.connectRediscover, (_event, profileId, ...args) => { + if (args.length) throw new Error('Invalid Connect rediscovery request'); + return options.connectDiscovery.rediscover(profileId); + }); + if (options.reportAcceptanceJourneyStage) { + handle(IPC_CHANNELS.acceptanceJourneyStage, (_event, stage, ...args) => { + if (args.length || !acceptanceStages.has(stage)) throw new Error('Invalid acceptance journey stage'); + options.reportAcceptanceJourneyStage!(stage); + }); + } + handle(IPC_CHANNELS.lifecycleStatus, () => options.lifecycle.status()); + handle(IPC_CHANNELS.lifecycleStart, () => options.lifecycle.start()); + handle(IPC_CHANNELS.lifecycleStop, () => options.lifecycle.stop()); + handle(IPC_CHANNELS.lifecycleRestart, () => options.lifecycle.restart()); + return { + close() { + if (closing) return; + closing = true; + for (const channel of channels) { + options.ipcMain.removeHandler(channel); + options.ipcMain.handle(channel, () => Promise.reject(closingError())); + } + }, + async awaitIdle() { + while (active.size > 0) await Promise.allSettled([...active]); + }, + dispose() { + closing = true; + for (const channel of channels) options.ipcMain.removeHandler(channel); + }, + }; +}; diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts new file mode 100644 index 000000000..a302635fc --- /dev/null +++ b/apps/desktop/src/lifecycle.ts @@ -0,0 +1,40 @@ +import type { LocalLifecycleOperationResult, LocalLifecycleStatus } from './shared/contract'; + +/** + * Stable renderer-facing lifecycle boundary. Runtime installation and process + * control are deliberately absent until the user-approved setup work lands. + */ +export class LocalLifecycleController { + #status: LocalLifecycleStatus = { state: 'disconnected' }; + + status(): LocalLifecycleStatus { + return { ...this.#status }; + } + + start(): LocalLifecycleOperationResult { + return this.#unsupported(); + } + + stop(): LocalLifecycleOperationResult { + return this.#unsupported(); + } + + restart(): LocalLifecycleOperationResult { + return this.#unsupported(); + } + + async shutdown(): Promise { + this.#status = { state: 'disconnected' }; + } + + #unsupported(): LocalLifecycleOperationResult { + return { + ok: false, + code: 'not-implemented', + status: { + ...this.#status, + detail: 'Local runtime management is not available in this desktop scaffold.', + }, + }; + } +} diff --git a/apps/desktop/src/logger.test.ts b/apps/desktop/src/logger.test.ts new file mode 100644 index 000000000..12d2a1908 --- /dev/null +++ b/apps/desktop/src/logger.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { assertPackagedLayout, parseEventLayout } from '../scripts/packaged-layout.mjs'; +import { formatDesktopLogRecord, sanitizeDesktopLogFields } from './logger'; + +const bounds = (left: number, top: number, width: number, height: number) => ({ + bottom: top + height, + height, + left, + right: left + width, + top, + width, +}); + +const completePackagedLayout = () => ({ + screen: { height: 1080, width: 1920 }, + viewport: { height: 780, width: 1280 }, + entry: bounds(0, 0, 1280, 780), + card: bounds(350, 40, 580, 640), + logo: bounds(624, 72, 32, 32), + heading: bounds(430, 132, 420, 58), + connectButton: bounds(380, 230, 520, 76), + connectDescription: bounds(490, 270, 300, 18), + windowBounds: { x: 0, y: 0, width: 1280, height: 820 }, + contentBounds: { x: 0, y: 0, width: 1280, height: 780 }, + minimumSize: { width: 880, height: 620 }, + workArea: { x: 0, y: 0, width: 1920, height: 1040 }, +}); + +describe('desktop logger field schemas', () => { + it('logs the complete successful packaged layout for the smoke parser and assertion', () => { + const inspectedLayout = completePackagedLayout(); + const record = formatDesktopLogRecord( + 'info', + 'desktop.renderer.layout.ready', + { layout: inspectedLayout }, + '2026-09-02T00:00:00.000Z', + ); + assert.equal(record, JSON.stringify({ + timestamp: '2026-09-02T00:00:00.000Z', + level: 'info', + event: 'desktop.renderer.layout.ready', + layout: inspectedLayout, + })); + + const parsedLayout = parseEventLayout(`Chromium prefix\n${record}\n`, 'desktop.renderer.layout.ready'); + assert.deepEqual(parsedLayout, inspectedLayout); + assert.doesNotThrow(() => assertPackagedLayout(parsedLayout, 'linux')); + assert.deepEqual( + sanitizeDesktopLogFields('desktop.renderer.layout.ready', { + layout: { ...inspectedLayout, missing: [] }, + }), + { layout: inspectedLayout }, + ); + }); + + it('preserves the exact reduced native window geometry schema', () => { + const layout = { + displayWorkArea: { x: -1600, y: 0, width: 1600, height: 900 }, + workArea: { x: -1200, y: 170, width: 800, height: 560 }, + windowBounds: { x: -1200, y: 170, width: 800, height: 560, visible: true }, + minimumSize: { width: 800, height: 560 }, + }; + assert.deepEqual(sanitizeDesktopLogFields('desktop.native.reduced_window.ready', { layout }), { layout }); + }); + + it('requires own layout and geometry keys despite inherited keys and a shadowed hasOwnProperty', () => { + const valid = completePackagedLayout(); + const { workArea, ...layoutWithoutOwnWorkArea } = valid; + const inheritedLayoutKey = Object.assign(Object.create({ workArea }), layoutWithoutOwnWorkArea); + const inheritedGeometryKey = Object.assign( + Object.create({ width: valid.windowBounds.width }) as Record, + { x: 0, y: 0, height: valid.windowBounds.height, visible: true }, + ); + Object.defineProperty(inheritedGeometryKey, 'hasOwnProperty', { + value: () => true, + }); + + for (const layout of [ + inheritedLayoutKey, + { ...valid, windowBounds: inheritedGeometryKey }, + ]) { + assert.deepEqual(sanitizeDesktopLogFields('desktop.renderer.layout.ready', { layout }), { + layout: { code: 'DETAIL_REDACTED' }, + }); + } + }); + + it('redacts malformed, secret, path-bearing, array, error, and over-broad layouts', () => { + const valid = completePackagedLayout(); + const rejectedLayouts: unknown[] = [ + { ...valid, unknown: { width: 1, height: 1 } }, + { ...valid, windowBounds: { ...valid.windowBounds, width: '1280' } }, + { ...valid, windowBounds: [0, 0, 1280, 820] }, + { ...valid, windowBounds: { ...valid.windowBounds, width: Number.POSITIVE_INFINITY } }, + { ...valid, windowBounds: { ...valid.windowBounds, token: 'secret-SENTINEL' } }, + { ...valid, windowBounds: { ...valid.windowBounds, path: '/private/path-SENTINEL' } }, + { ...valid, windowBounds: new Error('/private/path-SENTINEL') }, + { ...valid, windowBounds: { width: 1280, height: 820 } }, + { ...valid, missing: ['connectDescription'] }, + Object.fromEntries(Array.from({ length: 64 }, (_, index) => [ + `geometry${index}`, + { width: index + 1, height: index + 1 }, + ])), + ]; + + for (const layout of rejectedLayouts) { + const sanitized = sanitizeDesktopLogFields('desktop.renderer.layout.ready', { layout }); + assert.deepEqual(sanitized, { layout: { code: 'DETAIL_REDACTED' } }); + const serialized = JSON.stringify(sanitized); + assert.doesNotMatch(serialized, /secret-SENTINEL|private\/path-SENTINEL|connectDescription/u); + } + }); + + it('redacts a non-empty missing-selector result and leaves layout assertion failed closed', () => { + const record = formatDesktopLogRecord( + 'info', + 'desktop.renderer.layout.ready', + { layout: { missing: ['connectButton', 'connectDescription'] } }, + '2026-09-02T00:00:00.000Z', + ); + assert.doesNotMatch(record, /connectButton|connectDescription/u); + assert.match(record, /DETAIL_REDACTED/u); + + const parsedLayout = parseEventLayout(record, 'desktop.renderer.layout.ready'); + assert.deepEqual(parsedLayout, { code: 'DETAIL_REDACTED' }); + assert.throws( + () => assertPackagedLayout(parsedLayout, 'linux'), + /does not have positive bounds/, + ); + }); + + it('does not weaken general object or error redaction', () => { + const secret = { token: 'secret-SENTINEL', path: '/private/path-SENTINEL' }; + assert.deepEqual(sanitizeDesktopLogFields('desktop.other', { + detail: secret, + error: new Error('/private/path-SENTINEL'), + }), { + detail: { code: 'DETAIL_REDACTED' }, + error: { code: 'OPERATION_FAILED' }, + }); + }); +}); diff --git a/apps/desktop/src/logger.ts b/apps/desktop/src/logger.ts new file mode 100644 index 000000000..f63c89f90 --- /dev/null +++ b/apps/desktop/src/logger.ts @@ -0,0 +1,141 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface DesktopLogger { + log(level: LogLevel, event: string, fields?: Record): void; +} + +const safeField = (value: unknown): unknown => { + if (value instanceof Error) return { code: 'OPERATION_FAILED' }; + if (typeof value === 'string') return value.length <= 128 ? value : value.slice(0, 128); + if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value; + return { code: 'DETAIL_REDACTED' }; +}; + +const LAYOUT_EVENT = 'desktop.renderer.layout.ready'; +const REDUCED_NATIVE_WINDOW_EVENT = 'desktop.native.reduced_window.ready'; +const RENDERER_LAYOUT_KEYS = new Set([ + 'windowBounds', 'contentBounds', 'minimumSize', 'workArea', 'screen', 'viewport', + 'entry', 'card', 'logo', 'heading', 'connectButton', 'connectDescription', +]); +const REDUCED_NATIVE_WINDOW_LAYOUT_KEYS = new Set([ + 'windowBounds', 'minimumSize', 'workArea', 'displayWorkArea', +]); +const RECTANGLE_NUMBER_KEYS = new Set(['x', 'y', 'width', 'height']); +const DIMENSION_NUMBER_KEYS = new Set(['width', 'height']); +const ELEMENT_NUMBER_KEYS = new Set(['top', 'right', 'bottom', 'left', 'width', 'height']); +const LAYOUT_NUMBER_KEYS = new Map>([ + ['windowBounds', RECTANGLE_NUMBER_KEYS], + ['contentBounds', RECTANGLE_NUMBER_KEYS], + ['minimumSize', DIMENSION_NUMBER_KEYS], + ['workArea', RECTANGLE_NUMBER_KEYS], + ['displayWorkArea', RECTANGLE_NUMBER_KEYS], + ['screen', DIMENSION_NUMBER_KEYS], + ['viewport', DIMENSION_NUMBER_KEYS], + ['entry', ELEMENT_NUMBER_KEYS], + ['card', ELEMENT_NUMBER_KEYS], + ['logo', ELEMENT_NUMBER_KEYS], + ['heading', ELEMENT_NUMBER_KEYS], + ['connectButton', ELEMENT_NUMBER_KEYS], + ['connectDescription', ELEMENT_NUMBER_KEYS], +]); +const WINDOW_BOOLEAN_KEYS = new Set(['visible', 'maximized', 'fullScreen']); + +const boundedLayout = ( + event: string, + value: unknown, +): Record> | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entries = Object.entries(value); + const expectedLayoutKeys = event === LAYOUT_EVENT + ? RENDERER_LAYOUT_KEYS + : REDUCED_NATIVE_WINDOW_LAYOUT_KEYS; + const normalizedEntries: Array<[string, unknown]> = []; + for (const entry of entries) { + if (entry[0] !== 'missing') { + normalizedEntries.push(entry); + continue; + } + if (event !== LAYOUT_EVENT || !Array.isArray(entry[1]) || entry[1].length !== 0) return null; + } + if (normalizedEntries.length !== expectedLayoutKeys.size) return null; + const result: Record> = {}; + for (const [name, rawGeometry] of normalizedEntries) { + if (!expectedLayoutKeys.has(name) + || !rawGeometry + || typeof rawGeometry !== 'object' + || Array.isArray(rawGeometry)) { + return null; + } + const geometry = Object.entries(rawGeometry); + const expectedNumberKeys = LAYOUT_NUMBER_KEYS.get(name); + if (!expectedNumberKeys) return null; + const allowedBooleanKeys = name === 'windowBounds' ? WINDOW_BOOLEAN_KEYS : undefined; + if (geometry.length < expectedNumberKeys.size + || geometry.length > expectedNumberKeys.size + (allowedBooleanKeys?.size ?? 0)) return null; + const safeGeometry: Record = {}; + for (const [key, measurement] of geometry) { + const validNumber = expectedNumberKeys.has(key) + && typeof measurement === 'number' + && Number.isFinite(measurement); + const validBoolean = allowedBooleanKeys?.has(key) === true && typeof measurement === 'boolean'; + if (!validNumber && !validBoolean) return null; + safeGeometry[key] = measurement; + } + if ([...expectedNumberKeys].some( + key => !Object.prototype.hasOwnProperty.call(safeGeometry, key), + )) return null; + result[name] = safeGeometry; + } + return result; +}; + +export const sanitizeDesktopLogFields = ( + event: string, + fields: Record, +): Record => Object.fromEntries(Object.entries(fields).map(([key, value]) => { + if ((event === LAYOUT_EVENT || event === REDUCED_NATIVE_WINDOW_EVENT) && key === 'layout') { + return [key, boundedLayout(event, value) ?? { code: 'DETAIL_REDACTED' }]; + } + return [key, safeField(value)]; +})); + +export const formatDesktopLogRecord = ( + level: LogLevel, + event: string, + fields: Record = {}, + timestamp = new Date().toISOString(), +): string => JSON.stringify({ + timestamp, + level, + event, + ...sanitizeDesktopLogFields(event, fields), +}); + +export const createDesktopLogger = ( + logPath: string, + onWriteFailure?: () => void, +): DesktopLogger => { + let pending = Promise.resolve(); + const log = (level: LogLevel, event: string, fields: Record = {}) => { + const record = formatDesktopLogRecord(level, event, fields); + const consoleMethod = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + consoleMethod(record); + pending = pending + .then(async () => { + await mkdir(dirname(logPath), { recursive: true, mode: 0o700 }); + await appendFile(logPath, `${record}\n`, { encoding: 'utf8', mode: 0o600 }); + }) + .catch(() => { + try { + onWriteFailure?.(); + } catch { + // Keep the fixed logger diagnostic available even if the smoke-only sink also fails. + } + console.error(JSON.stringify({ level: 'error', event: 'desktop.log.write_failed', code: 'LOG_WRITE_FAILED' })); + }); + }; + return { log }; +}; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 000000000..480f93474 --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,1422 @@ +import { randomBytes } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import { basename, isAbsolute, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { app, BrowserWindow, crashReporter, ipcMain, net, protocol, safeStorage, screen, session, shell } from 'electron'; +import type { Rectangle } from 'electron'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { + DESKTOP_CONNECT_DISCOVERY_PLATFORMS, + discoverConfiguredConnect, +} from '@propr/cli/desktop-discovery'; +import { DesktopConnectDiscoveryService } from './connect-discovery'; +import { DeepLinkDelivery } from './deep-link-delivery'; +import { clearDesktopInstanceCookies } from './desktop-session'; +import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; +import { + registerIpcHandlers, + type DesktopAcceptanceOperation, + type DesktopAcceptanceOperationStatus, +} from './ipc'; +import { LocalLifecycleController } from './lifecycle'; +import { createDesktopLogger, type DesktopLogger } from './logger'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { openApprovedDesktopPairingUrl } from './pairing-browser'; +import { + clearPackagedApprovalStorage, + createPackagedApprovalNavigation, + createPackagedApprovalTaskTracker, + packagedApprovalPartition, +} from './packaged-approval-session'; +import { createDesktopShutdownCoordinator } from './shutdown'; +import { + createLatestRendererReloader, + deepLinkFromArguments, + isSafeExternalUrl, + isTrustedRendererUrl, + normalizeApiBaseUrl, + normalizeDeepLink, + rendererContentSecurityPolicy, + validatedDevServerUrl, +} from './security'; +import { + DESKTOP_PROTOCOL, + IPC_CHANNELS, + type DesktopAcceptanceJourneyStage, +} from './shared/contract'; +import { checkForSignedUpdates } from './signed-updates'; +import { authorizePackagedSmokeTest } from './smoke-test-authorization'; +import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { + configureDesktopSessionSecurity, + type DesktopNetworkPermissionEvidence, + type DesktopRendererOwnershipEvidence, +} from './session-security'; +import { + createBrowserWindowOptions, + MINIMUM_BROWSER_WINDOW_SIZE, + selectInitialWindowWorkArea, +} from './window-options'; + +const devServerUrl = typeof MAIN_WINDOW_VITE_DEV_SERVER_URL === 'string' + ? MAIN_WINDOW_VITE_DEV_SERVER_URL + : undefined; +const PACKAGED_RENDERER_SCHEME = 'propr-app'; +const PACKAGED_RENDERER_HOST = 'renderer'; +const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; +const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; +const PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT = 'desktop.renderer.connect_discovery.milestone'; +const PACKAGED_CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.stage'; +const PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; +const PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; +const PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; +type PackagedConnectJourneyStage = + | 'JOURNEY_DISCOVERY_RENDERER' + | 'JOURNEY_DISCOVERY_VALIDATED' + | 'JOURNEY_STORAGE_BACKEND' + | 'JOURNEY_NEGATIVE_MALFORMED' + | 'JOURNEY_NEGATIVE_OVERSIZED' + | 'JOURNEY_NEGATIVE_EXPIRY' + | 'JOURNEY_NEGATIVE_CANCEL' + | 'JOURNEY_NEGATIVE_STATE' + | 'JOURNEY_PAIR_MANUAL_FORM' + | 'JOURNEY_PAIR_BROWSER_APPROVAL' + | 'JOURNEY_PAIR_ACTIVATION_DASHBOARD' + | 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED' + | 'JOURNEY_PAIR_CREDENTIAL_COMMITTED' + | 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY' + | 'JOURNEY_PAIR_ACTIVATION_COMMITTED' + | 'JOURNEY_PAIR_ACTIVATION_PUBLISHED' + | 'JOURNEY_PAIR_REACT_CONNECTED' + | 'JOURNEY_PAIR_TRANSPORT' + | 'JOURNEY_PAIR_COMPLETE' + | 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD' + | 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY' + | 'JOURNEY_REPROBE_ACTIVATION_COMMITTED' + | 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED' + | 'JOURNEY_REPROBE_REACT_CONNECTED' + | 'JOURNEY_REPROBE_TRANSPORT' + | 'JOURNEY_REPROBE_COMPLETE'; +type PackagedConnectJourneyFailureReason = + | 'APPROVAL_REJECTED' + | 'JOURNEY_FAILED' + | 'RENDERER_STAGE_TIMEOUT' + | 'RENDERER_STATE_TIMEOUT' + | 'TRANSPORT_EVIDENCE_TIMEOUT'; +interface PackagedConnectJourneyDiagnosticState { + phase: 'pair' | 'reprobe'; + stage: PackagedConnectJourneyStage | 'JOURNEY_NOT_STARTED'; +} +let packagedConnectJourneyDiagnosticState: PackagedConnectJourneyDiagnosticState | null = null; +const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); +const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; +let packagedSmokeUserDataDirectory: string | null = null; +let packagedSmokeEvidence: ReturnType = null; +try { + packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({ + argv: process.argv, + defaultUserDataDirectory: join(app.getPath('appData'), app.name), + environmentTriggered: process.env.PROPR_DESKTOP_SMOKE_TEST === '1', + isPackaged: app.isPackaged, + platform: process.platform, + }); + if (packagedSmokeUserDataDirectory) { + const smokeDirectoryStats = lstatSync(packagedSmokeUserDataDirectory); + if (!smokeDirectoryStats.isDirectory() || smokeDirectoryStats.isSymbolicLink()) { + throw new Error('Packaged desktop smoke --user-data-dir must be an existing non-link directory'); + } + app.setPath('userData', packagedSmokeUserDataDirectory); + packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); + packagedSmokeEvidence?.write('desktop.smoke.authorized'); + } +} catch { + process.exit(1); +} +const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; +let mainWindow: BrowserWindow | null = null; +const initialDeepLink = deepLinkFromArguments(process.argv); +const deepLinkDelivery = new DeepLinkDelivery( + IPC_CHANNELS.deepLink, + initialDeepLink ? [initialDeepLink] : [], +); +let logger: DesktopLogger | null = null; +let shutdownStarted = false; +if (process.platform === 'win32') { + app.setAppUserModelId('dev.propr.desktop'); +} + +interface PackagedTransportSmoke { + firstOrigin: string; + secondOrigin: string; + shutdownMode: 'success' | 'retry' | 'forced-timeout'; +} +let activePackagedTransportSmoke: PackagedTransportSmoke | null = null; +let activePackagedConnectJourney = false; + +interface PackagedConnectSmoke { + configRoot: string; + fetch: typeof globalThis.fetch; + journeyEndpoint?: string; + journeyPhase?: 'pair' | 'reprobe'; +} + +const packagedConnectSmoke = (): PackagedConnectSmoke | null => { + if (!app.isPackaged || process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST !== '1') return null; + const suppliedRoot = process.env.PROPR_DESKTOP_CONNECT_SMOKE_CONFIG_ROOT; + if (!suppliedRoot || !isAbsolute(suppliedRoot)) throw new Error('Packaged Connect smoke requires an isolated config root'); + const configRoot = realpathSync.native(suppliedRoot); + const temporaryRoot = realpathSync.native(app.getPath('temp')); + const contained = relative(temporaryRoot, configRoot); + if (!contained || contained.startsWith('..') || isAbsolute(contained)) { + throw new Error('Packaged Connect smoke config root is outside the temporary directory'); + } + const suppliedJourneyEndpoint = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT; + const suppliedJourneyPhase = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE; + let journeyEndpoint: string | undefined; + let journeyPhase: 'pair' | 'reprobe' | undefined; + if (suppliedJourneyEndpoint !== undefined || suppliedJourneyPhase !== undefined) { + const normalized = normalizeApiBaseUrl(suppliedJourneyEndpoint ?? ''); + if (!normalized) throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + const parsed = new URL(normalized); + if (process.platform === 'win32' || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || (suppliedJourneyPhase !== 'pair' && suppliedJourneyPhase !== 'reprobe')) { + throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + } + journeyEndpoint = normalized; + journeyPhase = suppliedJourneyPhase; + } + const endpoint = 'https://t-packaged123.propr.dev'; + const publicInstanceIdentity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const fetch: typeof globalThis.fetch = async input => { + if (input.toString() !== `${endpoint}/api/desktop/discovery`) { + throw new Error('Packaged Connect smoke rejected an unexpected network request'); + } + return new Response(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: app.getVersion(), + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: endpoint, + publicInstanceIdentity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + return { configRoot, fetch, journeyEndpoint, journeyPhase }; +}; + +const packagedTransportSmoke = (): PackagedTransportSmoke | null => { + if (!app.isPackaged || process.env.PROPR_DESKTOP_SMOKE_TEST !== '1') return null; + const transportRequested = [ + process.env.PROPR_DESKTOP_SMOKE_FIRST_ORIGIN, + process.env.PROPR_DESKTOP_SMOKE_SECOND_ORIGIN, + process.env.PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE, + ].some(value => value !== undefined); + if (!transportRequested) return null; + const firstOrigin = normalizeApiBaseUrl(process.env.PROPR_DESKTOP_SMOKE_FIRST_ORIGIN ?? ''); + const secondOrigin = normalizeApiBaseUrl(process.env.PROPR_DESKTOP_SMOKE_SECOND_ORIGIN ?? ''); + const shutdownMode = process.env.PROPR_DESKTOP_SMOKE_SHUTDOWN_MODE; + const isolatedUserData = basename(app.getPath('userData')).startsWith('propr-desktop-smoke-'); + const loopback = (origin: string | null): origin is string => origin !== null + && new URL(origin).hostname === '127.0.0.1'; + if (!isolatedUserData || !loopback(firstOrigin) || !loopback(secondOrigin) || firstOrigin === secondOrigin + || (shutdownMode !== 'success' && shutdownMode !== 'retry' && shutdownMode !== 'forced-timeout')) { + throw new Error('Packaged desktop transport smoke requires two distinct loopback fixtures and isolated user data'); + } + return { firstOrigin, secondOrigin, shutdownMode }; +}; + +const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: Record) => { + packagedSmokeEvidence?.write(event); + if (logger) { + logger.log(level, event, fields); + } else { + console.error(JSON.stringify({ + timestamp: new Date().toISOString(), + level, + event, + code: fields ? 'DETAIL_REDACTED' : undefined, + })); + } +}; + +const reportPackagedConnectJourneyStage = ( + code: PackagedConnectJourneyStage, + evidence: { storageBackend: 'gnome_libsecret' | 'os-protected' } | undefined = undefined, +): void => { + if (packagedConnectJourneyDiagnosticState) packagedConnectJourneyDiagnosticState.stage = code; + log('info', PACKAGED_CONNECT_JOURNEY_STAGE_EVENT, { code, ...evidence }); +}; + +const packagedConnectJourneyFailureReason = (error: unknown): PackagedConnectJourneyFailureReason => { + if (!(error instanceof Error)) return 'JOURNEY_FAILED'; + if (error.message === 'Packaged pairing browser approval was rejected') return 'APPROVAL_REJECTED'; + if (error.message === 'Packaged Connect journey renderer stage timed out') { + return 'RENDERER_STAGE_TIMEOUT'; + } + if (error.message === 'Packaged Connect journey renderer state timed out') { + return 'RENDERER_STATE_TIMEOUT'; + } + if (error.message === 'Packaged Connect authenticated transport proof timed out') { + return 'TRANSPORT_EVIDENCE_TIMEOUT'; + } + return 'JOURNEY_FAILED'; +}; + +interface PackagedJourneyStageTracker { + record(stage: DesktopAcceptanceJourneyStage): void; + waitFor(stage: DesktopAcceptanceJourneyStage): Promise; +} + +const createPackagedJourneyStageTracker = ( + phase: 'pair' | 'reprobe', +): PackagedJourneyStageTracker => { + const seen = new Set(); + const waiters = new Map void>>(); + const stageCodes: Partial> = phase === 'pair' + ? { + AUTHENTICATION_REQUIRED: 'JOURNEY_PAIR_AUTHENTICATION_REQUIRED', + CREDENTIAL_COMMITTED: 'JOURNEY_PAIR_CREDENTIAL_COMMITTED', + AUTHENTICATED_REPROBE_READY: 'JOURNEY_PAIR_AUTHENTICATED_REPROBE_READY', + ACTIVATION_COMMITTED: 'JOURNEY_PAIR_ACTIVATION_COMMITTED', + ACTIVATION_PUBLISHED: 'JOURNEY_PAIR_ACTIVATION_PUBLISHED', + REACT_CONNECTED: 'JOURNEY_PAIR_REACT_CONNECTED', + } + : { + AUTHENTICATED_REPROBE_READY: 'JOURNEY_REPROBE_AUTHENTICATED_REPROBE_READY', + ACTIVATION_COMMITTED: 'JOURNEY_REPROBE_ACTIVATION_COMMITTED', + ACTIVATION_PUBLISHED: 'JOURNEY_REPROBE_ACTIVATION_PUBLISHED', + REACT_CONNECTED: 'JOURNEY_REPROBE_REACT_CONNECTED', + }; + return { + record(stage) { + const code = stageCodes[stage]; + if (!code) throw new Error('Packaged Connect journey reported an invalid phase stage'); + if (seen.has(stage)) return; + seen.add(stage); + reportPackagedConnectJourneyStage(code); + for (const resolveWaiter of waiters.get(stage) ?? []) resolveWaiter(); + waiters.delete(stage); + }, + waitFor(stage) { + if (seen.has(stage)) return Promise.resolve(); + return new Promise((resolveStage, rejectStage) => { + const timer = setTimeout(() => { + waiters.get(stage)?.delete(resolve); + rejectStage(new Error('Packaged Connect journey renderer stage timed out')); + }, 15_000); + const resolve = () => { + clearTimeout(timer); + resolveStage(); + }; + const current = waiters.get(stage) ?? new Set(); + current.add(resolve); + waiters.set(stage, current); + }); + }, + }; +}; + +process.on('uncaughtExceptionMonitor', () => { + log('error', 'desktop.main_process.uncaught_exception', { code: 'UNCAUGHT_EXCEPTION' }); +}); + +protocol.registerSchemesAsPrivileged([{ + scheme: PACKAGED_RENDERER_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, +}]); + +const registerProtocolClient = (): void => { + if (process.defaultApp && process.argv[1]) { + app.setAsDefaultProtocolClient(DESKTOP_PROTOCOL, process.execPath, [process.argv[1]]); + return; + } + app.setAsDefaultProtocolClient(DESKTOP_PROTOCOL); +}; + +const deliverDeepLink = (value: string): void => { + deepLinkDelivery.deliver(value); +}; + +const configurePackagedRendererProtocol = ( + contentSecurityPolicy: () => string, +): (() => void) => { + protocol.handle(PACKAGED_RENDERER_SCHEME, async request => { + const requestUrl = new URL(request.url); + if (requestUrl.hostname !== PACKAGED_RENDERER_HOST) { + return new Response(null, { status: 404 }); + } + + let requestedPath: string; + try { + requestedPath = decodeURIComponent(requestUrl.pathname).replace(/^\/+/, ''); + } catch { + return new Response(null, { status: 400 }); + } + const filePath = resolve(packagedRendererRoot, requestedPath); + const relativePath = relative(packagedRendererRoot, filePath); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + return new Response(null, { status: 403 }); + } + const response = await net.fetch(pathToFileURL(filePath).href); + if (requestedPath !== 'renderer.html' || !response.ok) return response; + + const packagedPolicy = rendererContentSecurityPolicy(); + const html = await response.text(); + if (!html.includes(packagedPolicy)) { + return new Response(null, { status: 500 }); + } + const headers = new Headers(response.headers); + headers.delete('content-length'); + headers.set('content-type', 'text/html; charset=UTF-8'); + return new Response(html.replace(packagedPolicy, contentSecurityPolicy()), { + status: response.status, + statusText: response.statusText, + headers, + }); + }); + return () => { void protocol.unhandle(PACKAGED_RENDERER_SCHEME); }; +}; + +const openAllowedExternalUrl = async (url: string): Promise => { + if (shutdownStarted) return; + if (!isSafeExternalUrl(url)) { + log('warn', 'desktop.external_url.rejected'); + return; + } + await shell.openExternal(url); +}; + +const inspectPackagedLayout = async (window: BrowserWindow): Promise> => { + const rendererLayout = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 5000; + let elements; + do { + const card = document.querySelector('.desktop-welcome-card'); + const connectButton = card?.querySelector('.desktop-choice-button'); + elements = { + entry: document.querySelector('.desktop-entry'), + card, + logo: card?.querySelector('.desktop-brand img'), + heading: card?.querySelector('.desktop-welcome-copy h1'), + connectButton, + connectDescription: connectButton?.querySelector('small'), + }; + if (Object.values(elements).every(Boolean)) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + + const missing = Object.entries(elements).filter(([, element]) => !element).map(([name]) => name); + if (missing.length > 0) return { missing }; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const bounds = element => { + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + }; + }; + return { + screen: { height: window.screen.height, width: window.screen.width }, + workArea: { height: window.screen.availHeight, width: window.screen.availWidth }, + viewport: { height: window.innerHeight, width: window.innerWidth }, + ...Object.fromEntries(Object.entries(elements).map(([name, element]) => [name, bounds(element)])), + }; + })()`); + const windowBounds = window.getBounds(); + const [minimumWidth, minimumHeight] = window.getMinimumSize(); + return { + ...rendererLayout, + windowBounds, + contentBounds: window.getContentBounds(), + minimumSize: { width: minimumWidth, height: minimumHeight }, + workArea: screen.getDisplayMatching(windowBounds).workArea, + }; +}; + +const closePackagedProfileEditorAndWaitForWelcomeChooser = async (window: BrowserWindow): Promise => { + const chooserReady = await window.webContents.executeJavaScript(`(async () => { + const editor = document.querySelector('.desktop-welcome-card form.desktop-profile-form'); + const backButton = editor?.querySelector('button.desktop-back-button'); + if (!(backButton instanceof HTMLButtonElement)) return false; + backButton.click(); + + const deadline = performance.now() + 5000; + do { + const card = document.querySelector('.desktop-welcome-card'); + const connectButton = card?.querySelector('.desktop-choice-button'); + const elements = { + entry: document.querySelector('.desktop-entry'), + card, + logo: card?.querySelector('.desktop-brand img'), + heading: card?.querySelector('.desktop-welcome-copy h1'), + connectButton, + connectDescription: connectButton?.querySelector('small'), + }; + const visiblyReady = Object.values(elements).every(element => { + if (!(element instanceof HTMLElement)) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return bounds.width > 0 && bounds.height > 0 + && bounds.right > 0 && bounds.bottom > 0 + && bounds.left < window.innerWidth && bounds.top < window.innerHeight + && style.display !== 'none' && style.visibility === 'visible' && style.opacity !== '0'; + }); + if (visiblyReady) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + if (chooserReady !== true) { + throw new Error('Packaged desktop welcome chooser was not restored after the profile flow'); + } +}; + +const createReducedSmokeWorkArea = (displayWorkArea: Rectangle): Rectangle => { + const width = Math.min(displayWorkArea.width, MINIMUM_BROWSER_WINDOW_SIZE.width - 80); + const height = Math.min(displayWorkArea.height, MINIMUM_BROWSER_WINDOW_SIZE.height - 60); + return { + x: displayWorkArea.x + Math.floor((displayWorkArea.width - width) / 2), + y: displayWorkArea.y + Math.floor((displayWorkArea.height - height) / 2), + width, + height, + }; +}; + +const inspectPackagedReducedNativeWindow = (): Record => { + const displayWorkArea = selectInitialWindowWorkArea(screen); + const workArea = createReducedSmokeWorkArea(displayWorkArea); + const probeWindow = new BrowserWindow( + createBrowserWindowOptions(join(__dirname, 'preload.cjs'), false, workArea), + ); + try { + const [minimumWidth, minimumHeight] = probeWindow.getMinimumSize(); + return { + displayWorkArea, + workArea, + windowBounds: probeWindow.getBounds(), + minimumSize: { width: minimumWidth, height: minimumHeight }, + }; + } finally { + probeWindow.destroy(); + } +}; + +const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<{ + selectedPlatform: string; + selectedArch: string; + authorityMechanism: string; + rendererSchemaValid: true; +}> => { + const proof = await window.webContents.executeJavaScript(`(async () => { + const bridge = window.proprDesktop; + const metadata = await bridge.app.getMetadata(); + const candidates = await bridge.discovery.discover(); + return { supported: bridge.discovery.supported, metadata, candidates }; + })()`); + const candidate = proof?.candidates?.[0]; + if (proof?.supported !== true + || proof.metadata?.packaged !== true + || proof.metadata?.platform !== process.platform + || proof.metadata?.arch !== process.arch + || !Array.isArray(proof.candidates) + || proof.candidates.length !== 1 + || !candidate + || Object.keys(candidate).sort().join(',') !== 'apiBaseUrl,id,label' + || candidate.id !== 'propr-connect-discovered' + || candidate.label !== 'ProPR Connect' + || candidate.apiBaseUrl !== 'https://t-packaged123.propr.dev') { + throw new Error('Packaged Connect renderer discovery proof was invalid'); + } + const readyFields = { + selectedPlatform: process.platform, + selectedArch: process.arch, + authorityMechanism: process.platform === 'darwin' + ? 'packaged-broker' + : process.platform === 'linux' + ? 'in-process-native-addon' + : 'inherited-standard-handle', + rendererSchemaValid: true, + } as const; + log('info', PACKAGED_CONNECT_DISCOVERY_MILESTONE_EVENT, { + code: 'JOURNEY_DISCOVERY_VALIDATED', + }); + return readyFields; +}; + +const publishPackagedConnectReady = async (readyFields: Awaited< + ReturnType +>): Promise => { + await new Promise((resolveReady, rejectReady) => { + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'info', + event: 'desktop.renderer.connect_discovery.ready', + ...readyFields, + })}\n`, error => { + if (error) rejectReady(new Error('Packaged Connect READY publication failed')); + else resolveReady(); + }); + }); +}; + +const openPackagedJourneyApproval = async (request: DesktopPairingBrowserRequest): Promise => { + reportPackagedConnectJourneyStage('JOURNEY_PAIR_BROWSER_APPROVAL'); + await openApprovedDesktopPairingUrl(request, { + openExternal: async url => { + const approvalSession = session.fromPartition( + packagedApprovalPartition(randomBytes(16).toString('hex')), + { cache: false }, + ); + let approvalWindow: BrowserWindow | null = null; + let navigation: ReturnType | null = null; + try { + approvalWindow = new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: approvalSession, + webSecurity: true, + }, + }); + navigation = createPackagedApprovalNavigation({ + approvalUrl: url, + approvalSession, + approvalWindow, + defaultSession: session.defaultSession, + }); + await navigation.navigate(); + } finally { + if (navigation) { + await navigation.cleanup(); + } else { + if (approvalWindow && !approvalWindow.isDestroyed()) approvalWindow.destroy(); + await clearPackagedApprovalStorage(approvalSession); + } + } + }, + }); +}; + +const runPackagedConnectJourneySmoke = async ( + window: BrowserWindow, + profiles: ProfileStore, + credentials: DesktopCredentialService, + waitForNextApproval: () => Promise, + waitForApprovalIdle: () => Promise, + endpoint: string, + phase: 'pair' | 'reprobe', + stages: PackagedJourneyStageTracker, +): Promise => { + const security = profiles.security(); + const requiredStorageBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!security.available || security.backend !== requiredStorageBackend) { + throw new Error('Packaged Connect journey requires the production OS credential backend'); + } + reportPackagedConnectJourneyStage('JOURNEY_STORAGE_BACKEND', { + storageBackend: requiredStorageBackend, + }); + if (phase === 'pair') { + const setMode = async (mode: 'success' | 'malformed' | 'oversized' | 'expiry' | 'cancel') => { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/control/${mode}`, { + method: 'POST', redirect: 'manual', + }); + if (response.status !== 204) throw new Error('Packaged Connect fixture control failed'); + }; + for (const mode of ['malformed', 'oversized'] as const) { + reportPackagedConnectJourneyStage(mode === 'malformed' + ? 'JOURNEY_NEGATIVE_MALFORMED' + : 'JOURNEY_NEGATIVE_OVERSIZED'); + await setMode(mode); + const result = await credentials.probe({ + id: `negative-${mode}`, + label: `Packaged ${mode}`, + apiBaseUrl: endpoint, + }); + if (result.status === 'ready' || result.status === 'incompatible') { + throw new Error('Strict packaged discovery accepted invalid identity'); + } + } + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_EXPIRY'); + await setMode('expiry'); + await credentials.pair({ + id: 'negative-expiry', label: 'Packaged expiry', apiBaseUrl: endpoint, + }).then( + () => { throw new Error('Packaged pairing expiry unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/expired/i.test(error.message)) { + throw new Error('Packaged pairing expiry classification failed'); + } + }, + ); + await waitForApprovalIdle(); + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_CANCEL'); + await setMode('cancel'); + const approvalReady = waitForNextApproval(); + const cancelledPairing = credentials.pair({ + id: 'negative-cancel', label: 'Packaged cancel', apiBaseUrl: endpoint, + }); + await approvalReady; + await new Promise(resolve => setTimeout(resolve, 50)); + credentials.cancelPairing('negative-cancel'); + await cancelledPairing.then( + () => { throw new Error('Packaged pairing cancellation unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/cancelled/i.test(error.message)) { + throw new Error('Packaged pairing cancellation classification failed'); + } + }, + ); + await waitForApprovalIdle(); + reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_STATE'); + const failedProfiles = await profiles.list(); + if (failedProfiles.profiles.some(profile => profile.id.startsWith('negative-'))) { + throw new Error('Failed packaged pairing left stale profile or credential state'); + } + await setMode('success'); + } + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_MANUAL_FORM' + : 'JOURNEY_REPROBE_ACTIVATION_DASHBOARD'); + if (phase === 'pair') { + const submitted = await window.webContents.executeJavaScript(`(async () => { + const waitFor = async predicate => { + const deadline = performance.now() + 15000; + do { + const value = predicate(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + throw new Error('Packaged Connect journey renderer state timed out'); + }; + const setInput = (input, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + const chooser = await waitFor(() => document.querySelector('.desktop-welcome-card')); + const connect = Array.from(chooser.querySelectorAll('button.desktop-choice-button')) + .find(button => button.textContent?.includes('Connect to an existing instance')); + if (!(connect instanceof HTMLButtonElement)) return false; + connect.click(); + const form = await waitFor(() => document.querySelector('form.desktop-profile-form')); + const inputs = form.querySelectorAll('input'); + if (inputs.length !== 2) return false; + setInput(inputs[0], 'Packaged remote'); + setInput(inputs[1], ${JSON.stringify(endpoint)}); + form.requestSubmit(); + await waitFor(() => Array.from(document.querySelectorAll('.desktop-connection-card button')) + .find(button => button.textContent?.includes('Sign in in browser'))); + return true; + })()`); + if (submitted !== true) throw new Error('Packaged Connect manual profile submission failed'); + await stages.waitFor('AUTHENTICATION_REQUIRED'); + const clicked = await window.webContents.executeJavaScript(`(() => { + const authenticate = Array.from(document.querySelectorAll('.desktop-connection-card button')) + .find(button => button.textContent?.includes('Sign in in browser')); + if (!(authenticate instanceof HTMLButtonElement)) return false; + authenticate.click(); + return true; + })()`); + if (clicked !== true) throw new Error('Packaged Connect authentication action was missing'); + await stages.waitFor('CREDENTIAL_COMMITTED'); + } + await stages.waitFor('AUTHENTICATED_REPROBE_READY'); + await stages.waitFor('ACTIVATION_COMMITTED'); + await stages.waitFor('ACTIVATION_PUBLISHED'); + await stages.waitFor('REACT_CONNECTED'); + const proof = await window.webContents.executeJavaScript(`(() => { + const dashboard = document.querySelector('.desktop-app'); + const connection = document.querySelector('.desktop-connection-pill.desktop-connection-ready'); + const titlebar = document.querySelector('.desktop-titlebar'); + return { + connected: dashboard instanceof HTMLElement + && connection instanceof HTMLButtonElement + && titlebar instanceof HTMLElement, + rendererContractsContainSecret: JSON.stringify([ + window.proprDesktop, + dashboard instanceof HTMLElement ? dashboard.dataset : null, + ]).includes('propr_it_'), + title: connection instanceof HTMLButtonElement ? connection.getAttribute('aria-label') : null, + }; + })()`); + if (proof?.connected !== true || proof?.rendererContractsContainSecret !== false + || !proof?.title?.startsWith('Connected: Packaged remote')) { + throw new Error('Packaged Connect dashboard did not reach its connected state'); + } + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_TRANSPORT' + : 'JOURNEY_REPROBE_TRANSPORT'); + const requiredAuthenticatedRequests = phase === 'pair' ? 1 : 2; + const evidenceDeadline = Date.now() + 10_000; + let transportEvidence = { authenticatedRest: 0, authenticatedSockets: 0 }; + do { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/evidence`, { + credentials: 'omit', + redirect: 'manual', + }); + if (response.status !== 200) throw new Error('Packaged Connect transport evidence was unavailable'); + const candidate: unknown = await response.json(); + if (candidate !== null && typeof candidate === 'object') { + const record = candidate as Record; + if (Number.isInteger(record.authenticatedRest) && Number.isInteger(record.authenticatedSockets)) { + transportEvidence = { + authenticatedRest: record.authenticatedRest as number, + authenticatedSockets: record.authenticatedSockets as number, + }; + } + } + if (transportEvidence.authenticatedRest >= requiredAuthenticatedRequests + && transportEvidence.authenticatedSockets >= requiredAuthenticatedRequests) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (Date.now() < evidenceDeadline); + if (transportEvidence.authenticatedRest < requiredAuthenticatedRequests + || transportEvidence.authenticatedSockets < requiredAuthenticatedRequests) { + throw new Error('Packaged Connect authenticated transport proof timed out'); + } + await waitForApprovalIdle(); + reportPackagedConnectJourneyStage(phase === 'pair' + ? 'JOURNEY_PAIR_COMPLETE' + : 'JOURNEY_REPROBE_COMPLETE'); +}; + +const runPackagedTransportSmoke = async ( + window: BrowserWindow, + profiles: ProfileStore, + credentials: DesktopCredentialService, + smoke: PackagedTransportSmoke, +): Promise => { + const profileId = 'packaged-transport-smoke'; + const tokenA = `propr_it_${randomBytes(32).toString('base64url')}`; + const tokenB = `propr_it_${randomBytes(32).toString('base64url')}`; + const security = profiles.security(); + if (!security.available || security.backend === 'basic_text') { + throw new Error('Packaged transport smoke requires the production OS credential backend'); + } + const profileA = await profiles.save({ + id: profileId, label: 'Packaged transport A', apiBaseUrl: smoke.firstOrigin, + }); + const storedA = await profiles.writeCredential({ + version: 2, profileId, origin: smoke.firstOrigin, + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', token: tokenA, + }); + if (!storedA.stored) throw new Error('Production credential encryption was unavailable'); + + const storageWindows = await Promise.all([smoke.firstOrigin, smoke.secondOrigin].map(async origin => { + const storageWindow = new BrowserWindow({ + show: false, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }, + }); + await storageWindow.loadURL(`${origin}/smoke-storage`); + return { origin, window: storageWindow }; + })); + const seedStorage = async (): Promise => { + await Promise.all(storageWindows.map(item => item.window.webContents.executeJavaScript(`(async () => { + document.cookie = 'packaged-smoke-cookie=present; SameSite=Lax'; + localStorage.setItem('packaged-smoke-local', 'present'); + await new Promise((resolve, reject) => { + const request = indexedDB.open('packaged-smoke-indexeddb', 1); + request.onupgradeneeded = () => request.result.createObjectStore('proof'); + request.onsuccess = () => { request.result.close(); resolve(true); }; + request.onerror = () => reject(request.error); + }); + const cache = await caches.open('packaged-smoke-cache'); + await cache.put('/packaged-smoke-cache-entry', new Response('present')); + await navigator.serviceWorker.register('/smoke-sw.js'); + await navigator.serviceWorker.ready; + return true; + })()`))); + }; + const storageState = async (expected: 'present' | 'absent'): Promise => { + const states = await Promise.all(storageWindows.map(async item => { + const rendererState = await item.window.webContents.executeJavaScript(`(async () => ({ + cookie: document.cookie.includes('packaged-smoke-cookie=present'), + localStorage: localStorage.getItem('packaged-smoke-local') === 'present', + indexedDB: (await indexedDB.databases()).some(database => database.name === 'packaged-smoke-indexeddb'), + cacheStorage: (await caches.keys()).includes('packaged-smoke-cache'), + serviceWorker: (await navigator.serviceWorker.getRegistrations()).some(registration => registration.scope.startsWith(location.origin)), + }))()`); + const cookies = await session.defaultSession.cookies.get({ url: item.origin }); + return { ...rendererState, cookie: rendererState.cookie || cookies.length > 0 } as Record; + })); + return states.every(state => Object.values(state).every(value => value === (expected === 'present'))); + }; + + try { + await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => { + if (window.__proprPackagedTransportSmoke) return resolve(true); + if (Date.now() - started > 5000) return reject(new Error('Packaged renderer smoke harness timed out')); + setTimeout(poll, 20); + }; + poll(); + })`); + const profileForRendererA = { id: profileId, name: profileA.label, baseUrl: smoke.firstOrigin, kind: 'local' }; + const first = await window.webContents.executeJavaScript(`(async () => { + const smoke = window.__proprPackagedTransportSmoke; + const first = await smoke.activate(${JSON.stringify(profileForRendererA)}); + await smoke.rest(); + const socketId = await smoke.connectSocket(); + const rotated = await smoke.activate(${JSON.stringify(profileForRendererA)}); + let staleRestRejected = false; + try { + const response = await fetch(${JSON.stringify(smoke.firstOrigin + '/api/smoke/rest')}, { + headers: { ${JSON.stringify(DESKTOP_TRANSPORT_SCOPE_HEADER)}: first.transportScope }, + credentials: 'include', + }); + staleRestRejected = !response.ok; + } catch { staleRestRejected = true; } + await smoke.expectSocketRejected(socketId); + await smoke.rest(); + localStorage.setItem('packaged-smoke-local', 'non-secret sentinel'); + sessionStorage.setItem('packaged-smoke-session', 'non-secret sentinel'); + return { first, rotated, socketId, staleRestRejected, rendererOrigin: location.origin }; + })()`); + if (first?.rendererOrigin !== DESKTOP_RENDERER_ORIGIN || first?.first?.profileId !== profileId + || first?.first?.transportScope === first?.rotated?.transportScope + || first?.first?.contractsContainSecret !== false || first?.rotated?.contractsContainSecret !== false + || first?.staleRestRejected !== true) { + throw new Error('Packaged renderer protocol or A transport smoke proof failed'); + } + await seedStorage(); + if (!await storageState('present')) throw new Error('Packaged origin storage fixture was incomplete'); + + let cleanupFailed = false; + try { + await credentials.saveProfile({ + id: profileId, label: 'Packaged transport B', apiBaseUrl: smoke.secondOrigin, + }, async () => { throw new Error('packaged cleanup failure'); }); + } catch (error) { + cleanupFailed = error instanceof Error && error.message === 'packaged cleanup failure'; + } + const rollback = await profiles.readProfileCredential(profileId); + if (!cleanupFailed || rollback.profile?.apiBaseUrl !== smoke.firstOrigin + || rollback.credential?.origin !== smoke.firstOrigin || rollback.credential.token !== tokenA + || !await storageState('present')) { + throw new Error('Origin cleanup failure did not preserve complete durable A'); + } + let precommitStorageCleared = false; + await credentials.saveProfile({ + id: profileId, label: 'Packaged transport B', apiBaseUrl: smoke.secondOrigin, + }, async (previousOrigin, nextOrigin) => { + await clearDesktopInstanceCookies(session.defaultSession, [previousOrigin, nextOrigin]); + precommitStorageCleared = await storageState('absent'); + if (!precommitStorageCleared) throw new Error('Complete origin storage was not cleared before commit'); + }); + if (!precommitStorageCleared || !await storageState('absent')) { + throw new Error('Same-ID URL edit did not clear both complete Electron origin stores'); + } + const storedB = await profiles.writeCredential({ + version: 2, profileId, origin: smoke.secondOrigin, + publicInstanceIdentity: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', token: tokenB, + }); + if (!storedB.stored) throw new Error('Replacement credential encryption was unavailable'); + + const profileForRendererB = { id: profileId, name: 'Packaged transport B', baseUrl: smoke.secondOrigin, kind: 'local' }; + const second = await window.webContents.executeJavaScript(`(async () => { + const smoke = window.__proprPackagedTransportSmoke; + const activated = await smoke.activate(${JSON.stringify(profileForRendererB)}); + const socketId = await smoke.connectSocket(); + await smoke.reconnectSocket(socketId); + const staleClassification = await smoke.handleStaleInvalidation( + ${JSON.stringify(profileId)}, ${JSON.stringify(first.rotated.transportScope)} + ); + smoke.disconnectSocket(${JSON.stringify(first.socketId)}); + await smoke.rest(); + const persisted = await window.proprDesktop.profiles.list(); + const rendererEvidence = smoke.rendererEvidence(); + return { + activated, + staleClassification, + persisted, + rendererEvidence, + rendererPersistenceContainsSecret: JSON.stringify([persisted, rendererEvidence]).includes('propr_it_'), + }; + })()`); + const secretInMainMetadata = [tokenA, tokenB].some(secret => + process.argv.some(argument => argument.includes(secret)) + || JSON.stringify(crashReporter.getParameters()).includes(secret)); + if (second?.staleClassification !== 'retryable' || second?.activated?.profileId !== profileId + || second?.activated?.contractsContainSecret !== false + || second?.rendererPersistenceContainsSecret !== false + || secretInMainMetadata) { + throw new Error('Packaged replacement scope or secret-custody smoke proof failed'); + } + log('info', 'desktop.renderer.transport_smoke.ready', { + customProtocol: true, + restBearer: true, + socketIo: true, + engineIoHandshake: true, + namespaceAuthentication: true, + reconnectAndErrorHandling: true, + scopeRotation: true, + allOriginStorageCleared: true, + cleanupRollbackAndRetry: true, + staleScopeRejected: true, + secretCustody: true, + productionCredentialRoundTrip: true, + storageBackend: security.backend, + }); + } finally { + for (const item of storageWindows) { + if (!item.window.isDestroyed()) item.window.destroy(); + } + } +}; + +const createMainWindow = async ( + transportSmoke: PackagedTransportSmoke | null = activePackagedTransportSmoke, + connectJourney = activePackagedConnectJourney, +): Promise => { + const workArea = selectInitialWindowWorkArea(screen); + const window = new BrowserWindow( + createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged, workArea), + ); + const readyToShow = new Promise(resolveReady => window.once('ready-to-show', resolveReady)); + + window.webContents.setWindowOpenHandler(({ url }) => { + void openAllowedExternalUrl(url); + return { action: 'deny' }; + }); + window.webContents.on('will-navigate', (event, url) => { + if (isTrustedRendererUrl(url, devServerUrl, packagedRendererUrl)) return; + event.preventDefault(); + void openAllowedExternalUrl(url); + }); + window.webContents.on('will-attach-webview', (event) => event.preventDefault()); + window.webContents.on('render-process-gone', (_event, details) => { + log('error', 'desktop.renderer.gone', { reason: details.reason, exitCode: details.exitCode }); + }); + window.webContents.on('did-finish-load', () => { + deepLinkDelivery.didFinishLoad(window); + }); + window.on('closed', () => { + deepLinkDelivery.clearWindow(window); + if (mainWindow === window) { + mainWindow = null; + } + }); + + const validatedDevUrl = validatedDevServerUrl(devServerUrl); + if (devServerUrl && !validatedDevUrl) throw new Error('Electron Forge supplied an unsafe renderer development URL'); + if (validatedDevUrl) { + await window.loadURL(new URL('renderer.html', validatedDevUrl).href); + } else { + const rendererUrl = new URL(packagedRendererUrl); + if (transportSmoke) rendererUrl.hash = 'packaged-transport-smoke'; + await window.loadURL(rendererUrl.href); + } + + await readyToShow; + const preloadBridgeExposed = await window.webContents.executeJavaScript( + "typeof window.proprDesktop === 'object' && window.proprDesktop !== null", + ); + if (preloadBridgeExposed !== true) { + throw new Error('Desktop preload bridge was not exposed to the renderer'); + } + deepLinkDelivery.setWindow(window); + const smokeProfileApiUrl = process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; + if (packagedSmokeTest && !transportSmoke && smokeProfileApiUrl) { + const normalizedSmokeApiUrl = normalizeApiBaseUrl(smokeProfileApiUrl); + if (!normalizedSmokeApiUrl || normalizedSmokeApiUrl !== smokeProfileApiUrl) { + throw new Error('Packaged desktop smoke profile API URL is invalid'); + } + const endpoints = [ + `${normalizedSmokeApiUrl}/api/compatibility`, + `${normalizedSmokeApiUrl}/api/desktop/discovery`, + ]; + const result = await window.webContents.executeJavaScript(`(async () => { + const results = []; + for (const endpoint of ${JSON.stringify(endpoints)}) { + const response = await fetch(endpoint, { credentials: 'include' }); + results.push({ ok: response.ok, status: response.status, body: await response.json() }); + } + return results; + })()`); + if (result?.[0]?.ok !== true || result[0]?.body?.profileEndpoint !== true + || result?.[1]?.ok !== true || result[1]?.body?.product !== 'ProPR' + || result[1]?.body?.desktopAuthentication?.protocolVersion !== 1) { + throw new Error('Packaged renderer profile API or ProPR Connect discovery request failed'); + } + log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); + } + let mvpFlowProof: Record = { connectDiscovery: true }; + if (packagedSmokeTest && !transportSmoke && !connectJourney) { + const profileFlow = await window.webContents.executeJavaScript(`(async () => { + const bridge = window.proprDesktop; + const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); + const remote = await bridge.profiles.save({ label: 'ProPR Connect', apiBaseUrl: 'https://connect.propr.dev' }); + await bridge.profiles.setActive(remote.id); + const profiles = await bridge.profiles.list(); + const lifecycle = await bridge.lifecycle.start(); + const deadline = performance.now() + 2000; + let connectDeepLink = false; + do { + const labels = Array.from(document.querySelectorAll('.desktop-welcome-card form > label')); + connectDeepLink = labels[1]?.querySelector('input')?.value === 'https://connect.propr.dev'; + if (connectDeepLink) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return { + active: profiles.activeProfileId === remote.id, + local: profiles.profiles.some(profile => profile.id === local.id && profile.apiBaseUrl === 'http://localhost:4000'), + remote: profiles.profiles.some(profile => profile.id === remote.id && profile.apiBaseUrl === 'https://connect.propr.dev'), + lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', + connectDeepLink, + }; + })()`); + if (!profileFlow?.active || !profileFlow?.local || !profileFlow?.remote + || !profileFlow?.lifecycleBoundary || !profileFlow?.connectDeepLink) { + throw new Error('Packaged desktop local/remote/API profile flow failed'); + } + mvpFlowProof = { + connectDiscovery: true, + localProfile: profileFlow.local, + remoteActiveProfile: profileFlow.active && profileFlow.remote, + lifecycleBoundary: profileFlow.lifecycleBoundary, + connectUiPopulated: profileFlow.connectDeepLink, + }; + await closePackagedProfileEditorAndWaitForWelcomeChooser(window); + } else if (packagedSmokeTest) { + const boundary = await window.webContents.executeJavaScript(`(async () => { + const bridge = window.proprDesktop; + const metadata = await bridge.app.getMetadata(); + const profiles = await bridge.profiles.list(); + const lifecycle = await bridge.lifecycle.start(); + return { + packaged: metadata.packaged, + profiles: Array.isArray(profiles.profiles), + lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', + }; + })()`); + if (!boundary?.packaged || !boundary?.profiles || !boundary?.lifecycleBoundary) { + throw new Error('Packaged desktop transport smoke did not preserve the MVP bridge boundaries'); + } + } + if (packagedSmokeTest && !connectJourney) { + log('info', 'desktop.renderer.mvp_flows.ready', mvpFlowProof); + log('info', PACKAGED_LAYOUT_READY_EVENT, { layout: await inspectPackagedLayout(window) }); + log('info', PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT, { + layout: inspectPackagedReducedNativeWindow(), + }); + } + log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); + return window; +}; + +app.on('open-url', (event, url) => { + event.preventDefault(); + if (shutdownStarted) return; + const normalized = normalizeDeepLink(url); + if (normalized) deliverDeepLink(normalized); +}); + +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', (_event, argv) => { + if (shutdownStarted) return; + const deepLink = deepLinkFromArguments(argv); + if (deepLink) deliverDeepLink(deepLink); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + + registerProtocolClient(); + void app.whenReady().then(async () => { + logger = createDesktopLogger( + join(app.getPath('logs'), 'desktop.jsonl'), + () => packagedSmokeEvidence?.write('desktop.log.write_failed'), + ); + log('info', 'desktop.app.ready', { version: app.getVersion(), platform: process.platform }); + const transportSmoke = packagedTransportSmoke(); + activePackagedTransportSmoke = transportSmoke; + const connectSmoke = packagedConnectSmoke(); + activePackagedConnectJourney = Boolean(connectSmoke?.journeyEndpoint); + packagedConnectJourneyDiagnosticState = connectSmoke?.journeyEndpoint && connectSmoke.journeyPhase + ? { phase: connectSmoke.journeyPhase, stage: 'JOURNEY_NOT_STARTED' } + : null; + if (transportSmoke && connectSmoke) throw new Error('Packaged desktop smoke modes are mutually exclusive'); + const smokeProfileOrigin = packagedSmokeTest + ? normalizeApiBaseUrl(process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL ?? '') + : null; + let rendererPolicyOrigins: readonly string[] = transportSmoke + ? [transportSmoke.firstOrigin, transportSmoke.secondOrigin] + : connectSmoke?.journeyEndpoint + ? [connectSmoke.journeyEndpoint] + : smokeProfileOrigin + ? [smokeProfileOrigin] + : []; + const rendererPolicyPinnedForSmoke = transportSmoke !== null + || connectSmoke?.journeyEndpoint !== undefined + || (packagedSmokeTest && smokeProfileOrigin !== null); + const reloadCurrentRendererForPolicyChange = createLatestRendererReloader( + () => mainWindow?.webContents ?? null, + ); + const contentSecurityPolicy = (): string => rendererContentSecurityPolicy( + !app.isPackaged, + rendererPolicyOrigins, + ); + const disposeRendererProtocol = configurePackagedRendererProtocol(contentSecurityPolicy); + const journeyStages = connectSmoke?.journeyPhase + ? createPackagedJourneyStageTracker(connectSmoke.journeyPhase) + : null; + + const productionEncryption: EncryptionProvider = { + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), + backend: () => { + if (process.platform !== 'linux') return 'os-protected'; + try { + return safeStorage.getSelectedStorageBackend(); + } catch { + return 'unavailable'; + } + }, + encrypt: value => safeStorage.encryptString(value), + decrypt: value => safeStorage.decryptString(value), + }; + const profiles = new ProfileStore(app.getPath('userData'), productionEncryption); + const connectDiscovery = new DesktopConnectDiscoveryService(profiles, { + supported: DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(process.platform), + discover: async () => { + const status = await discoverConfiguredConnect({ + configRoot: connectSmoke?.configRoot ?? join(app.getPath('home'), '.propr'), + statusDependencies: connectSmoke ? { + fetchImpl: connectSmoke.fetch, + inspectTunnel: () => ({ kind: 'ok', running: true }), + } : undefined, + reportSmokeDiagnostic: connectSmoke + ? diagnostic => log('info', 'desktop.renderer.connect_discovery.phase', { + phase: diagnostic.phase, + code: diagnostic.code, + ...(diagnostic.substep ? { substep: diagnostic.substep } : {}), + ...(diagnostic.category ? { category: diagnostic.category } : {}), + }) + : undefined, + }); + if (connectSmoke) { + const statusCode = { + incompatible: 'CONNECT_STATUS_INCOMPATIBLE', + internalFailure: 'CONNECT_STATUS_INTERNAL_FAILURE', + invalidConfig: 'CONNECT_STATUS_INVALID_CONFIG', + notReady: 'CONNECT_STATUS_NOT_READY', + ready: 'CONNECT_STATUS_READY', + timeout: 'CONNECT_STATUS_TIMEOUT', + }[status.status]; + log('info', 'desktop.renderer.connect_discovery.status', { code: statusCode }); + } + return status; + }, + }); + const packagedJourneyApprovals = connectSmoke?.journeyEndpoint + ? createPackagedApprovalTaskTracker(openPackagedJourneyApproval) + : null; + const credentials = new DesktopCredentialService({ + profiles, + fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, + openPairingBrowser: packagedJourneyApprovals + ? packagedJourneyApprovals.open + : request => openApprovedDesktopPairingUrl(request, shell), + clientName: `ProPR Desktop (${process.platform})`, + reportRevocationFailure: diagnostic => { + log('warn', 'desktop.credential_revocation.retry_pending', diagnostic); + }, + snapshotConnectIdentityClaim: (profileId, origin) => + connectDiscovery.snapshotIdentityClaim(profileId, origin), + }); + const sessionSecurity = configureDesktopSessionSecurity({ + contentSecurityPolicy, + credentials, + desktopSession: session.defaultSession, + enableRendererNetworkBoundary: process.platform !== 'win32', + getMainRenderer: () => mainWindow?.webContents ?? null, + isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl), + ...(connectSmoke?.journeyEndpoint ? { + reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => { + log('info', 'desktop.renderer.connect_network_permission', { ...evidence }); + }, + reportRendererOwnershipDecision: (evidence: DesktopRendererOwnershipEvidence) => { + log('info', PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT, { ...evidence }); + }, + } : {}), + }); + const credentialInitialization = await credentials.initialize(); + if (credentialInitialization.status === 'degraded') { + log('warn', 'desktop.credential_revocation.startup_degraded', { + retryPending: credentialInitialization.retryPending, + }); + } + if (app.isPackaged && !rendererPolicyPinnedForSmoke) { + const current = await credentials.listProfiles(); + const activeOrigin = current.profiles + .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl; + rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : []; + } + const lifecycle = new LocalLifecycleController(); + const registeredIpc = registerIpcHandlers({ + app, + ipcMain, + profiles, + credentials, + connectDiscovery, + lifecycle, + logger, + desktopSession: session.defaultSession, + devServerUrl, + packagedRendererUrl, + openExternal: openAllowedExternalUrl, + ...(app.isPackaged && !rendererPolicyPinnedForSmoke ? { + onRendererActiveProfileChanged: (origin: string | null) => { + const nextOrigins = origin?.startsWith('http://') ? [origin] : []; + if (rendererPolicyOrigins.length === nextOrigins.length + && rendererPolicyOrigins.every((value, index) => value === nextOrigins[index])) return; + rendererPolicyOrigins = nextOrigins; + // The next document receives the exact policy in both its meta tag and + // response header. Until then, the existing CSP and request boundary + // both fail closed for the new active endpoint. + reloadCurrentRendererForPolicyChange(); + }, + } : {}), + ...(journeyStages ? { + reportAcceptanceJourneyStage: (stage: DesktopAcceptanceJourneyStage) => { + journeyStages.record(stage); + }, + reportAcceptanceOperation: ( + operation: DesktopAcceptanceOperation, + status: DesktopAcceptanceOperationStatus, + ) => { + log('info', PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT, { operation, status }); + }, + } : {}), + }); + const shutdownLifecycle = transportSmoke?.shutdownMode === 'forced-timeout' + ? { shutdown: () => new Promise(() => undefined) } + : lifecycle; + const shutdown = createDesktopShutdownCoordinator({ + credentials, + lifecycle: shutdownLifecycle, + ipc: registeredIpc, + profiles, + sessionSecurity, + disposeRendererProtocol, + getWindow: () => mainWindow, + quit: () => app.quit(), + onStarted: () => { shutdownStarted = true; }, + log, + }, transportSmoke?.shutdownMode === 'forced-timeout' ? { drainTimeoutMs: 250 } : undefined); + app.on('before-quit', event => shutdown.beforeQuit(event)); + + mainWindow = await createMainWindow(); + + if (connectSmoke) { + reportPackagedConnectJourneyStage('JOURNEY_DISCOVERY_RENDERER'); + const readyFields = await runPackagedConnectDiscoverySmoke(mainWindow); + if (connectSmoke.journeyEndpoint && connectSmoke.journeyPhase) { + if (!journeyStages || !packagedJourneyApprovals) { + throw new Error('Packaged Connect journey stage tracker was unavailable'); + } + await runPackagedConnectJourneySmoke( + mainWindow, + profiles, + credentials, + packagedJourneyApprovals.waitForNextOpen, + packagedJourneyApprovals.waitForIdle, + connectSmoke.journeyEndpoint, + connectSmoke.journeyPhase, + journeyStages, + ); + } + await publishPackagedConnectReady(readyFields); + packagedConnectJourneyDiagnosticState = null; + app.quit(); + } else if (transportSmoke) { + await runPackagedTransportSmoke(mainWindow, profiles, credentials, transportSmoke); + app.quit(); + if (transportSmoke.shutdownMode === 'retry') { + log('info', 'desktop.app.shutdown_retry_requested'); + app.quit(); + } + } else if (packagedSmokeTest) { + app.quit(); + } else { + mainWindow.show(); + } + + const updateConfig = __PROPR_DESKTOP_UPDATE_MANIFEST_URL__ + ? { + manifestUrl: __PROPR_DESKTOP_UPDATE_MANIFEST_URL__, + publicKey: __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__, + signingIdentity: __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__, + windowsSignerPins: __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__, + } + : undefined; + if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest) { + const runUpdateCheck = () => { + void checkForSignedUpdates({ + config: updateConfig, + currentVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + request: (url, init) => net.fetch(url, init), + cacheDirectory: join(app.getPath('userData'), 'verified-updates'), + }).then(result => log('info', 'desktop.update.check_complete', { result })) + .catch(() => log('error', 'desktop.update.check_failed')); + }; + runUpdateCheck(); + } + + app.on('activate', () => { + if (shutdownStarted) return; + if (BrowserWindow.getAllWindows().length === 0) { + void createMainWindow(null).then(window => { + mainWindow = window; + }); + } + }); + }).catch(error => { + if (packagedConnectJourneyDiagnosticState) { + log('error', PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT, { + phase: packagedConnectJourneyDiagnosticState.phase, + stage: packagedConnectJourneyDiagnosticState.stage, + reason: packagedConnectJourneyFailureReason(error), + }); + } + log('error', 'desktop.app.start_failed', { error }); + app.exit(1); + }); +} + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +app.on('will-quit', () => { + packagedSmokeEvidence?.close(); + packagedSmokeEvidence = null; +}); diff --git a/apps/desktop/src/native/propr-windows-authority.cs b/apps/desktop/src/native/propr-windows-authority.cs new file mode 100644 index 000000000..aa1252228 --- /dev/null +++ b/apps/desktop/src/native/propr-windows-authority.cs @@ -0,0 +1,1066 @@ +// Strict UTF-8 source; the build gate rejects invalid byte sequences. +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; +using System.Text; +using System.Web.Script.Serialization; +using Microsoft.Win32.SafeHandles; + +public sealed class BrokerFailure : Exception { + public readonly string Code; + public readonly int Scenario; + public BrokerFailure(string code, int scenario) : base(code) { Code = code; Scenario = scenario; } +} + +public sealed class InspectionResult { + public int version = 1; + public string type = "inspection"; + public string volumeSerial; + public string fileId128; + public bool directory; + public string links; + public string size; + public string reparseTag; + public string ownerSid; + public bool daclProtected; + public string aceCount; + public string inheritedWriteAces; + public string broadWriteAces; + public string sha256; + public string sha1; +} + +public sealed class SecurityResult { + public string ownerSid; + public bool daclProtected; + public int aceCount; +} + +public static class ProprUpdateAuthority { + const uint DELETE = 0x00010000; + const uint READ_CONTROL = 0x00020000; + const uint GENERIC_READ = 0x80000000; + const uint FILE_READ_ATTRIBUTES = 0x00000080; + const uint FILE_SHARE_READ = 0x00000001; + const uint FILE_SHARE_WRITE = 0x00000002; + const uint FILE_SHARE_DELETE = 0x00000004; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + const uint ERROR_SHARING_VIOLATION = 32; + const uint FILE_BEGIN = 0; + const int FileStandardInfo = 1; + const int FileAttributeTagInfo = 9; + const int FileIdInfo = 18; + const int SE_FILE_OBJECT = 1; + const int OWNER_SECURITY_INFORMATION = 0x00000001; + const int DACL_SECURITY_INFORMATION = 0x00000004; + const int WRITE_AUTHORITY = unchecked((int)0x500D0156); + const int MAX_SECURITY_DESCRIPTOR = 65536; + const int MAX_READ = 1048576; + const int MAX_REQUEST = 16384; + const int MAX_JSON = 2097152; + const int MAX_FRAMES = 8192; + const long MAX_INPUT = 67108864L; + static readonly string CURRENT_USER_SID = WindowsIdentity.GetCurrent(TokenAccessLevels.Query).User.Value; + static readonly UTF8Encoding STRICT_UTF8 = new UTF8Encoding(false, true); + static readonly JavaScriptSerializer JSON = new JavaScriptSerializer { MaxJsonLength = MAX_JSON }; + static readonly Stream OUTPUT = Console.OpenStandardOutput(); + static SafeFileHandle IMAGE_LEASE; + static string IMAGE_VOLUME; + static string IMAGE_FILE_ID; + static string IMAGE_SHA256; + + [StructLayout(LayoutKind.Sequential)] + struct FILE_STANDARD_INFO { + public long AllocationSize; + public long EndOfFile; + public uint NumberOfLinks; + [MarshalAs(UnmanagedType.U1)] public bool DeletePending; + [MarshalAs(UnmanagedType.U1)] public bool Directory; + } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ATTRIBUTE_TAG_INFO { public uint FileAttributes; public uint ReparseTag; } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ID_INFO { + public ulong VolumeSerialNumber; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] FileId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle handle, int infoClass, + IntPtr information, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetFilePointerEx(SafeFileHandle handle, long distance, out long position, uint method); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool ReadFile(SafeFileHandle handle, byte[] buffer, uint requested, out uint read, IntPtr overlapped); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern uint GetSecurityInfo(SafeFileHandle handle, int objectType, int securityInfo, + out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor); + + [DllImport("kernel32.dll")] + static extern IntPtr LocalFree(IntPtr memory); + + [DllImport("wintrust.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + static extern int WinVerifyTrust(IntPtr window, [In] ref Guid action, IntPtr data); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct WINTRUST_FILE_INFO { + public uint cbStruct; + public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct WINTRUST_DATA { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public string pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [DllImport("advapi32.dll")] + static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + + static T ReadInfo(SafeFileHandle handle, int infoClass, string code, int scenario) where T : struct { + int size = Marshal.SizeOf(typeof(T)); + IntPtr memory = Marshal.AllocHGlobal(size); + try { + if (!GetFileInformationByHandleEx(handle, infoClass, memory, (uint)size)) { + throw new BrokerFailure(code, scenario); + } + return (T)Marshal.PtrToStructure(memory, typeof(T)); + } finally { Marshal.FreeHGlobal(memory); } + } + + static SecurityResult VerifySecurity(SafeFileHandle handle) { + IntPtr owner, group, dacl, sacl, descriptor; + uint error = GetSecurityInfo(handle, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + out owner, out group, out dacl, out sacl, out descriptor); + if (error != 0 || descriptor == IntPtr.Zero) throw new BrokerFailure("owner_sid", 6); + try { + int length = checked((int)GetSecurityDescriptorLength(descriptor)); + if (length <= 0 || length > MAX_SECURITY_DESCRIPTOR) throw new BrokerFailure("owner_sid", 6); + byte[] bytes = new byte[length]; + Marshal.Copy(descriptor, bytes, 0, length); + RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0); + SecurityIdentifier current = new SecurityIdentifier(CURRENT_USER_SID); + if (security.Owner == null || !security.Owner.Equals(current)) { + throw new BrokerFailure("owner_sid", 6); + } + if ((security.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0 + || security.DiscretionaryAcl == null) { + throw new BrokerFailure("dacl_protection", 7); + } + SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + SecurityIdentifier administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + int aceCount = 0; + int priorOrder = -1; + foreach (GenericAce generic in security.DiscretionaryAcl) { + aceCount++; + if ((generic.AceFlags & AceFlags.Inherited) != 0) throw new BrokerFailure("dacl_ace", 8); + QualifiedAce qualified = generic as QualifiedAce; + KnownAce known = generic as KnownAce; + if (qualified == null || known == null || known.SecurityIdentifier == null + || (qualified.AceQualifier != AceQualifier.AccessAllowed + && qualified.AceQualifier != AceQualifier.AccessDenied)) { + throw new BrokerFailure("dacl_ace", 8); + } + bool allowed = qualified.AceQualifier == AceQualifier.AccessAllowed; + int order = allowed ? 1 : 0; + if (order < priorOrder) throw new BrokerFailure("dacl_ace", 8); + priorOrder = order; + SecurityIdentifier sid = known.SecurityIdentifier; + bool trusted = sid != null && (sid.Equals(current) || sid.Equals(system) || sid.Equals(administrators)); + if (allowed && !trusted && (known.AccessMask & WRITE_AUTHORITY) != 0) { + throw new BrokerFailure("dacl_ace", 8); + } + } + return new SecurityResult { ownerSid = current.Value, daclProtected = true, aceCount = aceCount }; + } finally { LocalFree(descriptor); } + } + + static SafeFileHandle OpenPinned(string path, bool readBytes) { + uint access = READ_CONTROL | FILE_READ_ATTRIBUTES | (readBytes ? GENERIC_READ : 0); + SafeFileHandle handle = CreateFileW(path, access, FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) { + handle.Dispose(); + throw new BrokerFailure("open_handle", 2); + } + return handle; + } + + static void ProveNoShareLock(string path) { + SafeFileHandle competing = CreateFileW(path, DELETE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (!competing.IsInvalid) { + competing.Dispose(); + throw new BrokerFailure("no_share_lock", 10); + } + int error = Marshal.GetLastWin32Error(); + competing.Dispose(); + if ((uint)error != ERROR_SHARING_VIOLATION) throw new BrokerFailure("no_share_lock", 10); + } + + static byte[] ReadAt(SafeFileHandle handle, long offset, int length, string code, int scenario) { + long position; + if (!SetFilePointerEx(handle, offset, out position, FILE_BEGIN) || position != offset) { + throw new BrokerFailure(code, scenario); + } + byte[] bytes = new byte[length]; + int total = 0; + while (total < length) { + byte[] chunk = new byte[length - total]; + uint count; + if (!ReadFile(handle, chunk, (uint)chunk.Length, out count, IntPtr.Zero) || count == 0) { + throw new BrokerFailure(code, scenario); + } + Buffer.BlockCopy(chunk, 0, bytes, total, (int)count); + total += (int)count; + } + return bytes; + } + + static string[] Hash(SafeFileHandle handle, long size) { + using (SHA256 sha256 = SHA256.Create()) + using (SHA1 sha1 = SHA1.Create()) { + byte[] chunk = new byte[Math.Min(MAX_READ, (int)Math.Min(size, MAX_READ))]; + long offset = 0; + while (offset < size) { + int length = (int)Math.Min(chunk.Length, size - offset); + byte[] bytes = ReadAt(handle, offset, length, "hash_read", 11); + sha256.TransformBlock(bytes, 0, bytes.Length, null, 0); + sha1.TransformBlock(bytes, 0, bytes.Length, null, 0); + offset += bytes.Length; + } + sha256.TransformFinalBlock(new byte[0], 0, 0); + sha1.TransformFinalBlock(new byte[0], 0, 0); + return new string[] { + BitConverter.ToString(sha256.Hash).Replace("-", "").ToLowerInvariant(), + BitConverter.ToString(sha1.Hash).Replace("-", "").ToLowerInvariant() + }; + } + } + + static InspectionResult InspectHandle(SafeFileHandle handle, bool expectedDirectory, string purpose, long expectedBytes) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "reparse_query", 3); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("reparse_point", 4); + } + FILE_STANDARD_INFO standard = ReadInfo(handle, FileStandardInfo, "type_link_size", 5); + bool setup = purpose == "setup"; + bool artifact = purpose == "artifact"; + if (standard.DeletePending || standard.Directory != expectedDirectory || (!standard.Directory && standard.NumberOfLinks != 1) + || (standard.Directory && (!setup || expectedBytes != 0)) + || (!standard.Directory && setup && (expectedBytes != 0 || standard.EndOfFile < 0 || standard.EndOfFile > 1073807360L)) + || (!standard.Directory && artifact && (expectedBytes <= 0 || standard.EndOfFile != expectedBytes)) + || (!setup && !artifact)) { + throw new BrokerFailure("type_link_size", 5); + } + SecurityResult security = VerifySecurity(handle); + FILE_ID_INFO identity = ReadInfo(handle, FileIdInfo, "file_id_info", 9); + byte[] fileId = identity.FileId; + if (fileId == null || fileId.Length != 16) throw new BrokerFailure("file_id_info", 9); + InspectionResult result = new InspectionResult { + volumeSerial = identity.VolumeSerialNumber.ToString("x16"), + fileId128 = BitConverter.ToString(fileId).Replace("-", "").ToLowerInvariant(), + directory = standard.Directory, + links = standard.NumberOfLinks.ToString(), + size = standard.EndOfFile.ToString(), + reparseTag = attributes.ReparseTag.ToString("x8"), + ownerSid = security.ownerSid, + daclProtected = security.daclProtected, + aceCount = security.aceCount.ToString(), + inheritedWriteAces = "0", + broadWriteAces = "0" + }; + if (artifact) { + string[] hashes = Hash(handle, standard.EndOfFile); + result.sha256 = hashes[0]; + result.sha1 = hashes[1]; + } + return result; + } + + static bool Same(InspectionResult left, InspectionResult right) { + return left.volumeSerial == right.volumeSerial && left.fileId128 == right.fileId128 + && left.directory == right.directory && left.links == right.links && left.size == right.size + && left.reparseTag == right.reparseTag && left.ownerSid == right.ownerSid + && left.daclProtected == right.daclProtected && left.aceCount == right.aceCount + && left.inheritedWriteAces == right.inheritedWriteAces && left.broadWriteAces == right.broadWriteAces + && left.sha256 == right.sha256 && left.sha1 == right.sha1; + } + + static string PrivateSddl() { + return "O:" + CURRENT_USER_SID + "G:" + CURRENT_USER_SID + "D:P(A;;FA;;;" + CURRENT_USER_SID + + ")(A;;FA;;;SY)(A;;FA;;;BA)"; + } + + public static InspectionResult Inspect(string path, bool expectedDirectory) { + using (SafeFileHandle handle = OpenPinned(path, false)) { + return InspectHandle(handle, expectedDirectory, "setup", 0); + } + } + + public static InspectionResult EnsureDirectory(string path) { + if (!Directory.Exists(path)) { + DirectorySecurity security = new DirectorySecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + new DirectoryInfo(path).Create(security); + } + return Inspect(path, true); + } + + public static InspectionResult ProtectDirectory(string path) { + DirectorySecurity security = new DirectorySecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + Directory.SetAccessControl(path, security); + return Inspect(path, true); + } + + public static InspectionResult ProtectFile(string path) { + FileSecurity security = new FileSecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + File.SetAccessControl(path, security); + return Inspect(path, false); + } + + public sealed class HeldArtifact : IDisposable { + SafeFileHandle handle; + long expectedBytes; + string purpose; + InspectionResult initial; + + public HeldArtifact(string path, long exactBytes, string expectedVolumeSerial, string expectedFileId128, + string purpose, string expectedSha256) { + expectedBytes = exactBytes; + this.purpose = purpose; + handle = OpenPinned(path, true); + try { + initial = InspectHeld(); + if (initial.volumeSerial != expectedVolumeSerial || initial.fileId128 != expectedFileId128) { + throw new BrokerFailure("final_verify", 14); + } + if (purpose == "artifact" && initial.sha256 != expectedSha256) { + throw new BrokerFailure("hash_read", 11); + } + ProveNoShareLock(path); + } catch { + handle.Dispose(); + handle = null; + throw; + } + } + + void RequireOpen() { + if (handle == null || handle.IsClosed || handle.IsInvalid) throw new BrokerFailure("clean_shutdown", 15); + } + + public InspectionResult Initial { get { RequireOpen(); return initial; } } + + InspectionResult InspectHeld() { + InspectionResult result = InspectHandle(handle, false, purpose, expectedBytes); + // Held responses have one stable schema for setup and artifact + // capabilities. Setup policy remains bounded/non-exact, but its exact + // held bytes are still hashed for later same-handle comparisons. + if (purpose == "setup") { + string[] hashes = Hash(handle, Int64.Parse(result.size)); + result.sha256 = hashes[0]; + result.sha1 = hashes[1]; + } + return result; + } + + public byte[] Read(long offset, int length) { + RequireOpen(); + if (offset < 0 || length <= 0 || length > MAX_READ || offset + length > Int64.Parse(initial.size)) { + throw new BrokerFailure("request_protocol", 1); + } + return ReadAt(handle, offset, length, "held_read", 13); + } + + public InspectionResult Verify() { + RequireOpen(); + InspectionResult verified = InspectHeld(); + if (!Same(initial, verified)) throw new BrokerFailure("final_verify", 14); + return verified; + } + + public InspectionResult CloseVerified() { + try { return Verify(); } + finally { Dispose(); } + } + + public void Dispose() { + if (handle == null) return; + handle.Dispose(); + handle = null; + } + } + + public static HeldArtifact OpenHeld(string path, long expectedBytes, string expectedVolumeSerial, string expectedFileId128, + string purpose, string expectedSha256) { + if (expectedBytes < 0 || expectedBytes > 1073741824L || expectedVolumeSerial == null || expectedFileId128 == null + || (purpose != "setup" && purpose != "artifact") + || (purpose == "setup" && expectedBytes != 0) + || (purpose == "artifact" && (expectedBytes == 0 || (expectedSha256 != null && expectedSha256.Length != 64))) + || (purpose == "setup" && expectedSha256 != null)) { + throw new BrokerFailure("request_protocol", 1); + } + return new HeldArtifact(path, expectedBytes, expectedVolumeSerial, expectedFileId128, purpose, expectedSha256); + } + + public static void Smoke() { + string root = Path.Combine(Path.GetTempPath(), "propr-win-authority-smoke-" + Guid.NewGuid().ToString("N")); + HeldArtifact held = null; + try { + EnsureDirectory(root); + string artifact = Path.Combine(root, "smoke.bin"); + File.WriteAllBytes(artifact, new byte[] { 0x50 }); + ProtectFile(artifact); + InspectionResult setup = Inspect(artifact, false); + held = OpenHeld(artifact, 1, setup.volumeSerial, setup.fileId128, "setup", null); + if (held.Read(0, 1)[0] != 0x50) throw new BrokerFailure("held_read", 13); + held.CloseVerified(); + held = null; + File.Delete(artifact); + Directory.Delete(root); + } finally { + if (held != null) held.Dispose(); + try { if (Directory.Exists(root)) Directory.Delete(root, true); } catch { } + } + } + static readonly string[] START_FIELDS = { "version", "type", "challenge", "protocol" }; + static readonly string[] REQUEST_FIELDS = { "version", "type", "id", "operation", "purpose", "path", + "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "challenge", + "barrier", "offset", "length" }; + + static Dictionary Frame(params object[] values) { + Dictionary frame = new Dictionary(); + for (int index = 0; index < values.Length; index += 2) frame[(string)values[index]] = values[index + 1]; + return frame; + } + + static void WriteFrame(Dictionary frame) { + byte[] bytes = STRICT_UTF8.GetBytes(JSON.Serialize(frame)); + if (bytes.Length <= 0 || bytes.Length > MAX_JSON) throw new BrokerFailure("output_bound", 17); + byte[] prefix = new byte[] { + (byte)((bytes.Length >> 24) & 0xff), (byte)((bytes.Length >> 16) & 0xff), + (byte)((bytes.Length >> 8) & 0xff), (byte)(bytes.Length & 0xff) + }; + OUTPUT.Write(prefix, 0, prefix.Length); + OUTPUT.Write(bytes, 0, bytes.Length); + OUTPUT.Flush(); + } + + static void WriteFailure(string code, int scenario, string id) { + Dictionary frame = Frame("version", 1, "type", "error", "reason", code, "scenario", scenario); + if (!String.IsNullOrEmpty(id)) frame["id"] = id; + WriteFrame(frame); + } + + static void WriteInspection(string type, string id, string challenge, InspectionResult value) { + WriteFrame(Frame("version", 1, "type", type, "id", id, "challenge", challenge, + "volumeSerial", value.volumeSerial, "fileId128", value.fileId128, "directory", value.directory, + "links", value.links, "size", value.size, "reparseTag", value.reparseTag, "ownerSid", value.ownerSid, + "daclProtected", value.daclProtected, "aceCount", value.aceCount, + "inheritedWriteAces", value.inheritedWriteAces, "broadWriteAces", value.broadWriteAces, + "sha256", value.sha256, "sha1", value.sha1)); + } + + static bool ExactFields(Dictionary value, string[] fields) { + if (value == null || value.Count != fields.Length) return false; + foreach (string field in fields) if (!value.ContainsKey(field)) return false; + return true; + } + + static bool NullFields(Dictionary value, params string[] fields) { + foreach (string field in fields) if (!value.ContainsKey(field) || value[field] != null) return false; + return true; + } + + static string Text(Dictionary value, string field) { + object item; + return value.TryGetValue(field, out item) && item is string ? (string)item : null; + } + + static bool IsBool(Dictionary value, string field, bool expected) { + object item; + return value.TryGetValue(field, out item) && item is bool && (bool)item == expected; + } + + static long Integer(Dictionary value, string field) { + object item; + if (!value.TryGetValue(field, out item) || item == null) throw new BrokerFailure("request_protocol", 1); + try { return Convert.ToInt64(item); } catch { throw new BrokerFailure("request_protocol", 1); } + } + + static bool Hex(string value, int length) { + if (value == null || value.Length != length) return false; + foreach (char character in value) if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) return false; + return true; + } + + static bool CatalogEvidenceName(string value) { + if (value == null || value.Length < 5 || value.Length > 180 + || !value.EndsWith(".cat", StringComparison.OrdinalIgnoreCase)) return false; + foreach (char character in value) { + if (!((character >= '0' && character <= '9') || (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') || character == '_' || character == '.' + || character == '~' || character == '-')) return false; + } + return true; + } + + static string ReadFrameBounded(Stream input, ref long inputBytes) { + int first = input.ReadByte(); + if (first < 0) return null; + byte[] prefix = new byte[4]; + prefix[0] = (byte)first; + for (int index = 1; index < prefix.Length; index++) { + int next = input.ReadByte(); + if (next < 0) return throwProtocol(); + prefix[index] = (byte)next; + } + int length = (prefix[0] << 24) | (prefix[1] << 16) | (prefix[2] << 8) | prefix[3]; + if (length <= 0 || length > MAX_REQUEST || inputBytes + 4L + length > MAX_INPUT) { + throw new BrokerFailure("output_bound", 17); + } + byte[] bytes = new byte[length]; + int offset = 0; + while (offset < length) { + int read = input.Read(bytes, offset, length - offset); + if (read <= 0) return throwProtocol(); + offset += read; + } + inputBytes += 4L + length; + try { return STRICT_UTF8.GetString(bytes); } + catch { throw new BrokerFailure("request_protocol", 1); } + } + + static string throwProtocol() { throw new BrokerFailure("request_protocol", 1); } + + static Dictionary ReadObject(Stream input, ref long inputBytes) { + string line = ReadFrameBounded(input, ref inputBytes); + if (line == null) return null; + try { return JSON.Deserialize>(line); } + catch { throw new BrokerFailure("request_protocol", 1); } + } + + static BrokerFailure Innermost(Exception error) { + while (error.InnerException != null) error = error.InnerException; + return error as BrokerFailure; + } + + static string[] ManifestPins(Dictionary manifest) { + IList values = manifest["signerPins"] as IList; + if (values == null || values.Count <= 0 || values.Count > 16) throw new BrokerFailure("compile_load", 4); + string[] pins = new string[values.Count]; + string previous = null; + for (int index = 0; index < values.Count; index++) { + string pin = values[index] as string; + bool valid = pin != null && ((pin.StartsWith("certificate-sha256:", StringComparison.Ordinal) + && Hex(pin.Substring(19), 64)) || (pin.StartsWith("spki-sha256:", StringComparison.Ordinal) + && Hex(pin.Substring(12), 64))); + if (!valid || (previous != null && String.CompareOrdinal(previous, pin) >= 0)) { + throw new BrokerFailure("compile_load", 4); + } + pins[index] = pin; + previous = pin; + } + return pins; + } + + static void VerifyCompilerBuildRecord(Dictionary manifest) { + Dictionary compiler = manifest["compiler"] as Dictionary; + string[] fields = { "kind", "framework" }; + if (compiler == null || !ExactFields(compiler, fields) + || Text(compiler, "kind") != "windows-fixed-system-dotnet-framework-csc-v1" + || (Text(compiler, "framework") != "Framework64-v4.0.30319" + && Text(compiler, "framework") != "Framework-v4.0.30319")) throw new BrokerFailure("compile_load", 4); + } + + static void Stage(int index, string name) { + Console.Error.WriteLine("PROPR_BOOTSTRAP " + index.ToString("D2") + " " + name); + Console.Error.Flush(); + } + + static Dictionary ReadManifest(string path) { + byte[] bytes = File.ReadAllBytes(path); + if (bytes.Length <= 0 || bytes.Length > 16384 || bytes[bytes.Length - 1] != 10) { + throw new BrokerFailure("compile_load", 4); + } + string text; + try { text = STRICT_UTF8.GetString(bytes, 0, bytes.Length - 1); } + catch { throw new BrokerFailure("compile_load", 4); } + Dictionary value; + try { value = JSON.Deserialize>(text); } + catch { throw new BrokerFailure("compile_load", 4); } + string[] fields = { "schemaVersion", "name", "format", "architecture", "machine", "clr", "size", "sha256", + "sourceSha256", "protocol", "trust", "publisher", "signerPins", "signerCertificateSha256", + "signerSpkiSha256", "compiler", "bootstrap", "launcher" }; + if (!ExactFields(value, fields) || Integer(value, "schemaVersion") != 1 + || Text(value, "name") != "propr-windows-authority.exe" || Text(value, "format") != "PE32" + || Text(value, "architecture") != "anycpu" || Text(value, "machine") != "I386" + || !IsBool(value, "clr", true) || !Hex(Text(value, "sha256"), 64) + || !Hex(Text(value, "sourceSha256"), 64) || Text(value, "protocol") != "propr-windows-authority-v1" + || (Text(value, "trust") != "unsigned-validation" && Text(value, "trust") != "production-signed")) { + throw new BrokerFailure("compile_load", 4); + } + bool production = Text(value, "trust") == "production-signed"; + if (production) { + string[] pins = ManifestPins(value); + string certificatePin = "certificate-sha256:" + Text(value, "signerCertificateSha256"); + string spkiPin = "spki-sha256:" + Text(value, "signerSpkiSha256"); + if (!Hex(Text(value, "signerCertificateSha256"), 64) || !Hex(Text(value, "signerSpkiSha256"), 64) + || Array.IndexOf(pins, certificatePin) < 0 && Array.IndexOf(pins, spkiPin) < 0 + || String.IsNullOrEmpty(Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + } else if (value["publisher"] != null || value["signerCertificateSha256"] != null + || value["signerSpkiSha256"] != null || !(value["signerPins"] is IList) + || ((IList)value["signerPins"]).Count != 0) throw new BrokerFailure("compile_load", 4); + Dictionary launcher = value["launcher"] as Dictionary; + string[] launcherFields = { "name", "format", "architecture", "machine", "size", "sha256", "trust", + "publisher", "signerPins", "signerCertificateSha256", "signerSpkiSha256" }; + if (!ExactFields(launcher, launcherFields) || Text(launcher, "name") != "propr-windows-launcher.node" + || Text(launcher, "format") != "PE" + || (Text(launcher, "architecture") != "x64" && Text(launcher, "architecture") != "arm64") + || (Text(launcher, "architecture") == "x64" ? Text(launcher, "machine") != "AMD64" + : Text(launcher, "machine") != "ARM64") + || Integer(launcher, "size") <= 0 || Integer(launcher, "size") > 4194304 + || !Hex(Text(launcher, "sha256"), 64) || Text(launcher, "trust") != Text(value, "trust") + || (launcher["publisher"] == null ? value["publisher"] != null + : Text(launcher, "publisher") != Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + Dictionary bootstrap = value["bootstrap"] as Dictionary; + if (bootstrap == null || !ExactFields(bootstrap, launcherFields) || Text(bootstrap, "name") != "propr-windows-bootstrap.node" + || Text(bootstrap, "format") != "PE" || Text(bootstrap, "architecture") != Text(launcher, "architecture") + || Text(bootstrap, "machine") != Text(launcher, "machine") + || Integer(bootstrap, "size") <= 0 || Integer(bootstrap, "size") > 4194304 + || !Hex(Text(bootstrap, "sha256"), 64) || Text(bootstrap, "trust") != Text(value, "trust") + || (bootstrap["publisher"] == null ? value["publisher"] != null + : Text(bootstrap, "publisher") != Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + VerifyCompilerBuildRecord(value); + return value; + } + + static void VerifyImageSecurity(SafeFileHandle handle, bool production) { + IntPtr owner, group, dacl, sacl, descriptor; + uint error = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + out owner, out group, out dacl, out sacl, out descriptor); + if (error != 0 || owner == IntPtr.Zero || dacl == IntPtr.Zero || descriptor == IntPtr.Zero) { + throw new BrokerFailure("compile_load", 6); + } + try { + if (!production) return; + int length = checked((int)GetSecurityDescriptorLength(descriptor)); + if (length <= 0 || length > MAX_SECURITY_DESCRIPTOR) throw new BrokerFailure("compile_load", 6); + byte[] bytes = new byte[length]; + Marshal.Copy(descriptor, bytes, 0, length); + RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0); + SecurityIdentifier current = new SecurityIdentifier(CURRENT_USER_SID); + SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + SecurityIdentifier administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + SecurityIdentifier trustedInstaller = new SecurityIdentifier( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); + bool ownerTrusted = security.Owner != null && (security.Owner.Equals(current) || security.Owner.Equals(system) + || security.Owner.Equals(administrators) || security.Owner.Equals(trustedInstaller)); + if (!ownerTrusted || security.DiscretionaryAcl == null) throw new BrokerFailure("compile_load", 6); + foreach (GenericAce generic in security.DiscretionaryAcl) { + QualifiedAce qualified = generic as QualifiedAce; + KnownAce known = generic as KnownAce; + if (qualified == null || known == null || qualified.AceQualifier != AceQualifier.AccessAllowed) continue; + SecurityIdentifier sid = known.SecurityIdentifier; + bool trusted = sid != null && (sid.Equals(current) || sid.Equals(system) || sid.Equals(administrators) + || sid.Equals(trustedInstaller)); + if (!trusted && (known.AccessMask & WRITE_AUTHORITY) != 0) throw new BrokerFailure("compile_load", 6); + } + } finally { LocalFree(descriptor); } + } + + static void VerifyImageAncestors(string imagePath, bool production) { + string directory = Path.GetDirectoryName(imagePath); + while (!String.IsNullOrEmpty(directory)) { + using (SafeFileHandle handle = OpenPinned(directory, false)) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "compile_load", 7); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("compile_load", 7); + } + VerifyImageSecurity(handle, production); + } + string parent = Path.GetDirectoryName(directory); + if (String.IsNullOrEmpty(parent) || String.Equals(parent, directory, StringComparison.OrdinalIgnoreCase)) break; + directory = parent; + } + } + + static void VerifyAnyCpuPe(SafeFileHandle handle, long size) { + int headerLength = checked((int)Math.Min(size, 65536)); + byte[] bytes = ReadAt(handle, 0, headerLength, "compile_load", 8); + if (bytes.Length < 512 || bytes[0] != 0x4d || bytes[1] != 0x5a) throw new BrokerFailure("compile_load", 8); + int pe = BitConverter.ToInt32(bytes, 0x3c); + if (pe < 0x40 || pe + 248 > bytes.Length || bytes[pe] != 0x50 || bytes[pe + 1] != 0x45 + || bytes[pe + 2] != 0 || bytes[pe + 3] != 0 || BitConverter.ToUInt16(bytes, pe + 4) != 0x14c + || BitConverter.ToUInt16(bytes, pe + 24) != 0x10b) throw new BrokerFailure("compile_load", 8); + int sectionCount = BitConverter.ToUInt16(bytes, pe + 6); + int optionalSize = BitConverter.ToUInt16(bytes, pe + 20); + int clrDirectory = pe + 24 + 96 + (14 * 8); + uint clrRva = BitConverter.ToUInt32(bytes, clrDirectory); + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 || clrDirectory + 8 > pe + 24 + optionalSize + || clrRva == 0 || BitConverter.ToUInt32(bytes, clrDirectory + 4) < 72) { + throw new BrokerFailure("compile_load", 8); + } + int sectionTable = pe + 24 + optionalSize; + int clrOffset = -1; + for (int index = 0; index < sectionCount; index++) { + int section = sectionTable + (index * 40); + if (section + 40 > bytes.Length) throw new BrokerFailure("compile_load", 8); + uint virtualSize = BitConverter.ToUInt32(bytes, section + 8); + uint virtualAddress = BitConverter.ToUInt32(bytes, section + 12); + uint rawSize = BitConverter.ToUInt32(bytes, section + 16); + uint rawAddress = BitConverter.ToUInt32(bytes, section + 20); + uint span = Math.Max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva - virtualAddress < span) { + clrOffset = checked((int)(rawAddress + clrRva - virtualAddress)); + } + } + if (clrOffset < 0 || clrOffset + 20 > bytes.Length) throw new BrokerFailure("compile_load", 8); + uint corFlags = BitConverter.ToUInt32(bytes, clrOffset + 16); + if ((corFlags & 0x1) == 0 || (corFlags & (0x2 | 0x10 | 0x20000)) != 0) throw new BrokerFailure("compile_load", 8); + } + + sealed class DerElement { + public int Start; + public int Content; + public int End; + } + + static DerElement ReadDer(byte[] bytes, ref int offset, int expectedTag) { + int start = offset; + if (offset >= bytes.Length || bytes[offset++] != expectedTag || offset >= bytes.Length) { + throw new BrokerFailure("compile_load", 9); + } + int length = bytes[offset++]; + if ((length & 0x80) != 0) { + int count = length & 0x7f; + if (count <= 0 || count > 4 || offset + count > bytes.Length || bytes[offset] == 0) { + throw new BrokerFailure("compile_load", 9); + } + length = 0; + for (int index = 0; index < count; index++) length = checked((length << 8) | bytes[offset++]); + if (length < 128) throw new BrokerFailure("compile_load", 9); + } + int end = checked(offset + length); + if (end > bytes.Length) throw new BrokerFailure("compile_load", 9); + return new DerElement { Start = start, Content = offset, End = end }; + } + + static byte[] SubjectPublicKeyInfo(X509Certificate2 certificate) { + byte[] raw = certificate.RawData; + int cursor = 0; + DerElement outer = ReadDer(raw, ref cursor, 0x30); + int tbsCursor = outer.Content; + DerElement tbs = ReadDer(raw, ref tbsCursor, 0x30); + int field = tbs.Content; + if (field < tbs.End && raw[field] == 0xa0) ReadDer(raw, ref field, 0xa0); + ReadDer(raw, ref field, 0x02); // serial + ReadDer(raw, ref field, 0x30); // signature algorithm + ReadDer(raw, ref field, 0x30); // issuer + ReadDer(raw, ref field, 0x30); // validity + ReadDer(raw, ref field, 0x30); // subject + DerElement spki = ReadDer(raw, ref field, 0x30); + byte[] result = new byte[spki.End - spki.Start]; + Buffer.BlockCopy(raw, spki.Start, result, 0, result.Length); + return result; + } + + static string Sha256(byte[] bytes) { + using (SHA256 hash = SHA256.Create()) { + return BitConverter.ToString(hash.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); + } + } + + static void VerifyProductionSignature(string imagePath, string publisher, string[] pins, + string expectedCertificateSha256, string expectedSpkiSha256) { + WINTRUST_FILE_INFO file = new WINTRUST_FILE_INFO { + cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_FILE_INFO)), pcwszFilePath = imagePath, + hFile = IntPtr.Zero, pgKnownSubject = IntPtr.Zero + }; + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WINTRUST_FILE_INFO))); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WINTRUST_DATA))); + try { + Marshal.StructureToPtr(file, filePointer, false); + WINTRUST_DATA data = new WINTRUST_DATA { + cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_DATA)), dwUIChoice = 2, fdwRevocationChecks = 1, + dwUnionChoice = 1, pFile = filePointer, dwStateAction = 0, dwProvFlags = 0x00000080, + dwUIContext = 0, pSignatureSettings = IntPtr.Zero + }; + Marshal.StructureToPtr(data, dataPointer, false); + Guid action = new Guid("00AAC56B-CD44-11D0-8CC2-00C04FC295EE"); + if (WinVerifyTrust(new IntPtr(-1), ref action, dataPointer) != 0) throw new BrokerFailure("compile_load", 9); + X509Certificate2 certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(imagePath)); + try { + if (!String.Equals(certificate.Subject, publisher, StringComparison.Ordinal)) throw new BrokerFailure("compile_load", 9); + DateTime now = DateTime.Now; + if (now < certificate.NotBefore || now > certificate.NotAfter) throw new BrokerFailure("compile_load", 9); + bool codeSigning = false; + foreach (X509Extension extension in certificate.Extensions) { + X509EnhancedKeyUsageExtension eku = extension as X509EnhancedKeyUsageExtension; + if (eku == null) continue; + foreach (Oid oid in eku.EnhancedKeyUsages) { + if (oid.Value == "1.3.6.1.5.5.7.3.3") codeSigning = true; + } + } + if (!codeSigning) throw new BrokerFailure("compile_load", 9); + using (X509Chain chain = new X509Chain()) { + chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; + chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(15); + if (!chain.Build(certificate)) throw new BrokerFailure("compile_load", 9); + } + string certificateSha256 = Sha256(certificate.RawData); + string spkiSha256 = Sha256(SubjectPublicKeyInfo(certificate)); + if (certificateSha256 != expectedCertificateSha256 || spkiSha256 != expectedSpkiSha256 + || Array.IndexOf(pins, "certificate-sha256:" + certificateSha256) < 0 + && Array.IndexOf(pins, "spki-sha256:" + spkiSha256) < 0) { + throw new BrokerFailure("compile_load", 9); + } + } finally { certificate.Dispose(); } + } finally { + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + static void AuthenticateImage() { + Stage(4, "MANIFEST"); + string imagePath = Path.GetFullPath(Assembly.GetExecutingAssembly().Location); + if (String.IsNullOrEmpty(imagePath) || imagePath.IndexOf(':', 2) >= 0 + || !String.Equals(Path.GetFileName(imagePath), "propr-windows-authority.exe", StringComparison.OrdinalIgnoreCase)) { + throw new BrokerFailure("compile_load", 4); + } + Dictionary manifest = ReadManifest(Path.Combine(Path.GetDirectoryName(imagePath), + "propr-windows-authority.manifest.json")); + Stage(5, "HELPER_OPEN"); + SafeFileHandle handle = OpenPinned(imagePath, true); + try { + FILE_STANDARD_INFO standard = ReadInfo(handle, FileStandardInfo, "compile_load", 5); + if (standard.DeletePending || standard.Directory || standard.NumberOfLinks != 1 || standard.EndOfFile <= 0 + || standard.EndOfFile != Integer(manifest, "size")) throw new BrokerFailure("compile_load", 5); + Stage(6, "HELPER_OWNER_DACL"); + bool production = Text(manifest, "trust") == "production-signed"; + VerifyImageAncestors(imagePath, production); + VerifyImageSecurity(handle, production); + Stage(7, "HELPER_REPARSE"); + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "compile_load", 7); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("compile_load", 7); + } + Stage(8, "HELPER_IDENTITY"); + FILE_ID_INFO identity = ReadInfo(handle, FileIdInfo, "compile_load", 8); + if (identity.FileId == null || identity.FileId.Length != 16) throw new BrokerFailure("compile_load", 8); + VerifyAnyCpuPe(handle, standard.EndOfFile); + IMAGE_VOLUME = identity.VolumeSerialNumber.ToString("x16"); + IMAGE_FILE_ID = BitConverter.ToString(identity.FileId).Replace("-", "").ToLowerInvariant(); + Stage(9, "HELPER_HASH"); + IMAGE_SHA256 = Hash(handle, standard.EndOfFile)[0]; + if (IMAGE_SHA256 != Text(manifest, "sha256")) throw new BrokerFailure("compile_load", 9); + if (Text(manifest, "trust") == "production-signed") VerifyProductionSignature(imagePath, + Text(manifest, "publisher"), ManifestPins(manifest), Text(manifest, "signerCertificateSha256"), + Text(manifest, "signerSpkiSha256")); + ProveNoShareLock(imagePath); + IMAGE_LEASE = handle; + handle = null; + } finally { if (handle != null) handle.Dispose(); } + } + + static void ReverifyImage() { + FILE_ID_INFO identity = ReadInfo(IMAGE_LEASE, FileIdInfo, "compile_load", 8); + FILE_STANDARD_INFO standard = ReadInfo(IMAGE_LEASE, FileStandardInfo, "compile_load", 8); + string fileId = BitConverter.ToString(identity.FileId).Replace("-", "").ToLowerInvariant(); + string hash = Hash(IMAGE_LEASE, standard.EndOfFile)[0]; + if (identity.VolumeSerialNumber.ToString("x16") != IMAGE_VOLUME || fileId != IMAGE_FILE_ID || hash != IMAGE_SHA256) { + throw new BrokerFailure("compile_load", 8); + } + string imagePath = Path.GetFullPath(Assembly.GetExecutingAssembly().Location); + using (SafeFileHandle reopened = OpenPinned(imagePath, true)) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(reopened, FileAttributeTagInfo, "compile_load", 7); + FILE_ID_INFO reopenedIdentity = ReadInfo(reopened, FileIdInfo, "compile_load", 8); + FILE_STANDARD_INFO reopenedStandard = ReadInfo(reopened, FileStandardInfo, "compile_load", 8); + string reopenedFileId = BitConverter.ToString(reopenedIdentity.FileId).Replace("-", "").ToLowerInvariant(); + string reopenedHash = Hash(reopened, reopenedStandard.EndOfFile)[0]; + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0 + || reopenedIdentity.VolumeSerialNumber.ToString("x16") != IMAGE_VOLUME || reopenedFileId != IMAGE_FILE_ID + || reopenedStandard.NumberOfLinks != 1 || reopenedHash != IMAGE_SHA256) { + throw new BrokerFailure("compile_load", 8); + } + } + } + + public static void Initialize() { Smoke(); } + + public static void Serve() { + Stream input = Console.OpenStandardInput(); + long inputBytes = 0; + int frameCount = 0; + Dictionary start; + try { + start = ReadObject(input, ref inputBytes); + if (!ExactFields(start, START_FIELDS) || Integer(start, "version") != 1 || Text(start, "type") != "start" + || Text(start, "protocol") != "propr-windows-authority-v1" || !Hex(Text(start, "challenge"), 32)) { + throw new BrokerFailure("ready_protocol", 12); + } + ReverifyImage(); + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "ready_protocol" : failure.Code, failure == null ? 12 : failure.Scenario, ""); + return; + } + Stage(11, "READY"); + WriteFrame(Frame("version", 1, "type", "ready", "challenge", Text(start, "challenge"), + "protocol", "propr-windows-authority-v1", "maxRequestBytes", MAX_REQUEST, + "nativeSmoke", true, "compileCount", 1, "imageVolumeSerial", IMAGE_VOLUME, + "imageFileId128", IMAGE_FILE_ID, "imageSha256", IMAGE_SHA256)); + + HeldArtifact held = null; + string heldChallenge = ""; + string heldId = ""; + string heldPurpose = ""; + try { + while (true) { + Dictionary request = ReadObject(input, ref inputBytes); + if (request == null) break; + if (++frameCount > MAX_FRAMES) throw new BrokerFailure("output_bound", 17); + string id = ""; + try { + if (!ExactFields(request, REQUEST_FIELDS) || Integer(request, "version") != 1 + || Text(request, "type") != "request" || !Hex(Text(request, "id"), 32)) throwProtocol(); + id = Text(request, "id"); + string operation = Text(request, "operation"); + string purpose = Text(request, "purpose"); + if (operation == "fault-stderr") { + Console.Error.WriteLine("PROPR_FAULT 01"); + Console.Error.Flush(); + } else if (operation == "hold") { + string path = Text(request, "path"); + if (held != null || String.IsNullOrEmpty(path) || path.Length > 8192 + || (purpose != "setup" && purpose != "artifact") || !NullFields(request, "directory", "offset", "length") + || !Hex(Text(request, "challenge"), 32) || !Hex(Text(request, "expectedVolumeSerial"), 16) + || !Hex(Text(request, "expectedFileId128"), 32) + || (purpose == "artifact" && request["expectedSha256"] != null + && !Hex(Text(request, "expectedSha256"), 64)) + || (purpose == "setup" && request["expectedSha256"] != null)) throwProtocol(); + long expectedBytes = Integer(request, "expectedBytes"); + if ((purpose == "setup" && expectedBytes != 0) || (purpose == "artifact" && expectedBytes <= 0)) throwProtocol(); + if (request["barrier"] != null) { + string barrier = Text(request, "barrier"); + if (!Hex(barrier, 32)) throwProtocol(); + WriteFrame(Frame("version", 1, "type", "before-open", "id", id, "challenge", barrier)); + Dictionary continuation = ReadObject(input, ref inputBytes); + if (++frameCount > MAX_FRAMES || !ExactFields(continuation, REQUEST_FIELDS) + || Integer(continuation, "version") != 1 || Text(continuation, "type") != "request" + || Text(continuation, "id") != id || Text(continuation, "operation") != "continue" + || Text(continuation, "purpose") != purpose || Text(continuation, "challenge") != Text(request, "challenge") + || Text(continuation, "barrier") != barrier || !NullFields(continuation, "path", "directory", "expectedBytes", + "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "offset", "length")) throwProtocol(); + } + held = OpenHeld(path, expectedBytes, Text(request, "expectedVolumeSerial"), Text(request, "expectedFileId128"), + purpose, Text(request, "expectedSha256")); + heldChallenge = Text(request, "challenge"); heldId = id; heldPurpose = purpose; + WriteInspection("held", id, heldChallenge, held.Initial); + } else if (operation == "read") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !NullFields(request, "path", "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", + "expectedSha256", "barrier")) throwProtocol(); + byte[] bytes = held.Read(Integer(request, "offset"), checked((int)Integer(request, "length"))); + WriteFrame(Frame("version", 1, "type", "bytes", "id", id, "challenge", heldChallenge, + "bytes", Convert.ToBase64String(bytes))); + } else if (operation == "verify") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !Hex(Text(request, "barrier"), 32) || !NullFields(request, "path", "directory", "expectedBytes", + "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "offset", "length")) throwProtocol(); + WriteInspection("verified", id, Text(request, "barrier"), held.Verify()); + } else if (operation == "close") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !NullFields(request, "path", "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", + "expectedSha256", "barrier", "offset", "length")) throwProtocol(); + InspectionResult final = held.CloseVerified(); held = null; heldChallenge = ""; heldId = ""; heldPurpose = ""; + WriteInspection("closed", id, "", final); + } else if (held != null) { + throwProtocol(); + } else if (operation == "inspect") { + if (purpose != "setup" || request["path"] == null || !(request["directory"] is bool) + || !NullFields(request, "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", + "challenge", "barrier", "offset", "length")) throwProtocol(); + WriteInspection("inspection", id, "", Inspect(Text(request, "path"), (bool)request["directory"])); + } else if (operation == "ensure-directory" || operation == "protect-directory" || operation == "protect-file") { + bool expectedDirectory = operation != "protect-file"; + if (purpose != "setup" || !IsBool(request, "directory", expectedDirectory) + || !NullFields(request, "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", + "challenge", "barrier", "offset", "length")) throwProtocol(); + InspectionResult result = operation == "ensure-directory" ? EnsureDirectory(Text(request, "path")) + : operation == "protect-directory" ? ProtectDirectory(Text(request, "path")) : ProtectFile(Text(request, "path")); + WriteInspection("inspection", id, "", result); + } else throwProtocol(); + } catch (Exception error) { + if (held != null) { held.Dispose(); held = null; heldChallenge = ""; heldId = ""; heldPurpose = ""; } + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "request_protocol" : failure.Code, failure == null ? 1 : failure.Scenario, id); + } + } + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "request_protocol" : failure.Code, failure == null ? 1 : failure.Scenario, ""); + } finally { if (held != null) held.Dispose(); } + } + + public static int Main(string[] args) { + try { + if (args == null || args.Length != 1 || args[0] != "--broker") return 64; + AuthenticateImage(); + Stage(10, "PROTOCOL_INIT"); + // Startup authenticates and pins this exact image before any request is served. + Initialize(); + Serve(); + return 0; + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + if (failure != null) { + Console.Error.WriteLine("PROPR_FAILURE " + failure.Code + " " + failure.Scenario.ToString()); + Console.Error.Flush(); + } + return 70; + } finally { + if (IMAGE_LEASE != null) IMAGE_LEASE.Dispose(); + IMAGE_LEASE = null; + } + } +} diff --git a/apps/desktop/src/native/windows-launcher/binding.gyp b/apps/desktop/src/native/windows-launcher/binding.gyp new file mode 100644 index 000000000..5dabc5e85 --- /dev/null +++ b/apps/desktop/src/native/windows-launcher/binding.gyp @@ -0,0 +1,64 @@ +{ + "targets": [ + { + "target_name": "propr_windows_malicious_bootstrap", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602", "PROPR_WINDOWS_BOOTSTRAP_ONLY=1", "PROPR_WINDOWS_MALICIOUS_BOOTSTRAP=1"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + }, + { + "target_name": "propr_windows_build_bootstrap", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602", "PROPR_WINDOWS_BOOTSTRAP_ONLY=1", "PROPR_WINDOWS_BUILD_BOOTSTRAP=1"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + }, + { + "target_name": "propr_windows_bootstrap", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602", "PROPR_WINDOWS_BOOTSTRAP_ONLY=1"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + }, + { + "target_name": "propr_windows_launcher", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + } + ] +} diff --git a/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc b/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc new file mode 100644 index 000000000..74d87a04e --- /dev/null +++ b/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc @@ -0,0 +1,2140 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "crypt32.lib") +#pragma comment(lib, "wintrust.lib") + +namespace { +constexpr size_t kSystemDirectoryChars = 520; +constexpr DWORD kMaxImageBytes = 4 * 1024 * 1024; +constexpr DWORD kMaxBuildInputBytes = 32 * 1024 * 1024; +constexpr DWORD kMaxSourceBytes = 256 * 1024; +constexpr DWORD kFileIdInfo = 18; +constexpr DWORD kFileAttributeTagInfo = 9; + +struct FileIdInfo { + ULONGLONG volume; + BYTE id[16]; +}; + +struct AttributeTagInfo { + DWORD attributes; + DWORD reparse_tag; +}; + +struct LaunchLease { + HANDLE image = nullptr; + HANDLE process = nullptr; + HANDLE job = nullptr; + int stdin_fd = -1; + int stdout_fd = -1; + int stderr_fd = -1; + bool closed = false; +}; + +struct FileLeases { std::vector handles; bool closed = false; }; + +struct CatalogContextLease { + HCATADMIN admin = nullptr; + HCATINFO catalog = nullptr; + ~CatalogContextLease() { + if (catalog) CryptCATAdminReleaseCatalogContext(admin, catalog, 0); + if (admin) CryptCATAdminReleaseContext(admin, 0); + } + CatalogContextLease() = default; + CatalogContextLease(const CatalogContextLease&) = delete; + CatalogContextLease& operator=(const CatalogContextLease&) = delete; +}; + +enum class CatalogBindingFault { + None, + NullAdmin, + MismatchedAdmin, + ReleasedEarly, + WrongHashAlgorithm, + ForeignCatalogContext, +}; + +CatalogBindingFault CatalogBindingFaultFromString(const std::string& fault) { + if (fault == "catalog-binding-null-admin") return CatalogBindingFault::NullAdmin; + if (fault == "catalog-binding-mismatched-admin") return CatalogBindingFault::MismatchedAdmin; + if (fault == "catalog-binding-released-early") return CatalogBindingFault::ReleasedEarly; + if (fault == "catalog-binding-wrong-hash-algorithm") return CatalogBindingFault::WrongHashAlgorithm; + if (fault == "catalog-binding-foreign-catalog-context") return CatalogBindingFault::ForeignCatalogContext; + return CatalogBindingFault::None; +} + +bool ExactCatalogBinding(HCATADMIN acquired_admin, HCATINFO enumerated_catalog, + HCATADMIN supplied_admin, HCATINFO supplied_catalog, const wchar_t* hash_algorithm, + bool admin_retained, bool catalog_retained) { + return acquired_admin != nullptr && enumerated_catalog != nullptr + && supplied_admin == acquired_admin && supplied_catalog == enumerated_catalog + && hash_algorithm != nullptr && lstrcmpW(hash_algorithm, BCRYPT_SHA256_ALGORITHM) == 0 + && admin_retained && catalog_retained; +} + +void CloseFileLeases(FileLeases* leases) { + if (!leases || leases->closed) return; + leases->closed = true; + for (HANDLE handle : leases->handles) if (handle && handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + leases->handles.clear(); +} + +void FinalizeFileLeases(napi_env, void* data, void*) { + auto* leases = static_cast(data); + CloseFileLeases(leases); + delete leases; +} + +void CloseLease(LaunchLease* lease) { + if (!lease || lease->closed) return; + lease->closed = true; + if (lease->stdin_fd >= 0) { _close(lease->stdin_fd); lease->stdin_fd = -1; } + if (lease->stdout_fd >= 0) { _close(lease->stdout_fd); lease->stdout_fd = -1; } + if (lease->stderr_fd >= 0) { _close(lease->stderr_fd); lease->stderr_fd = -1; } + if (lease->job) { CloseHandle(lease->job); lease->job = nullptr; } + if (lease->process) { CloseHandle(lease->process); lease->process = nullptr; } + if (lease->image) { CloseHandle(lease->image); lease->image = nullptr; } +} + +void FinalizeLease(napi_env, void* data, void*) { + auto* lease = static_cast(data); + CloseLease(lease); + delete lease; +} + +bool Throw(napi_env env, const char* code) { + napi_throw_error(env, code, "Windows native authority boundary rejected the operation"); + return false; +} + +bool StringValue(napi_env env, napi_value object, const char* name, std::wstring* result) { + napi_value value; + size_t length = 0; + if (napi_get_named_property(env, object, name, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &length) != napi_ok + || length == 0 || length > 32767) return false; + std::vector buffer(length + 1); + if (napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &length) != napi_ok) return false; + result->assign(reinterpret_cast(buffer.data()), length); + return true; +} + +bool Utf8Value(napi_env env, napi_value object, const char* name, std::string* result, bool optional = false) { + napi_value value; + if (napi_get_named_property(env, object, name, &value) != napi_ok) return optional; + napi_valuetype type; + if (napi_typeof(env, value, &type) != napi_ok || type == napi_null || type == napi_undefined) return optional; + size_t length = 0; + if (type != napi_string || napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || length > 1024) return false; + std::vector buffer(length + 1); + if (napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &length) != napi_ok) return false; + result->assign(buffer.data(), length); + return true; +} + +bool Uint32Value(napi_env env, napi_value object, const char* name, uint32_t* result) { + napi_value value; + return napi_get_named_property(env, object, name, &value) == napi_ok + && napi_get_value_uint32(env, value, result) == napi_ok; +} + +bool BoolValue(napi_env env, napi_value object, const char* name, bool* result) { + napi_value value; + return napi_get_named_property(env, object, name, &value) == napi_ok + && napi_get_value_bool(env, value, result) == napi_ok; +} + +std::string Hex(const BYTE* bytes, size_t length) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result(length * 2, '0'); + for (size_t i = 0; i < length; ++i) { + result[i * 2] = digits[bytes[i] >> 4]; + result[i * 2 + 1] = digits[bytes[i] & 15]; + } + return result; +} + +bool Sha256Handle(HANDLE file, DWORD expected_size, std::string* result, DWORD maximum_size = kMaxImageBytes) { + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 || size.QuadPart != expected_size + || size.QuadPart > maximum_size || SetFilePointer(file, 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + DWORD object_size = 0, written = 0; + std::vector object; + std::array digest{}; + bool ok = BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0 + && BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), sizeof(object_size), &written, 0) == 0; + if (ok) { object.resize(object_size); ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) == 0; } + std::array buffer{}; + DWORD total = 0; + while (ok && total < expected_size) { + DWORD read = 0; + const DWORD requested = std::min(static_cast(buffer.size()), expected_size - total); + ok = ReadFile(file, buffer.data(), requested, &read, nullptr) && read > 0 + && BCryptHashData(hash, buffer.data(), read, 0) == 0; + total += read; + } + ok = ok && total == expected_size && BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0; + if (hash) BCryptDestroyHash(hash); + if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + if (ok) *result = Hex(digest.data(), digest.size()); + return ok; +} + +bool FileIdentity(HANDLE file, FileIdInfo* result) { + return GetFileInformationByHandleEx(file, static_cast(kFileIdInfo), result, sizeof(*result)) != FALSE; +} + +bool SameIdentity(const FileIdInfo& left, const FileIdInfo& right) { + return left.volume == right.volume && memcmp(left.id, right.id, sizeof(left.id)) == 0; +} + +bool SameSid(PSID left, const wchar_t* right_text) { + PSID right = nullptr; + const bool same = ConvertStringSidToSidW(right_text, &right) && EqualSid(left, right); + if (right) LocalFree(right); + return same; +} + +bool CurrentUserSid(PSID owner) { + HANDLE token = nullptr; + DWORD bytes = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector value(bytes); + const bool same = bytes > 0 && GetTokenInformation(token, TokenUser, value.data(), bytes, &bytes) + && EqualSid(owner, reinterpret_cast(value.data())->User.Sid); + CloseHandle(token); + return same; +} + +bool CurrentUserSidText(std::wstring* text) { + HANDLE token = nullptr; + DWORD bytes = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector value(bytes); + LPWSTR sid_text = nullptr; + const bool ok = bytes > 0 && GetTokenInformation(token, TokenUser, value.data(), bytes, &bytes) + && ConvertSidToStringSidW(reinterpret_cast(value.data())->User.Sid, &sid_text); + if (ok) *text = sid_text; + if (sid_text) LocalFree(sid_text); + CloseHandle(token); + return ok; +} + +bool TrustedAuthoritySid(PSID sid, bool allow_current_user) { + return (allow_current_user && CurrentUserSid(sid)) || SameSid(sid, L"S-1-5-18") || SameSid(sid, L"S-1-5-32-544") + || SameSid(sid, L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); +} + +bool QualifiedAceSidAndMask(const ACE_HEADER* header, ACCESS_MASK* mask, PSID* sid, bool* allowed) { + if (!header || header->AceSize < sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD)) return false; + const BYTE* bytes = reinterpret_cast(header); + switch (header->AceType) { + case ACCESS_ALLOWED_ACE_TYPE: + case ACCESS_ALLOWED_CALLBACK_ACE_TYPE: + *allowed = true; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + *sid = const_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + break; + case ACCESS_DENIED_ACE_TYPE: + case ACCESS_DENIED_CALLBACK_ACE_TYPE: + *allowed = false; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + *sid = const_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + break; + case ACCESS_ALLOWED_OBJECT_ACE_TYPE: + case ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE: + case ACCESS_DENIED_OBJECT_ACE_TYPE: + case ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE: { + *allowed = header->AceType == ACCESS_ALLOWED_OBJECT_ACE_TYPE + || header->AceType == ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE; + if (header->AceSize < sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD)) return false; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + const DWORD flags = *reinterpret_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + size_t offset = sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD); + if ((flags & ACE_OBJECT_TYPE_PRESENT) != 0) offset += sizeof(GUID); + if ((flags & ACE_INHERITED_OBJECT_TYPE_PRESENT) != 0) offset += sizeof(GUID); + if (offset >= header->AceSize) return false; + *sid = const_cast(bytes + offset); + break; + } + default: + return false; + } + const BYTE* sid_bytes = static_cast(*sid); + if (sid_bytes < bytes || sid_bytes >= bytes + header->AceSize || !IsValidSid(*sid)) return false; + const DWORD sid_bytes_length = GetLengthSid(*sid); + return sid_bytes_length > 0 && sid_bytes + sid_bytes_length <= bytes + header->AceSize; +} + +bool DangerousUntrustedAcl(PACL dacl, bool allow_current_user) { + int prior_order = -1; + for (DWORD index = 0; index < dacl->AceCount; ++index) { + void* raw = nullptr; + if (!GetAce(dacl, index, &raw)) return true; + auto* header = static_cast(raw); + if ((header->AceFlags & INHERIT_ONLY_ACE) != 0) continue; + ACCESS_MASK mask = 0; + PSID sid = nullptr; + bool allow_ace = false; + if (!QualifiedAceSidAndMask(header, &mask, &sid, &allow_ace)) return true; + const int order = (header->AceFlags & INHERITED_ACE) != 0 + ? (allow_ace ? 3 : 2) : (allow_ace ? 1 : 0); + if (order < prior_order) return true; + prior_order = order; + GENERIC_MAPPING mapping{FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_GENERIC_EXECUTE, FILE_ALL_ACCESS}; + MapGenericMask(&mask, &mapping); + // Callback and conditional allow ACEs are conservatively treated as + // effective. Evaluating their claims against only the current token would + // miss a future attacker token for which the condition becomes true. + // A named attacker SID is just as dangerous as a well-known broad group. + // Only the user and the fixed Windows authority principals may mutate an + // authenticated input while it is leased. + constexpr DWORD mapped_dangerous = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD | DELETE | WRITE_DAC | WRITE_OWNER; + if (allow_ace && (mask & mapped_dangerous) != 0 && !TrustedAuthoritySid(sid, allow_current_user)) return true; + } + return false; +} + +bool SecureObjectAcl(HANDLE object, bool allow_current_user = true) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + PSID owner = nullptr; + PACL dacl = nullptr; + const DWORD status = GetSecurityInfo(object, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + const bool secure = status == ERROR_SUCCESS && owner != nullptr && dacl != nullptr + && ((allow_current_user && CurrentUserSid(owner)) || SameSid(owner, L"S-1-5-18") || SameSid(owner, L"S-1-5-32-544") + || SameSid(owner, L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464")) + && !DangerousUntrustedAcl(dacl, allow_current_user); + if (descriptor) LocalFree(descriptor); + return secure; +} + +#if defined(PROPR_WINDOWS_BUILD_BOOTSTRAP) +enum class SecureRegularFileFailure { + None, + FileMeta, + Owner, + Dacl, + DaclProtected, +}; + +bool AcceptedFileOwner(PSID owner, bool allow_current_user) { + return owner != nullptr + && ((allow_current_user && CurrentUserSid(owner)) || SameSid(owner, L"S-1-5-18") + || SameSid(owner, L"S-1-5-32-544") + || SameSid(owner, L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464")); +} + +SecureRegularFileFailure DiagnoseSecureRegularFile(HANDLE file, DWORD expected_size, FileIdInfo* identity, + bool require_protected = true, bool allow_current_user = true) { + AttributeTagInfo tag{}; + BY_HANDLE_FILE_INFORMATION basic{}; + if (!GetFileInformationByHandle(file, &basic) + || !GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), &tag, sizeof(tag)) + || !FileIdentity(file, identity) || (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || tag.reparse_tag != 0 + || basic.nNumberOfLinks != 1 || basic.nFileSizeHigh != 0 || basic.nFileSizeLow != expected_size) { + return SecureRegularFileFailure::FileMeta; + } + PSECURITY_DESCRIPTOR owner_descriptor = nullptr; + PSID owner = nullptr; + const DWORD owner_status = GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, nullptr, nullptr, nullptr, &owner_descriptor); + const bool accepted_owner = owner_status == ERROR_SUCCESS && AcceptedFileOwner(owner, allow_current_user); + if (owner_descriptor) LocalFree(owner_descriptor); + if (!accepted_owner) return SecureRegularFileFailure::Owner; + + PSECURITY_DESCRIPTOR dacl_descriptor = nullptr; + PACL dacl = nullptr; + const DWORD dacl_status = GetSecurityInfo(file, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &dacl, nullptr, &dacl_descriptor); + if (dacl_status != ERROR_SUCCESS || dacl == nullptr || DangerousUntrustedAcl(dacl, allow_current_user)) { + if (dacl_descriptor) LocalFree(dacl_descriptor); + return SecureRegularFileFailure::Dacl; + } + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + const bool protected_dacl = GetSecurityDescriptorControl(dacl_descriptor, &control, &revision) + && (!require_protected || (control & SE_DACL_PROTECTED) != 0); + if (dacl_descriptor) LocalFree(dacl_descriptor); + return protected_dacl ? SecureRegularFileFailure::None : SecureRegularFileFailure::DaclProtected; +} +#endif + +bool SecureRegularFile(HANDLE file, DWORD expected_size, FileIdInfo* identity, bool require_protected = true, + bool allow_current_user = true) { + AttributeTagInfo tag{}; + BY_HANDLE_FILE_INFORMATION basic{}; + if (!GetFileInformationByHandle(file, &basic) + || !GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), &tag, sizeof(tag)) + || !FileIdentity(file, identity) || (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || tag.reparse_tag != 0 + || basic.nNumberOfLinks != 1 || basic.nFileSizeHigh != 0 || basic.nFileSizeLow != expected_size) return false; + PSECURITY_DESCRIPTOR descriptor = nullptr; + PSID owner = nullptr; + PACL dacl = nullptr; + const DWORD status = GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + bool secure = status == ERROR_SUCCESS && owner != nullptr && dacl != nullptr && SecureObjectAcl(file, allow_current_user); + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + secure = secure && GetSecurityDescriptorControl(descriptor, &control, &revision) + && (!require_protected || (control & SE_DACL_PROTECTED) != 0); + if (descriptor) LocalFree(descriptor); + return secure; +} + +bool SecureServicedSystemFile(HANDLE file, DWORD expected_size, FileIdInfo* identity) { + AttributeTagInfo tag{}; + BY_HANDLE_FILE_INFORMATION basic{}; + return expected_size > 0 && expected_size <= kMaxBuildInputBytes + && GetFileInformationByHandle(file, &basic) + && GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), &tag, sizeof(tag)) + && FileIdentity(file, identity) && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 + && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && tag.reparse_tag == 0 + && basic.nNumberOfLinks >= 1 && basic.nFileSizeHigh == 0 && basic.nFileSizeLow == expected_size + && SecureObjectAcl(file, false); +} + +bool VerifyTrust(const std::wstring& path, HANDLE held) { + WINTRUST_FILE_INFO file{}; + file.cbStruct = sizeof(file); + file.pcwszFilePath = path.c_str(); + file.hFile = held; + WINTRUST_DATA data{}; + data.cbStruct = sizeof(data); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_FILE; + data.pFile = &file; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_REVOCATION_CHECK_NONE | WTD_CACHE_ONLY_URL_RETRIEVAL; + GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG status = WinVerifyTrust(nullptr, &policy, &data); + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(nullptr, &policy, &data); + return status == ERROR_SUCCESS; +} + +bool Sha256Bytes(const BYTE* bytes, DWORD length, std::string* result) { + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + DWORD object_size = 0, written = 0; + std::vector object; + std::array digest{}; + bool ok = BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0 + && BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), sizeof(object_size), &written, 0) == 0; + if (ok) { object.resize(object_size); ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) == 0; } + ok = ok && BCryptHashData(hash, const_cast(bytes), length, 0) == 0 + && BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0; + if (hash) BCryptDestroyHash(hash); + if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + if (ok) *result = Hex(digest.data(), digest.size()); + return ok; +} + +enum class SignerContent { + EmbeddedPe, + StandaloneCatalog, +}; + +enum class CatalogFailure { + None, + Enumeration, + MemberTag, + CatalogHash, + WinTrustPolicy, + Revocation, + CatalogLease, + SignerParse, + ExactPublisher, + RootPin, + CertificatePin, + SpkiPin, +}; + +const char* CatalogFailureCode(CatalogFailure failure) { + switch (failure) { + case CatalogFailure::Enumeration: return "CATALOG_ENUMERATION"; + case CatalogFailure::MemberTag: return "MEMBER_TAG"; + case CatalogFailure::CatalogHash: return "CATALOG_HASH"; + case CatalogFailure::WinTrustPolicy: return "WINTRUST_POLICY"; + case CatalogFailure::Revocation: return "REVOCATION"; + case CatalogFailure::CatalogLease: return "CATALOG_LEASE"; + case CatalogFailure::SignerParse: return "SIGNER_PARSE"; + case CatalogFailure::ExactPublisher: return "EXACT_PUBLISHER"; + case CatalogFailure::RootPin: return "ROOT_PIN"; + case CatalogFailure::CertificatePin: return "CERTIFICATE_PIN"; + case CatalogFailure::SpkiPin: return "SPKI_PIN"; + default: return "SIGNER_CATALOG"; + } +} + +bool RevocationFailure(LONG status) { + return status == CERT_E_REVOKED || status == CRYPT_E_REVOKED + || status == CRYPT_E_REVOCATION_OFFLINE || status == CERT_E_REVOCATION_FAILURE; +} + +bool ReadHeldBytes(HANDLE held, DWORD maximum, std::vector* bytes) { + LARGE_INTEGER size{}; + if (!GetFileSizeEx(held, &size) || size.QuadPart <= 0 || size.QuadPart > maximum + || SetFilePointer(held, 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + bytes->resize(static_cast(size.QuadPart)); + DWORD total = 0; + while (total < bytes->size()) { + DWORD read = 0; + const DWORD requested = std::min(64 * 1024, static_cast(bytes->size()) - total); + if (!ReadFile(held, bytes->data() + total, requested, &read, nullptr) || read == 0) return false; + total += read; + } + return total == bytes->size(); +} + +bool SignerEvidence(HANDLE held, SignerContent expected_content, std::wstring* publisher, + std::string* certificate_hash, std::string* spki_hash, std::string* root_spki_hash = nullptr, + DWORD* chain_errors = nullptr, std::string* subject_der = nullptr, + std::string* subject_der_sha256 = nullptr) { + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + DWORD encoding = 0, content = 0, format = 0; + const DWORD content_flag = expected_content == SignerContent::EmbeddedPe + ? CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED : CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED; + const DWORD required_content = expected_content == SignerContent::EmbeddedPe + ? CERT_QUERY_CONTENT_PKCS7_SIGNED_EMBED : CERT_QUERY_CONTENT_PKCS7_SIGNED; + std::vector exact_bytes; + CRYPT_DATA_BLOB blob{}; + const bool read = ReadHeldBytes(held, kMaxBuildInputBytes, &exact_bytes); + if (read) { + blob.cbData = static_cast(exact_bytes.size()); + blob.pbData = exact_bytes.data(); + } + if (!read || !CryptQueryObject(CERT_QUERY_OBJECT_BLOB, &blob, content_flag, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, &content, &format, &store, &message, nullptr) + || content != required_content || format != CERT_QUERY_FORMAT_BINARY) return false; + DWORD bytes = 0; + bool ok = CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &bytes) != FALSE; + std::vector signer(bytes); + ok = ok && CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, signer.data(), &bytes); + PCCERT_CONTEXT certificate = nullptr; + if (ok) { + auto* info = reinterpret_cast(signer.data()); + CERT_INFO wanted{}; + wanted.Issuer = info->Issuer; + wanted.SerialNumber = info->SerialNumber; + certificate = CertFindCertificateInStore(store, encoding, 0, CERT_FIND_SUBJECT_CERT, &wanted, nullptr); + ok = certificate != nullptr; + } + if (ok) { + if (publisher) { + std::array name{}; + const DWORD name_length = CertNameToStrW(certificate->dwCertEncodingType, &certificate->pCertInfo->Subject, + CERT_X500_NAME_STR, name.data(), static_cast(name.size())); + *publisher = name_length > 1 && name_length <= name.size() ? std::wstring(name.data(), name_length - 1) : L""; + ok = !publisher->empty(); + } + const CERT_NAME_BLOB& subject = certificate->pCertInfo->Subject; + ok = ok && subject.pbData != nullptr && subject.cbData > 0 && subject.cbData <= 1024; + if (ok && subject_der) *subject_der = Hex(subject.pbData, subject.cbData); + if (ok && subject_der_sha256) ok = Sha256Bytes(subject.pbData, subject.cbData, subject_der_sha256); + BYTE* encoded = nullptr; + DWORD encoded_bytes = 0; + ok = ok && Sha256Bytes(certificate->pbCertEncoded, certificate->cbCertEncoded, certificate_hash) + && CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, &certificate->pCertInfo->SubjectPublicKeyInfo, + CRYPT_ENCODE_ALLOC_FLAG, nullptr, &encoded, &encoded_bytes) + && Sha256Bytes(encoded, encoded_bytes, spki_hash); + if (encoded) LocalFree(encoded); + if (ok) { + CERT_CHAIN_PARA parameters{}; + parameters.cbSize = sizeof(parameters); + PCCERT_CHAIN_CONTEXT chain = nullptr; + // Catalogs in the canonical CatRoot store are the locally authoritative + // Windows servicing statement. Never turn a hosted build into an online + // revocation request: cached revocation is still enforced and an + // explicitly revoked or otherwise untrusted chain remains fatal. + ok = CertGetCertificateChain(nullptr, certificate, nullptr, store, ¶meters, + CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT | CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, + nullptr, &chain) + && chain && chain->cChain >= 1 && chain->rgpChain[0]->cElement >= 2; + if (ok) { + const DWORD errors = chain->TrustStatus.dwErrorStatus; + if (chain_errors) *chain_errors = errors; + // A locally installed OS catalog remains usable without network or a + // warmed revocation cache. Known revocation and every other chain + // trust error are fatal; only an unavailable offline response is + // tolerated for this canonical servicing catalog. + const DWORD offline_only = CERT_TRUST_REVOCATION_STATUS_UNKNOWN | CERT_TRUST_IS_OFFLINE_REVOCATION; + ok = (errors & ~offline_only) == CERT_TRUST_NO_ERROR; + } + if (ok && root_spki_hash) { + PCCERT_CONTEXT root = chain->rgpChain[0]->rgpElement[chain->rgpChain[0]->cElement - 1]->pCertContext; + BYTE* root_encoded = nullptr; + DWORD root_bytes = 0; + ok = CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, &root->pCertInfo->SubjectPublicKeyInfo, + CRYPT_ENCODE_ALLOC_FLAG, nullptr, &root_encoded, &root_bytes) + && Sha256Bytes(root_encoded, root_bytes, root_spki_hash); + if (root_encoded) LocalFree(root_encoded); + } + if (chain) CertFreeCertificateChain(chain); + } + } + if (certificate) CertFreeCertificateContext(certificate); + if (message) CryptMsgClose(message); + if (store) CertCloseStore(store, 0); + return ok; +} + +bool VerifyPinnedSignature(const std::wstring& path, HANDLE held, const std::string& expected_publisher, + const std::string& expected_certificate, const std::string& expected_spki) { + if (!VerifyTrust(path, held) || expected_publisher.empty() + || expected_certificate.size() != 64 || expected_spki.size() != 64) return false; + std::wstring publisher; + std::string certificate, spki; + std::wstring expected(expected_publisher.begin(), expected_publisher.end()); + return SignerEvidence(held, SignerContent::EmbeddedPe, &publisher, &certificate, &spki) + && publisher == expected && certificate == expected_certificate && spki == expected_spki; +} + +bool PinnedMicrosoftRoot(const std::string& root_spki) { + return root_spki == "02376d0908ac23041cc7d666d9daf192554f7fc36317aa9cb800908616b28af8" + || root_spki == "c9905b0ee01202293ca026e64f08412442c5504c06e44ca7e9726d61f20e4089" + || root_spki == "b2f7298b52bf2c3cac4ddfe72de4d682ac58957595982f2b62301af597c699c5"; +} + +std::wstring SystemWindowsDirectory(); + +// Exact Microsoft Windows system-component publisher identity. This is the +// encoded CERT_NAME_BLOB in certificate order (C, ST, L, O, CN), independent +// of CertNameToStr display order and aliases such as S/ST. Servicing catalog +// names, hashes, leaf certificates, and SPKIs are deliberately not authority: +// they are retained as observed build evidence only after the held catalog and +// member have passed the OS-backed checks below. +constexpr char kMicrosoftWindowsSubjectDer[] = + "3070310b3009060355040613025553311330110603550408130a57617368696e67746f6e3110300e060355040713075265646d6f6e64311e301c060355040a13154d6963726f736f667420436f72706f726174696f6e311a3018060355040313114d6963726f736f66742057696e646f7773"; + +const wchar_t* BaseName(const std::wstring& path) { + const size_t slash = path.find_last_of(L"\\/"); + return path.c_str() + (slash == std::wstring::npos ? 0 : slash + 1); +} + +bool AsciiEvidenceName(const wchar_t* value, size_t maximum, std::string* output) { + output->clear(); + for (const wchar_t* cursor = value; *cursor; ++cursor) { + const wchar_t ch = *cursor; + const bool allowed = ch < 0x80 && (iswalnum(ch) || ch == L'_' || ch == L'.' || ch == L'~' || ch == L'-'); + if (!allowed || output->size() == maximum) return false; + output->push_back(static_cast(ch)); + } + return !output->empty(); +} + +bool MicrosoftSystemComponentAuthority(const std::string& subject_der, const std::string& root_spki) { + return subject_der == kMicrosoftWindowsSubjectDer && PinnedMicrosoftRoot(root_spki); +} + +napi_value MicrosoftSystemComponentForTest(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1], result; + std::string subject_der, root_spki; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !Utf8Value(env, args[0], "subjectDer", &subject_der) + || !Utf8Value(env, args[0], "rootSpkiSha256", &root_spki)) { + Throw(env, "CATALOG_TEST_ARGUMENT"); return nullptr; + } + napi_get_boolean(env, MicrosoftSystemComponentAuthority(subject_der, root_spki), &result); + return result; +} + +bool CanonicalMicrosoftCatalog(const std::wstring& path, std::string* sha256, FileIdInfo* identity, + HANDLE* held_catalog) { + const std::wstring windows = SystemWindowsDirectory(); + const std::wstring catalog_root = windows + + L"\\System32\\CatRoot\\{F750E6C3-38EE-11D1-85E5-00C04FC295EE}\\"; + if (windows.empty() || path.size() <= catalog_root.size() + || _wcsnicmp(path.c_str(), catalog_root.c_str(), catalog_root.size()) != 0 + || path.find(L'\\', catalog_root.size()) != std::wstring::npos) return false; + HANDLE catalog = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + LARGE_INTEGER size{}; + std::array final_path{}; + const DWORD final_length = catalog == INVALID_HANDLE_VALUE ? 0 + : GetFinalPathNameByHandleW(catalog, final_path.data(), static_cast(final_path.size()), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + const std::wstring expected_final = L"\\\\?\\" + path; + const bool valid = catalog != INVALID_HANDLE_VALUE && GetFileSizeEx(catalog, &size) + && size.QuadPart > 0 && size.QuadPart <= kMaxBuildInputBytes + && final_length > 0 && final_length < final_path.size() + && _wcsicmp(final_path.data(), expected_final.c_str()) == 0 + && SecureServicedSystemFile(catalog, static_cast(size.QuadPart), identity) + && Sha256Handle(catalog, static_cast(size.QuadPart), sha256, kMaxBuildInputBytes); + if (valid) *held_catalog = catalog; + else if (catalog != INVALID_HANDLE_VALUE) CloseHandle(catalog); + return valid; +} + +bool VerifyCatalogTrust(const std::wstring& path, HANDLE file, std::wstring* catalog_path, + std::string* catalog_sha256, FileIdInfo* catalog_identity, HANDLE* held_catalog, + CatalogContextLease* context_lease, CatalogFailure* failure, + CatalogBindingFault binding_fault = CatalogBindingFault::None) { + *failure = CatalogFailure::Enumeration; + HCATADMIN admin = nullptr; + GUID driver_action = DRIVER_ACTION_VERIFY; + if (!CryptCATAdminAcquireContext2(&admin, &driver_action, BCRYPT_SHA256_ALGORITHM, nullptr, 0)) return false; + DWORD hash_bytes = 0; + bool ok = CryptCATAdminCalcHashFromFileHandle2(admin, file, &hash_bytes, nullptr, 0) != FALSE + && hash_bytes > 0 && hash_bytes <= 128; + if (!ok) *failure = CatalogFailure::CatalogHash; + std::vector hash(hash_bytes); + ok = ok && SetFilePointer(file, 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER + && CryptCATAdminCalcHashFromFileHandle2(admin, file, &hash_bytes, hash.data(), 0); + if (!ok) *failure = CatalogFailure::CatalogHash; + HCATINFO catalog = ok ? CryptCATAdminEnumCatalogFromHash(admin, hash.data(), hash_bytes, 0, nullptr) : nullptr; + const HCATADMIN acquired_admin = admin; + const HCATINFO enumerated_catalog = catalog; + HCATADMIN supplied_admin = admin; + HCATINFO supplied_catalog = catalog; + const wchar_t* supplied_hash_algorithm = BCRYPT_SHA256_ALGORITHM; + bool admin_retained = admin != nullptr; + bool catalog_retained = catalog != nullptr; + CatalogContextLease foreign_context{}; + if (binding_fault == CatalogBindingFault::NullAdmin) { + supplied_admin = nullptr; + } else if (binding_fault == CatalogBindingFault::MismatchedAdmin) { + CryptCATAdminAcquireContext2(&foreign_context.admin, &driver_action, BCRYPT_SHA256_ALGORITHM, nullptr, 0); + supplied_admin = foreign_context.admin; + } else if (binding_fault == CatalogBindingFault::WrongHashAlgorithm) { + CryptCATAdminAcquireContext2(&foreign_context.admin, &driver_action, BCRYPT_SHA1_ALGORITHM, nullptr, 0); + supplied_admin = foreign_context.admin; + supplied_hash_algorithm = BCRYPT_SHA1_ALGORITHM; + } else if (binding_fault == CatalogBindingFault::ForeignCatalogContext) { + if (CryptCATAdminAcquireContext2(&foreign_context.admin, &driver_action, + BCRYPT_SHA256_ALGORITHM, nullptr, 0)) { + foreign_context.catalog = CryptCATAdminEnumCatalogFromHash( + foreign_context.admin, hash.data(), hash_bytes, 0, nullptr); + } + supplied_catalog = foreign_context.catalog; + } else if (binding_fault == CatalogBindingFault::ReleasedEarly) { + if (catalog) CryptCATAdminReleaseCatalogContext(admin, catalog, 0); + catalog = nullptr; + if (admin) CryptCATAdminReleaseContext(admin, 0); + admin = nullptr; + admin_retained = false; + catalog_retained = false; + } + const bool catalog_enumerated = ok && enumerated_catalog != nullptr; + const bool exact_binding = catalog_enumerated + && ExactCatalogBinding(acquired_admin, enumerated_catalog, supplied_admin, supplied_catalog, + supplied_hash_algorithm, admin_retained, catalog_retained); + if (catalog_enumerated && !exact_binding) *failure = CatalogFailure::WinTrustPolicy; + ok = exact_binding; + CATALOG_INFO catalog_info{}; + catalog_info.cbStruct = sizeof(catalog_info); + ok = ok && supplied_catalog && CryptCATCatalogInfoFromContext(supplied_catalog, &catalog_info, 0); + std::wstring member_tag; + if (ok) { + *catalog_path = catalog_info.wszCatalogFile; + ok = CanonicalMicrosoftCatalog(*catalog_path, catalog_sha256, catalog_identity, held_catalog); + if (!ok) *failure = CatalogFailure::CatalogLease; + } + if (ok) { + const std::string lower = Hex(hash.data(), hash.size()); + member_tag.assign(lower.begin(), lower.end()); + std::transform(member_tag.begin(), member_tag.end(), member_tag.begin(), + [](wchar_t value) { return static_cast(towupper(value)); }); + if (member_tag.empty() || member_tag.size() != hash.size() * 2) { + ok = false; + *failure = CatalogFailure::MemberTag; + } + } + if (ok) { + WINTRUST_CATALOG_INFO member{}; + member.cbStruct = sizeof(member); + member.pcwszCatalogFilePath = catalog_info.wszCatalogFile; + member.pcwszMemberTag = member_tag.c_str(); + member.pcwszMemberFilePath = path.c_str(); + member.hMemberFile = file; + member.pbCalculatedFileHash = hash.data(); + member.cbCalculatedFileHash = hash_bytes; + // pbCalculatedFileHash/member tag were produced by this exact retained + // SHA-256 admin. Keep the exact enumerated HCATINFO alive through VERIFY + // and CLOSE; pcCatalogContext is deliberately absent rather than sourced + // from a different catalog-admin context. + member.pcCatalogContext = nullptr; + member.hCatAdmin = admin; + WINTRUST_DATA data{}; + data.cbStruct = sizeof(data); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_CATALOG; + data.pCatalog = &member; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_REVOCATION_CHECK_NONE | WTD_CACHE_ONLY_URL_RETRIEVAL; + GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG trust_status = WinVerifyTrust(nullptr, &policy, &data); + ok = trust_status == ERROR_SUCCESS; + if (!ok) *failure = RevocationFailure(trust_status) + ? CatalogFailure::Revocation : CatalogFailure::WinTrustPolicy; + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(nullptr, &policy, &data); + } + if (!ok && *held_catalog != INVALID_HANDLE_VALUE) { + CloseHandle(*held_catalog); + *held_catalog = INVALID_HANDLE_VALUE; + } + if (ok) { + context_lease->admin = admin; + context_lease->catalog = catalog; + admin = nullptr; + catalog = nullptr; + } + if (catalog) CryptCATAdminReleaseCatalogContext(admin, catalog, 0); + if (admin) CryptCATAdminReleaseContext(admin, 0); + if (ok) *failure = CatalogFailure::None; + return ok; +} + +bool VerifyMicrosoftCompilerInput(const std::wstring& path, HANDLE file, std::string* certificate, + std::string* spki, std::string* root_spki, std::string* catalog_sha256, + std::string* catalog_name, std::wstring* catalog_path, FileIdInfo* catalog_identity, + HANDLE* held_catalog, CatalogContextLease* context_lease, CatalogFailure* failure, + CatalogBindingFault binding_fault = CatalogBindingFault::None) { + // Inbox compiler/reference authorization is membership in the immutable, + // OS-serviced Windows catalog rooted at the canonical CatRoot namespace. + // An arbitrary embedded Authenticode signature, even under a Microsoft root, + // is deliberately insufficient. + std::wstring evidence_path; + const bool trusted = VerifyCatalogTrust(path, file, &evidence_path, catalog_sha256, + catalog_identity, held_catalog, context_lease, failure, binding_fault); + std::string subject_der; + DWORD chain_errors = 0xffffffff; + if (!trusted) return false; + if (!SignerEvidence(*held_catalog, SignerContent::StandaloneCatalog, + nullptr, certificate, spki, root_spki, &chain_errors, &subject_der)) { + *failure = (chain_errors & CERT_TRUST_IS_REVOKED) != 0 + ? CatalogFailure::Revocation : chain_errors == 0xffffffff + ? CatalogFailure::SignerParse : CatalogFailure::WinTrustPolicy; + return false; + } + if (subject_der != kMicrosoftWindowsSubjectDer) { + *failure = CatalogFailure::ExactPublisher; return false; + } + if (!PinnedMicrosoftRoot(*root_spki)) { *failure = CatalogFailure::RootPin; return false; } + // These values are evidence emitted by the exact retained handles above; + // unlike WinTrust membership, publisher identity, and the pinned root, no + // observed servicing leaf/catalog tuple can confer authority by itself. + if (certificate->size() != 64 || spki->size() != 64 || catalog_sha256->size() != 64 + || !AsciiEvidenceName(BaseName(evidence_path), 180, catalog_name) + || catalog_name->size() < 5 + || _stricmp(catalog_name->c_str() + catalog_name->size() - 4, ".cat") != 0) { + *failure = CatalogFailure::CatalogHash; return false; + } + *catalog_path = evidence_path; + *failure = CatalogFailure::None; + return true; +} + +bool ExpectedArchitecture(HANDLE file) { + IMAGE_DOS_HEADER dos{}; + DWORD read = 0; + if (!ReadFile(file, &dos, sizeof(dos), &read, nullptr) || read != sizeof(dos) || dos.e_magic != IMAGE_DOS_SIGNATURE + || SetFilePointer(file, dos.e_lfanew, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + DWORD signature = 0; + IMAGE_FILE_HEADER header{}; + if (!ReadFile(file, &signature, sizeof(signature), &read, nullptr) || signature != IMAGE_NT_SIGNATURE + || !ReadFile(file, &header, sizeof(header), &read, nullptr)) return false; +#if defined(_M_ARM64) + return header.Machine == IMAGE_FILE_MACHINE_ARM64; +#else + return header.Machine == IMAGE_FILE_MACHINE_AMD64; +#endif +} + +std::wstring SystemWindowsDirectory() { + std::array path{}; + const UINT length = GetSystemWindowsDirectoryW(path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size() || path[0] == L'\\' || path[1] != L':') return {}; + return std::wstring(path.data(), length); +} + +bool CanonicalDirectory(const std::wstring& path, bool allow_current_user = true) { + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + AttributeTagInfo tag{}; + FileIdInfo identity{}; + const bool valid = directory != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(directory, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && FileIdentity(directory, &identity) + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 + && SecureObjectAcl(directory, allow_current_user); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + return valid; +} + +bool ProtectPrivateBuildDirectory(const std::wstring& path) { + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + AttributeTagInfo tag{}; + PSECURITY_DESCRIPTOR current = nullptr; + PSID owner = nullptr; + std::wstring user_sid; + bool valid = directory != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(directory, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 + && GetSecurityInfo(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, nullptr, nullptr, nullptr, + ¤t) == ERROR_SUCCESS && owner && CurrentUserSid(owner) && CurrentUserSidText(&user_sid); + PSECURITY_DESCRIPTOR replacement = nullptr; + PACL dacl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + if (valid) { + const std::wstring sddl = L"D:P(A;OICI;FA;;;" + user_sid + + L")(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + valid = ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &replacement, nullptr) && GetSecurityDescriptorDacl(replacement, &present, &dacl, &defaulted) + && present && dacl && SetSecurityInfo(directory, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, dacl, nullptr) == ERROR_SUCCESS; + } + if (replacement) LocalFree(replacement); + if (current) LocalFree(current); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + return valid && CanonicalDirectory(path, true); +} + +bool PrivateSessionDirectoryOwner(PSID owner) { + // This exception is only consumed by the atomically created random session + // temp entry below. Build, helper, package, and artifact authentication keep + // their existing owner policies. + return owner != nullptr && (CurrentUserSid(owner) || SameSid(owner, L"S-1-5-18") + || SameSid(owner, L"S-1-5-32-544")); +} + +bool HeldPrivateSessionDirectory(HANDLE directory, FileIdInfo* identity) { + AttributeTagInfo tag{}; + PSECURITY_DESCRIPTOR descriptor = nullptr; + PSID owner = nullptr; + const bool valid = directory != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(directory, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && FileIdentity(directory, identity) + && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 + && GetSecurityInfo(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, nullptr, nullptr, nullptr, + &descriptor) == ERROR_SUCCESS && PrivateSessionDirectoryOwner(owner); + if (descriptor) LocalFree(descriptor); + return valid; +} + +bool ExactPrivateDirectoryDacl(HANDLE directory, const std::wstring& user_sid_text) { + PSID user_sid = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + const DWORD status = GetSecurityInfo(directory, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &dacl, nullptr, &descriptor); + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + bool current = false, system = false, administrators = false; + bool valid = ConvertStringSidToSidW(user_sid_text.c_str(), &user_sid) + && status == ERROR_SUCCESS && descriptor != nullptr && dacl != nullptr && dacl->AceCount == 3 + && GetSecurityDescriptorControl(descriptor, &control, &revision) + && (control & SE_DACL_PROTECTED) != 0; + for (DWORD index = 0; valid && index < dacl->AceCount; ++index) { + void* raw = nullptr; + valid = GetAce(dacl, index, &raw) != FALSE; + if (!valid) break; + auto* header = static_cast(raw); + ACCESS_MASK mask = 0; + PSID sid = nullptr; + bool allowed = false; + const BYTE expected_flags = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + valid = QualifiedAceSidAndMask(header, &mask, &sid, &allowed) && allowed + && header->AceType == ACCESS_ALLOWED_ACE_TYPE && header->AceFlags == expected_flags && mask == FILE_ALL_ACCESS; + if (!valid) break; + if (EqualSid(sid, user_sid)) { + valid = !current; + current = true; + } else if (SameSid(sid, L"S-1-5-18")) { + valid = !system; + system = true; + } else if (SameSid(sid, L"S-1-5-32-544")) { + valid = !administrators; + administrators = true; + } else { + valid = false; + } + } + if (user_sid) LocalFree(user_sid); + if (descriptor) LocalFree(descriptor); + return valid && current && system && administrators; +} + +bool ProtectPrivateSessionDirectory(const std::wstring& path) { + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + FileIdInfo before{}, after{}; + std::wstring user_sid; + bool valid = HeldPrivateSessionDirectory(directory, &before) && CurrentUserSidText(&user_sid); + PSECURITY_DESCRIPTOR replacement = nullptr; + PACL dacl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + if (valid) { + const std::wstring sddl = L"D:P(A;OICI;FA;;;" + user_sid + + L")(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + valid = ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &replacement, nullptr) && GetSecurityDescriptorDacl(replacement, &present, &dacl, &defaulted) + && present && dacl && SetSecurityInfo(directory, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, dacl, nullptr) == ERROR_SUCCESS; + } + valid = valid && HeldPrivateSessionDirectory(directory, &after) + && SameIdentity(before, after) && ExactPrivateDirectoryDacl(directory, user_sid); + if (replacement) LocalFree(replacement); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + return valid; +} + +bool MutationWasDenied(const std::wstring& path, const std::string& fault); + +napi_value ProtectPrivateDirectory(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring path; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &path) || path.size() < 3 || path.size() >= 32768 + || path[0] == L'\\' || path[1] != L':' || !ProtectPrivateSessionDirectory(path)) { + Throw(env, "PRIVATE_DIRECTORY"); return nullptr; + } + napi_value result; + napi_get_boolean(env, true, &result); + return result; +} + +napi_value VerifyPrivateDirectoryForTest(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring path; + std::string fault; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &path) || path.size() < 3 || path.size() >= 32768 + || path[0] == L'\\' || path[1] != L':') { + Throw(env, "PRIVATE_DIRECTORY"); return nullptr; + } + Utf8Value(env, args[0], "fault", &fault, true); + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + FileIdInfo before{}, after{}; + std::wstring user_sid; + bool valid = HeldPrivateSessionDirectory(directory, &before) && CurrentUserSidText(&user_sid) + && ExactPrivateDirectoryDacl(directory, user_sid); + if (valid && fault == "substitution") valid = MutationWasDenied(path, "swap"); + valid = valid && HeldPrivateSessionDirectory(directory, &after) && SameIdentity(before, after) + && ExactPrivateDirectoryDacl(directory, user_sid); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + if (!valid) { Throw(env, "PRIVATE_DIRECTORY"); return nullptr; } + napi_value result; + napi_get_boolean(env, true, &result); + return result; +} + +napi_value ProbeSystemDirectory(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + std::string fault; + Utf8Value(env, args[0], "fault", &fault, true); + const std::wstring windows = SystemWindowsDirectory(); + if (windows.empty()) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + const std::wstring powershell = windows + L"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const bool directory_valid = CanonicalDirectory(windows, false) + && CanonicalDirectory(windows + L"\\System32", false) + && CanonicalDirectory(windows + L"\\System32\\WindowsPowerShell", false) + && CanonicalDirectory(windows + L"\\System32\\WindowsPowerShell\\v1.0", false); + if (!directory_valid) { Throw(env, "SYSTEM_DIRECTORY"); return nullptr; } + HANDLE candidate = CreateFileW(powershell.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (candidate == INVALID_HANDLE_VALUE) { Throw(env, "SYSTEM_CANDIDATE"); return nullptr; } + LARGE_INTEGER size{}; + FileIdInfo identity{}; + FileIdInfo system_catalog_identity{}; + HANDLE system_catalog = INVALID_HANDLE_VALUE; + CatalogContextLease system_catalog_context{}; + CatalogFailure catalog_failure = CatalogFailure::None; + std::string system_certificate, system_spki, system_root_spki, system_catalog_sha256, system_catalog_name; + std::wstring system_catalog_path; + std::array final_path{}; + const DWORD final_length = GetFinalPathNameByHandleW(candidate, final_path.data(), static_cast(final_path.size()), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + const std::wstring expected_final = L"\\\\?\\" + powershell; + const bool valid = GetFileSizeEx(candidate, &size) && size.QuadPart > 0 && size.QuadPart <= kMaxImageBytes + && final_length > 0 && final_length < final_path.size() && _wcsicmp(final_path.data(), expected_final.c_str()) == 0 + && SecureServicedSystemFile(candidate, static_cast(size.QuadPart), &identity) + && VerifyMicrosoftCompilerInput(powershell, candidate, &system_certificate, &system_spki, + &system_root_spki, &system_catalog_sha256, &system_catalog_name, &system_catalog_path, + &system_catalog_identity, &system_catalog, &system_catalog_context, &catalog_failure, + CatalogBindingFaultFromString(fault)); + if (system_catalog != INVALID_HANDLE_VALUE) CloseHandle(system_catalog); + CloseHandle(candidate); + if (!valid) { + const char* code = catalog_failure == CatalogFailure::None + ? "SYSTEM_CANDIDATE" : CatalogFailureCode(catalog_failure); + Throw(env, code); + return nullptr; + } + constexpr std::array diagnostic_faults{ + "CATALOG_ENUMERATION", "MEMBER_TAG", "CATALOG_HASH", + "WINTRUST_POLICY", "REVOCATION", + "CATALOG_LEASE", "SIGNER_PARSE", "EXACT_PUBLISHER", "ROOT_PIN", "CERTIFICATE_PIN", "SPKI_PIN", + }; + for (const char* code : diagnostic_faults) { + if (fault == std::string("directory-") + code) { Throw(env, code); return nullptr; } + } + + std::wstring system_root_hint, windir_hint; + StringValue(env, args[0], "systemRoot", &system_root_hint); + StringValue(env, args[0], "windir", &windir_hint); + auto equal = [](const std::wstring& a, const std::wstring& b) { + return a.empty() || (a.size() == b.size() && _wcsicmp(a.c_str(), b.c_str()) == 0); + }; + if (!equal(system_root_hint, windows) || !equal(windir_hint, windows)) { Throw(env, "SYSTEM_HINT"); return nullptr; } + + void* data = nullptr; + napi_value output; + const size_t bytes = sizeof(uint16_t) + kSystemDirectoryChars * sizeof(char16_t); + if (napi_create_buffer(env, bytes, &data, &output) != napi_ok) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + memset(data, 0, bytes); + *static_cast(data) = static_cast(windows.size()); + memcpy(static_cast(data) + sizeof(uint16_t), windows.data(), windows.size() * sizeof(wchar_t)); + return output; +} + +bool PipePair(HANDLE* read, HANDLE* write, bool parent_reads) { + SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE}; + if (!CreatePipe(read, write, &attributes, 0)) return false; + HANDLE parent = parent_reads ? *read : *write; + return SetHandleInformation(parent, HANDLE_FLAG_INHERIT, 0) != FALSE; +} + +bool MutationWasDenied(const std::wstring& path, const std::string& fault) { + if (fault.find("delete") != std::string::npos) return !DeleteFileW(path.c_str()); + if (fault.find("swap") != std::string::npos || fault.find("rename") != std::string::npos + || fault.find("aba") != std::string::npos) { + const std::wstring displaced = path + L".native-barrier"; + if (!MoveFileExW(path.c_str(), displaced.c_str(), MOVEFILE_REPLACE_EXISTING)) return true; + MoveFileExW(displaced.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING); + return false; + } + if (fault.find("write") != std::string::npos) { + HANDLE writer = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (writer == INVALID_HANDLE_VALUE) return true; + CloseHandle(writer); + return false; + } + return true; +} + +napi_value LoadVerifiedModule(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring path; + std::string expected_hash, publisher, certificate_pin, spki_pin, fault, authentication_mode; + uint32_t expected_size = 0; + bool production = false; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &path) || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) || !BoolValue(env, args[0], "production", &production) + || expected_hash.size() != 64 || expected_size == 0 || expected_size > kMaxImageBytes) { + Throw(env, "MODULE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + Utf8Value(env, args[0], "fault", &fault, true); + if (!Utf8Value(env, args[0], "authenticationMode", &authentication_mode)) { + Throw(env, "MODULE_ARGUMENT"); return nullptr; + } +#if defined(PROPR_WINDOWS_BUILD_BOOTSTRAP) + const bool allow_current_build_owner = authentication_mode == "held-build-artifact" && !production + && publisher.empty() && certificate_pin.empty() && spki_pin.empty(); + if (!allow_current_build_owner) { Throw(env, "MODULE_ARGUMENT"); return nullptr; } +#else + const bool allow_current_build_owner = false; + if (authentication_mode != "runtime") { Throw(env, "MODULE_ARGUMENT"); return nullptr; } +#endif + + // This handle denies write/delete sharing across authentication, loader + // mapping, loaded-image comparison and N-API registration. Consequently a + // hostile DllMain/NAPI image cannot be substituted at the pre-load barrier. + HANDLE held = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + FileIdInfo held_id{}; + std::string held_hash; +#if defined(PROPR_WINDOWS_BUILD_BOOTSTRAP) + SecureRegularFileFailure file_failure = SecureRegularFileFailure::None; + bool regular_file_valid = false; + bool architecture_valid = false; + bool hash_valid = false; + if (held != INVALID_HANDLE_VALUE) { + file_failure = DiagnoseSecureRegularFile( + held, expected_size, &held_id, allow_current_build_owner, allow_current_build_owner); + if (file_failure == SecureRegularFileFailure::None) + regular_file_valid = SecureRegularFile( + held, expected_size, &held_id, allow_current_build_owner, allow_current_build_owner); + if (regular_file_valid) architecture_valid = ExpectedArchitecture(held); + if (architecture_valid) { + hash_valid = Sha256Handle(held, expected_size, &held_hash) && held_hash == expected_hash; + } + } + const bool authenticated = held != INVALID_HANDLE_VALUE && regular_file_valid && architecture_valid && hash_valid + && (!production || VerifyPinnedSignature(path, held, publisher, certificate_pin, spki_pin)); +#else + const bool authenticated = held != INVALID_HANDLE_VALUE + && SecureRegularFile(held, expected_size, &held_id, allow_current_build_owner, allow_current_build_owner) + && ExpectedArchitecture(held) + && Sha256Handle(held, expected_size, &held_hash) && held_hash == expected_hash + && (!production || VerifyPinnedSignature(path, held, publisher, certificate_pin, spki_pin)); +#endif + if (!authenticated) { + if (held != INVALID_HANDLE_VALUE) CloseHandle(held); +#if defined(PROPR_WINDOWS_BUILD_BOOTSTRAP) + if (held == INVALID_HANDLE_VALUE) { Throw(env, "OPEN"); return nullptr; } + if (file_failure == SecureRegularFileFailure::FileMeta) { Throw(env, "FILE_META"); return nullptr; } + if (file_failure == SecureRegularFileFailure::Owner) { Throw(env, "OWNER"); return nullptr; } + if (file_failure == SecureRegularFileFailure::Dacl) { Throw(env, "DACL"); return nullptr; } + if (file_failure == SecureRegularFileFailure::DaclProtected) { Throw(env, "DACL_PROTECTED"); return nullptr; } + if (!regular_file_valid) { Throw(env, "MODULE_AUTHORITY"); return nullptr; } + if (!architecture_valid) { Throw(env, "ARCH"); return nullptr; } + if (!hash_valid) { Throw(env, "HASH"); return nullptr; } +#endif + Throw(env, "MODULE_AUTHORITY"); return nullptr; + } + if (fault.rfind("barrier-before-module-load-", 0) == 0 && !MutationWasDenied(path, fault)) { + CloseHandle(held); Throw(env, "MODULE_BARRIER"); return nullptr; + } + + HMODULE module = LoadLibraryExW(path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); + std::array loaded_path{}; + const DWORD loaded_length = module + ? GetModuleFileNameW(module, loaded_path.data(), static_cast(loaded_path.size())) : 0; + HANDLE loaded = loaded_length > 0 && loaded_length < loaded_path.size() + ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) + : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + const bool same_image = module && loaded != INVALID_HANDLE_VALUE + && SecureRegularFile(loaded, expected_size, &loaded_id, allow_current_build_owner, allow_current_build_owner) + && SameIdentity(held_id, loaded_id) + && Sha256Handle(loaded, expected_size, &loaded_hash) && loaded_hash == held_hash; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + if (!same_image) { + if (module) FreeLibrary(module); + CloseHandle(held); Throw(env, "MODULE_IMAGE"); return nullptr; + } + using RegisterModule = napi_value (*)(napi_env, napi_value); + auto* registration = reinterpret_cast(GetProcAddress(module, "napi_register_module_v1")); + napi_value exports; + if (!registration || napi_create_object(env, &exports) != napi_ok) { + FreeLibrary(module); CloseHandle(held); Throw(env, "MODULE_REGISTER"); return nullptr; + } + napi_value registered = registration(env, exports); + CloseHandle(held); + if (!registered) { Throw(env, "MODULE_REGISTER"); return nullptr; } + // Deliberately retain the authenticated module for the Node environment; + // unloading while exported functions remain reachable would be unsafe. + return registered; +} + +std::wstring Quote(const std::wstring& value) { + std::wstring result = L"\""; + for (wchar_t ch : value) { if (ch == L'\"') result += L'\\'; result += ch; } + return result + L"\" --broker"; +} + +napi_value Launch(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1) { Throw(env, "LAUNCH_ARGUMENT"); return nullptr; } + std::wstring path; + std::string expected_hash; + std::string fault; + std::string publisher, certificate_pin, spki_pin; + uint32_t expected_size = 0; + bool production = false; + if (!StringValue(env, args[0], "path", &path) || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) || expected_hash.size() != 64 + || !BoolValue(env, args[0], "production", &production) + || expected_size == 0 || expected_size > kMaxImageBytes) { Throw(env, "LAUNCH_ARGUMENT"); return nullptr; } + Utf8Value(env, args[0], "fault", &fault, true); + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + + HANDLE image = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (image == INVALID_HANDLE_VALUE) { Throw(env, "HELPER_OPEN"); return nullptr; } + FileIdInfo held_id{}; + std::string held_hash; + if (!SecureRegularFile(image, expected_size, &held_id, false) + || !Sha256Handle(image, expected_size, &held_hash) || held_hash != expected_hash + || (production && !VerifyPinnedSignature(path, image, publisher, certificate_pin, spki_pin))) { + CloseHandle(image); Throw(env, "HELPER_AUTHORITY"); return nullptr; + } + if (fault.rfind("barrier-after-hash-", 0) == 0 && !MutationWasDenied(path, fault)) { + CloseHandle(image); Throw(env, "HELPER_BARRIER"); return nullptr; + } + + HANDLE child_in_read = nullptr, parent_in_write = nullptr; + HANDLE parent_out_read = nullptr, child_out_write = nullptr; + HANDLE parent_err_read = nullptr, child_err_write = nullptr; + if (!PipePair(&child_in_read, &parent_in_write, false) + || !PipePair(&parent_out_read, &child_out_write, true) + || !PipePair(&parent_err_read, &child_err_write, true)) { + if (child_in_read) CloseHandle(child_in_read); + if (parent_in_write) CloseHandle(parent_in_write); + if (parent_out_read) CloseHandle(parent_out_read); + if (child_out_write) CloseHandle(child_out_write); + if (parent_err_read) CloseHandle(parent_err_read); + if (child_err_write) CloseHandle(child_err_write); + CloseHandle(image); Throw(env, "PIPE_CREATE"); return nullptr; + } + + SIZE_T attribute_bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_bytes); + std::vector attribute_storage(attribute_bytes); + auto* attributes = reinterpret_cast(attribute_storage.data()); + HANDLE inherited[] = {child_in_read, child_out_write, child_err_write}; + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_in_read; + startup.StartupInfo.hStdOutput = child_out_write; + startup.StartupInfo.hStdError = child_err_write; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process{}; + std::wstring command = Quote(path); + const std::wstring windows = SystemWindowsDirectory(); + std::wstring environment; + if (!fault.empty()) { + std::wstring wide_fault(fault.begin(), fault.end()); + if (fault == "stderr") environment += L"PROPR_WINDOWS_AUTHORITY_TEST_TRANSPORT_FAULT=stderr"; + else if (fault == "process-image") environment += L"PROPR_WINDOWS_AUTHORITY_TEST_IMAGE_FAULT=process-image"; + else environment += L"PROPR_WINDOWS_AUTHORITY_TEST_STAGE=" + wide_fault; + environment.push_back(L'\0'); + } + // CreateProcess requires a sorted Unicode environment block. The optional + // fixed PROPR_* test enum sorts before the sole production SystemRoot entry. + environment += L"SystemRoot=" + windows; + environment.push_back(L'\0'); + environment.push_back(L'\0'); + const bool attributes_initialized = InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_bytes) != FALSE; + const bool precreate_barrier = fault.rfind("barrier-before-create-", 0) != 0 || MutationWasDenied(path, fault); + bool created = precreate_barrier && attributes_initialized + && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) + && CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment.data(), nullptr, &startup.StartupInfo, &process); + if (attributes_initialized) DeleteProcThreadAttributeList(attributes); + CloseHandle(child_in_read); CloseHandle(child_out_write); CloseHandle(child_err_write); + if (!created) { + CloseHandle(parent_in_write); CloseHandle(parent_out_read); CloseHandle(parent_err_read); CloseHandle(image); + Throw(env, "PROCESS_CREATE"); return nullptr; + } + + HANDLE job = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + bool proven = job && SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) + && AssignProcessToJobObject(job, process.hProcess); + if (fault == "job-assignment") proven = false; + if (fault == "extra-child" && proven) { + STARTUPINFOW extra_startup{}; + extra_startup.cb = sizeof(extra_startup); + PROCESS_INFORMATION extra{}; + std::wstring extra_command = Quote(path); + const bool extra_created = CreateProcessW(path.c_str(), extra_command.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + environment.data(), nullptr, &extra_startup, &extra); + const bool process_limit_enforced = extra_created && !AssignProcessToJobObject(job, extra.hProcess); + if (extra_created) { + TerminateProcess(extra.hProcess, 127); + CloseHandle(extra.hThread); + CloseHandle(extra.hProcess); + } + proven = process_limit_enforced; + } + if (fault.rfind("barrier-after-process-", 0) == 0 && !MutationWasDenied(path, fault)) proven = false; + std::array loaded_path{}; + DWORD loaded_length = static_cast(loaded_path.size()); + proven = proven && QueryFullProcessImageNameW(process.hProcess, 0, loaded_path.data(), &loaded_length); + HANDLE loaded = proven ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + proven = proven && loaded != INVALID_HANDLE_VALUE && SecureRegularFile(loaded, expected_size, &loaded_id, false) + && SameIdentity(held_id, loaded_id) && Sha256Handle(loaded, expected_size, &loaded_hash) && loaded_hash == held_hash; + if (fault == "parent-image-proof" || fault == "pipe-substitution") proven = false; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + if (!proven || ResumeThread(process.hThread) == static_cast(-1)) { + TerminateProcess(process.hProcess, 127); CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (job) CloseHandle(job); + CloseHandle(parent_in_write); CloseHandle(parent_out_read); CloseHandle(parent_err_read); CloseHandle(image); + Throw(env, proven ? "PROCESS_RESUME" : "PROCESS_IMAGE"); return nullptr; + } + CloseHandle(process.hThread); + + auto* lease = new LaunchLease(); + lease->image = image; + lease->process = process.hProcess; + lease->job = job; + lease->stdin_fd = _open_osfhandle(reinterpret_cast(parent_in_write), _O_WRONLY | _O_BINARY); + if (lease->stdin_fd >= 0) parent_in_write = nullptr; + lease->stdout_fd = _open_osfhandle(reinterpret_cast(parent_out_read), _O_RDONLY | _O_BINARY); + if (lease->stdout_fd >= 0) parent_out_read = nullptr; + lease->stderr_fd = _open_osfhandle(reinterpret_cast(parent_err_read), _O_RDONLY | _O_BINARY); + if (lease->stderr_fd >= 0) parent_err_read = nullptr; + if (lease->stdin_fd < 0 || lease->stdout_fd < 0 || lease->stderr_fd < 0) { + CloseLease(lease); + if (parent_in_write) CloseHandle(parent_in_write); + if (parent_out_read) CloseHandle(parent_out_read); + if (parent_err_read) CloseHandle(parent_err_read); + delete lease; Throw(env, "PIPE_EXPORT"); return nullptr; + } + napi_value result, external, value; + napi_create_object(env, &result); + napi_create_external(env, lease, FinalizeLease, nullptr, &external); + napi_set_named_property(env, result, "lease", external); + napi_create_int32(env, lease->stdin_fd, &value); napi_set_named_property(env, result, "stdinFd", value); + napi_create_int32(env, lease->stdout_fd, &value); napi_set_named_property(env, result, "stdoutFd", value); + napi_create_int32(env, lease->stderr_fd, &value); napi_set_named_property(env, result, "stderrFd", value); + napi_create_uint32(env, process.dwProcessId, &value); napi_set_named_property(env, result, "pid", value); + char volume[17]{}; + sprintf_s(volume, "%016llx", held_id.volume); + napi_create_string_utf8(env, volume, NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "volumeSerial", value); + napi_create_string_utf8(env, Hex(held_id.id, sizeof(held_id.id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "fileId128", value); + return result; +} + +LaunchLease* LeaseArgument(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + void* data = nullptr; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_get_value_external(env, args[0], &data) != napi_ok) return nullptr; + return static_cast(data); +} + +napi_value Status(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed || !lease->process) { Throw(env, "LEASE_CLOSED"); return nullptr; } + DWORD code = 0; + if (!GetExitCodeProcess(lease->process, &code)) { Throw(env, "PROCESS_STATUS"); return nullptr; } + napi_value result; + if (code == STILL_ACTIVE) napi_get_null(env, &result); else napi_create_uint32(env, code, &result); + return result; +} + +napi_value CloseInput(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed) { Throw(env, "LEASE_CLOSED"); return nullptr; } + if (lease->stdin_fd >= 0) { _close(lease->stdin_fd); lease->stdin_fd = -1; } + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value Terminate(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed || !lease->process || !TerminateProcess(lease->process, 127)) { + Throw(env, "PROCESS_TERMINATE"); return nullptr; + } + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value Close(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease) { Throw(env, "LEASE_CLOSED"); return nullptr; } + CloseLease(lease); + napi_value result; napi_get_undefined(env, &result); return result; +} + +bool StringArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t chars = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &chars) != napi_ok + || chars == 0 || chars > 32767) return false; + std::vector buffer(chars + 1); + if (napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &chars) != napi_ok) return false; + result->emplace_back(reinterpret_cast(buffer.data()), chars); + } + return true; +} + +bool Uint32ArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + uint32_t number = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_uint32(env, value, &number) != napi_ok || number == 0 + || number > kMaxBuildInputBytes) return false; + result->push_back(number); + } + return true; +} + +bool Utf8ArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t bytes = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_string_utf8(env, value, nullptr, 0, &bytes) != napi_ok || bytes != 64) return false; + std::vector buffer(bytes + 1); + if (napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &bytes) != napi_ok) return false; + result->emplace_back(buffer.data(), bytes); + } + return true; +} + +std::wstring QuoteArgument(const std::wstring& value) { + if (value.find(L'"') != std::wstring::npos || value.find(L'\0') != std::wstring::npos) return {}; + return L"\"" + value + L"\""; +} + +bool SameHeldBuildInput(HANDLE handle, const FileIdInfo& expected_id, DWORD expected_size, + const std::string& expected_hash) { + FileIdInfo after_id{}; + std::string after_hash; + return SecureServicedSystemFile(handle, expected_size, &after_id) && SameIdentity(expected_id, after_id) + && Sha256Handle(handle, expected_size, &after_hash, kMaxBuildInputBytes) && after_hash == expected_hash; +} + +bool SameHeldCatalog(HANDLE handle, const FileIdInfo& expected_id, const std::string& expected_hash) { + LARGE_INTEGER size{}; + FileIdInfo after_id{}; + std::string after_hash; + return handle != INVALID_HANDLE_VALUE && GetFileSizeEx(handle, &size) + && size.QuadPart > 0 && size.QuadPart <= kMaxBuildInputBytes + && SecureServicedSystemFile(handle, static_cast(size.QuadPart), &after_id) + && SameIdentity(expected_id, after_id) + && Sha256Handle(handle, static_cast(size.QuadPart), &after_hash, kMaxBuildInputBytes) + && after_hash == expected_hash; +} + +napi_value CompileHeld(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1], source_value; + std::wstring system_root, output_path, working_directory; + std::vector paths; + std::vector sizes; + std::vector hashes; + std::string fault; + void* source_data = nullptr; + size_t source_size = 0; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "systemRoot", &system_root) + || !StringValue(env, args[0], "output", &output_path) + || !StringValue(env, args[0], "cwd", &working_directory) + || !StringArrayValue(env, args[0], "paths", 3, &paths) + || !Uint32ArrayValue(env, args[0], "sizes", 3, &sizes) + || !Utf8ArrayValue(env, args[0], "sha256", 3, &hashes) + || napi_get_named_property(env, args[0], "source", &source_value) != napi_ok + || napi_get_buffer_info(env, source_value, &source_data, &source_size) != napi_ok + || source_size == 0 || source_size > kMaxSourceBytes) { + Throw(env, "COMPILE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "fault", &fault, true); + const std::wstring expected_output = working_directory + L"\\propr-windows-authority.exe"; + if (_wcsicmp(output_path.c_str(), expected_output.c_str()) != 0 + || std::any_of(paths.begin(), paths.end(), [](const std::wstring& path) { + return path.find(L'"') != std::wstring::npos || path.find(L'\0') != std::wstring::npos; + })) { + Throw(env, "COMPILE_ARGUMENT"); return nullptr; + } + if (!CanonicalDirectory(system_root, false) || !ProtectPrivateBuildDirectory(working_directory)) { + Throw(env, "DIRECTORY_PROBE"); return nullptr; + } + HANDLE directory_lease = CreateFileW(working_directory.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + FileIdInfo directory_id{}; + if (directory_lease == INVALID_HANDLE_VALUE || !FileIdentity(directory_lease, &directory_id) + || !SecureObjectAcl(directory_lease, true)) { + if (directory_lease != INVALID_HANDLE_VALUE) CloseHandle(directory_lease); + Throw(env, "DIRECTORY_PROBE"); return nullptr; + } + + std::array inputs{INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE}; + std::array catalogs{INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE}; + std::array catalog_contexts{}; + std::array identities{}; + std::array catalog_identities{}; + std::array certificates, spkis, root_spkis, catalog_hashes, catalog_names; + std::array catalog_paths; + CatalogFailure catalog_failure = CatalogFailure::None; + bool inputs_valid = true; + size_t failed_input = inputs.size(); + for (size_t index = 0; index < inputs.size(); ++index) { + inputs[index] = CreateFileW(paths[index].c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (inputs[index] == INVALID_HANDLE_VALUE + || !SecureServicedSystemFile(inputs[index], sizes[index], &identities[index]) + || !Sha256Handle(inputs[index], sizes[index], &certificates[index], kMaxBuildInputBytes) + || certificates[index] != hashes[index]) { + inputs_valid = false; + failed_input = index; + break; + } + } + if (!inputs_valid) { + for (HANDLE handle : inputs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, failed_input == 0 ? "COMPILER_OPEN" : "REFERENCE_OPEN"); return nullptr; + } + for (size_t index = 0; index < inputs.size(); ++index) { + // Overwrite the temporary hash slot with actual signer evidence only after + // exact held-byte authentication. Catalog-signed serviced hard links are + // accepted; reparse points and user-writable aliases are not. + if (!VerifyMicrosoftCompilerInput(paths[index], inputs[index], &certificates[index], &spkis[index], + &root_spkis[index], &catalog_hashes[index], &catalog_names[index], &catalog_paths[index], + &catalog_identities[index], &catalogs[index], &catalog_contexts[index], &catalog_failure, + CatalogBindingFault::None)) { + inputs_valid = false; + break; + } + } + if (inputs_valid && fault == "compiler-nonmember") { + const std::wstring nonmember_path = working_directory + L"\\attacker-nonmember.bin"; + HANDLE nonmember = CreateFileW(nonmember_path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + DWORD written = 0; + bool rejected = nonmember != INVALID_HANDLE_VALUE + && WriteFile(nonmember, source_data, static_cast(source_size), &written, nullptr) + && written == source_size && FlushFileBuffers(nonmember) + && SetFilePointer(nonmember, 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER; + std::wstring unexpected_catalog_path; + std::string unexpected_catalog_hash; + FileIdInfo unexpected_catalog_id{}; + HANDLE unexpected_catalog = INVALID_HANDLE_VALUE; + CatalogContextLease unexpected_context{}; + CatalogFailure observed = CatalogFailure::None; + rejected = rejected && !VerifyCatalogTrust(nonmember_path, nonmember, &unexpected_catalog_path, + &unexpected_catalog_hash, &unexpected_catalog_id, &unexpected_catalog, &unexpected_context, &observed) + && observed == CatalogFailure::Enumeration; + if (unexpected_catalog != INVALID_HANDLE_VALUE) CloseHandle(unexpected_catalog); + if (nonmember != INVALID_HANDLE_VALUE) CloseHandle(nonmember); + DeleteFileW(nonmember_path.c_str()); + inputs_valid = false; + catalog_failure = rejected ? CatalogFailure::Enumeration : CatalogFailure::SignerParse; + } + if (inputs_valid && fault == "compiler-swapped-catalog") { + // Perform the pathname replacement while the exact catalog is leased. A + // denied mutation and a surprising successful mutation are both a fatal + // test outcome before the compiler process exists. + const bool denied = MutationWasDenied(catalog_paths[0], "swap"); + inputs_valid = false; + catalog_failure = denied ? CatalogFailure::CatalogLease : CatalogFailure::CatalogHash; + } + if (inputs_valid && (fault == "compiler-wrong-catalog" || fault == "compiler-unsigned-catalog")) { + // Materialize either the exact valid catalog or a deliberately corrupted + // copy outside canonical CatRoot. Even an exact basename/hash/signer copy + // cannot become authority at another location, and an unsigned copy must + // fail the standalone catalog signer parser. + const bool corrupt = fault == "compiler-unsigned-catalog"; + const std::wstring wrong_path = working_directory + L"\\" + BaseName(catalog_paths[0]); + HANDLE wrong_output = CreateFileW(wrong_path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + bool presented = wrong_output != INVALID_HANDLE_VALUE + && SetFilePointer(catalogs[0], 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER; + std::array bytes{}; + bool first = true; + while (presented) { + DWORD read = 0, written = 0; + if (!ReadFile(catalogs[0], bytes.data(), static_cast(bytes.size()), &read, nullptr)) { + presented = false; break; + } + if (read == 0) break; + if (corrupt && first) bytes[0] ^= 0xff; + first = false; + if (!WriteFile(wrong_output, bytes.data(), read, &written, nullptr) || written != read) { + presented = false; break; + } + } + if (wrong_output != INVALID_HANDLE_VALUE) { + presented = presented && FlushFileBuffers(wrong_output); + CloseHandle(wrong_output); + } + HANDLE wrong = CreateFileW(wrong_path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + LARGE_INTEGER wrong_size{}; + std::string wrong_hash, wrong_certificate, wrong_spki, wrong_root, wrong_subject; + presented = presented && wrong != INVALID_HANDLE_VALUE && GetFileSizeEx(wrong, &wrong_size) + && wrong_size.QuadPart > 0 && wrong_size.QuadPart <= kMaxBuildInputBytes + && Sha256Handle(wrong, static_cast(wrong_size.QuadPart), &wrong_hash, kMaxBuildInputBytes); + if (presented && corrupt) { + presented = !SignerEvidence(wrong, SignerContent::StandaloneCatalog, nullptr, + &wrong_certificate, &wrong_spki, &wrong_root, nullptr, &wrong_subject); + } else if (presented) { + FileIdInfo rejected_id{}; + HANDLE rejected_catalog = INVALID_HANDLE_VALUE; + std::string rejected_hash; + presented = wrong_hash == catalog_hashes[0] + && SignerEvidence(wrong, SignerContent::StandaloneCatalog, nullptr, + &wrong_certificate, &wrong_spki, &wrong_root, nullptr, &wrong_subject) + && MicrosoftSystemComponentAuthority(wrong_subject, wrong_root) + && !CanonicalMicrosoftCatalog(wrong_path, &rejected_hash, &rejected_id, &rejected_catalog) + && rejected_catalog == INVALID_HANDLE_VALUE; + if (rejected_catalog != INVALID_HANDLE_VALUE) CloseHandle(rejected_catalog); + } + if (wrong != INVALID_HANDLE_VALUE) CloseHandle(wrong); + DeleteFileW(wrong_path.c_str()); + inputs_valid = false; + catalog_failure = presented + ? (corrupt ? CatalogFailure::SignerParse : CatalogFailure::CatalogLease) + : CatalogFailure::CatalogHash; + } + if (inputs_valid && fault == "compiler-member-replacement") { + MutationWasDenied(paths[0], "swap"); + inputs_valid = false; + catalog_failure = CatalogFailure::CatalogLease; + } + if (!inputs_valid) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + const char* code = catalog_failure == CatalogFailure::None + ? "SIGNER_CATALOG" : CatalogFailureCode(catalog_failure); + Throw(env, code); + return nullptr; + } + if (fault == "compiler-held-member-identity-mismatch") identities[0].id[0] ^= 0xff; + if (fault == "compiler-held-catalog-identity-mismatch") catalog_identities[0].id[0] ^= 0xff; + if ((fault == "compiler-swap-after-open" && !MutationWasDenied(paths[0], "swap")) + || (fault == "reference-swap-after-open" && !MutationWasDenied(paths[1], "swap"))) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "LEASE"); return nullptr; + } + + std::array random{}; + if (BCryptGenRandom(nullptr, random.data(), static_cast(random.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "SOURCE_COPY"); return nullptr; + } + const std::string random_hex = Hex(random.data(), random.size()); + const std::wstring random_name(random_hex.begin(), random_hex.end()); + const std::wstring source_path = working_directory + L"\\source-" + random_name + L".cs"; + HANDLE source = CreateFileW(source_path.c_str(), GENERIC_READ | GENERIC_WRITE | READ_CONTROL, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + DWORD written = 0; + bool source_valid = source != INVALID_HANDLE_VALUE + && WriteFile(source, source_data, static_cast(source_size), &written, nullptr) && written == source_size + && FlushFileBuffers(source) && SetFilePointer(source, 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER; + FileIdInfo source_id{}; + std::string source_hash; + source_valid = source_valid && SecureRegularFile(source, static_cast(source_size), &source_id, false) + && Sha256Handle(source, static_cast(source_size), &source_hash); + if (!source_valid) { + if (source != INVALID_HANDLE_VALUE) CloseHandle(source); + DeleteFileW(source_path.c_str()); + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "SOURCE_COPY"); return nullptr; + } + if ((fault == "source-swap-after-copy" || fault == "source-rename" || fault == "source-reparse" + || fault == "source-replace") && !MutationWasDenied(source_path, "swap")) source_valid = false; + if (fault == "source-truncate" && !MutationWasDenied(source_path, "write")) source_valid = false; + if (fault == "source-hardlink") { + const std::wstring extra_link = source_path + L".link"; + CreateHardLinkW(extra_link.c_str(), source_path.c_str(), nullptr); + DeleteFileW(extra_link.c_str()); + } + if ((fault == "compiler-swap-before-create" && !MutationWasDenied(paths[0], "swap")) + || (fault == "reference-swap-before-create" && !MutationWasDenied(paths[1], "swap"))) source_valid = false; + + SECURITY_ATTRIBUTES inheritable{sizeof(inheritable), nullptr, TRUE}; + HANDLE child_stdin = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE child_stdout = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE child_stderr = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE inherited[] = {child_stdin, child_stdout, child_stderr}; + SIZE_T attribute_bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_bytes); + std::vector attribute_storage(attribute_bytes); + auto* attributes = reinterpret_cast(attribute_storage.data()); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_stdin; + startup.StartupInfo.hStdOutput = child_stdout; + startup.StartupInfo.hStdError = child_stderr; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process{}; + const std::wstring compiler_arg = QuoteArgument(paths[0]); + const std::wstring output_arg = QuoteArgument(L"/out:" + output_path); + const std::wstring reference_one = QuoteArgument(L"/reference:" + paths[1]); + const std::wstring reference_two = QuoteArgument(L"/reference:" + paths[2]); + const std::wstring source_arg = QuoteArgument(source_path); + std::wstring command = compiler_arg + L" /nologo /noconfig /target:exe /platform:anycpu /optimize+ /checked+" + L" /warnaserror+ " + output_arg + L" " + reference_one + L" " + reference_two + L" " + source_arg; + std::wstring environment = L"SystemRoot=" + system_root + L'\0' + L'\0'; + const bool attributes_initialized = child_stdin != INVALID_HANDLE_VALUE && child_stdout != INVALID_HANDLE_VALUE + && child_stderr != INVALID_HANDLE_VALUE && source_valid + && InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_bytes); + bool created = attributes_initialized + && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) + && CreateProcessW(paths[0].c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment.data(), working_directory.c_str(), &startup.StartupInfo, &process); + if (attributes_initialized) DeleteProcThreadAttributeList(attributes); + if (child_stdin != INVALID_HANDLE_VALUE) CloseHandle(child_stdin); + if (child_stdout != INVALID_HANDLE_VALUE) CloseHandle(child_stdout); + if (child_stderr != INVALID_HANDLE_VALUE) CloseHandle(child_stderr); + + HANDLE job = created ? CreateJobObjectW(nullptr, nullptr) : nullptr; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + bool image_proven = created && job && SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) + && AssignProcessToJobObject(job, process.hProcess) && fault != "compiler-job"; + std::array loaded_path{}; + DWORD loaded_length = static_cast(loaded_path.size()); + image_proven = image_proven && QueryFullProcessImageNameW(process.hProcess, 0, loaded_path.data(), &loaded_length); + HANDLE loaded = image_proven ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + image_proven = image_proven && loaded != INVALID_HANDLE_VALUE + && SecureServicedSystemFile(loaded, sizes[0], &loaded_id) && SameIdentity(identities[0], loaded_id) + && Sha256Handle(loaded, sizes[0], &loaded_hash, kMaxBuildInputBytes) && loaded_hash == hashes[0] + && fault != "compiler-image"; + if (fault == "compiler-swap-after-process" && !MutationWasDenied(paths[0], "swap")) image_proven = false; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + bool exited = image_proven && ResumeThread(process.hThread) != static_cast(-1) + && WaitForSingleObject(process.hProcess, 60'000) == WAIT_OBJECT_0; + DWORD exit_code = 1; + if (exited) exited = GetExitCodeProcess(process.hProcess, &exit_code) && exit_code == 0 && fault != "compiler-exit"; + if (created && (!exited || !image_proven)) { + TerminateProcess(process.hProcess, 127); + WaitForSingleObject(process.hProcess, 5'000); + } + if (created) { CloseHandle(process.hThread); CloseHandle(process.hProcess); } + + bool lease_proven = image_proven && exited; + FileIdInfo directory_after{}; + lease_proven = lease_proven && FileIdentity(directory_lease, &directory_after) + && SameIdentity(directory_id, directory_after) && SecureObjectAcl(directory_lease, true); + for (size_t index = 0; index < inputs.size(); ++index) { + lease_proven = lease_proven && SameHeldBuildInput(inputs[index], identities[index], sizes[index], hashes[index]); + lease_proven = lease_proven + && SameHeldCatalog(catalogs[index], catalog_identities[index], catalog_hashes[index]); + } + FileIdInfo source_after{}; + std::string source_after_hash; + lease_proven = lease_proven && SecureRegularFile(source, static_cast(source_size), &source_after, false) + && SameIdentity(source_id, source_after) + && Sha256Handle(source, static_cast(source_size), &source_after_hash) && source_after_hash == source_hash; + CloseHandle(source); + DeleteFileW(source_path.c_str()); + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) CloseHandle(handle); + + HANDLE output = lease_proven ? CreateFileW(output_path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + LARGE_INTEGER output_size{}; + FileIdInfo output_id{}; + std::string output_hash; + bool output_valid = output != INVALID_HANDLE_VALUE && GetFileSizeEx(output, &output_size) + && output_size.QuadPart > 0 && output_size.QuadPart <= kMaxImageBytes + && SecureRegularFile(output, static_cast(output_size.QuadPart), &output_id, false) + && Sha256Handle(output, static_cast(output_size.QuadPart), &output_hash) + && fault != "compiler-output"; + if (output != INVALID_HANDLE_VALUE) CloseHandle(output); + if (job) CloseHandle(job); + CloseHandle(directory_lease); + if (!created) { Throw(env, "SPAWN"); return nullptr; } + if (!image_proven) { Throw(env, "IMAGE"); return nullptr; } + if (!exited) { Throw(env, "EXIT"); return nullptr; } + if (!lease_proven) { Throw(env, "LEASE"); return nullptr; } + if (!output_valid) { Throw(env, "OUTPUT_VALIDATION"); return nullptr; } + + napi_value result, value; + napi_create_object(env, &result); + napi_create_uint32(env, static_cast(output_size.QuadPart), &value); + napi_set_named_property(env, result, "size", value); + napi_create_string_utf8(env, output_hash.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "sha256", value); + napi_create_string_utf8(env, certificates[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerCertificateSha256", value); + napi_create_string_utf8(env, spkis[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerSpkiSha256", value); + napi_create_string_utf8(env, root_spkis[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerRootSpkiSha256", value); + napi_value certificate_values, spki_values, root_values, catalog_name_values, catalog_values, + catalog_volume_values, catalog_id_values; + napi_create_array_with_length(env, inputs.size(), &certificate_values); + napi_create_array_with_length(env, inputs.size(), &spki_values); + napi_create_array_with_length(env, inputs.size(), &root_values); + napi_create_array_with_length(env, inputs.size(), &catalog_name_values); + napi_create_array_with_length(env, inputs.size(), &catalog_values); + napi_create_array_with_length(env, inputs.size(), &catalog_volume_values); + napi_create_array_with_length(env, inputs.size(), &catalog_id_values); + for (uint32_t index = 0; index < inputs.size(); ++index) { + napi_create_string_utf8(env, certificates[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, certificate_values, index, value); + napi_create_string_utf8(env, spkis[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, spki_values, index, value); + napi_create_string_utf8(env, root_spkis[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, root_values, index, value); + napi_create_string_utf8(env, catalog_names[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_name_values, index, value); + napi_create_string_utf8(env, catalog_hashes[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_values, index, value); + char catalog_volume[17]{}; + sprintf_s(catalog_volume, "%016llx", catalog_identities[index].volume); + napi_create_string_utf8(env, catalog_volume, NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_volume_values, index, value); + const std::string catalog_file_id = Hex(catalog_identities[index].id, sizeof(catalog_identities[index].id)); + napi_create_string_utf8(env, catalog_file_id.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_id_values, index, value); + } + napi_set_named_property(env, result, "inputCertificateSha256", certificate_values); + napi_set_named_property(env, result, "inputSpkiSha256", spki_values); + napi_set_named_property(env, result, "inputRootSpkiSha256", root_values); + napi_set_named_property(env, result, "inputCatalogName", catalog_name_values); + napi_set_named_property(env, result, "inputCatalogSha256", catalog_values); + napi_set_named_property(env, result, "inputCatalogVolumeSerial", catalog_volume_values); + napi_set_named_property(env, result, "inputCatalogFileId128", catalog_id_values); + char volume[17]{}; + sprintf_s(volume, "%016llx", identities[0].volume); + napi_create_string_utf8(env, volume, NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerVolumeSerial", value); + napi_create_string_utf8(env, Hex(identities[0].id, sizeof(identities[0].id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerFileId128", value); + return result; +} + +napi_value LeaseFiles(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + bool array = false; + uint32_t length = 0; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_is_array(env, args[0], &array) != napi_ok || !array + || napi_get_array_length(env, args[0], &length) != napi_ok || length != 4) { + Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + auto* leases = new FileLeases(); + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t chars = 0; + if (napi_get_element(env, args[0], index, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &chars) != napi_ok || chars == 0 || chars > 32767) { + CloseFileLeases(leases); delete leases; Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + std::vector buffer(chars + 1); + napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &chars); + const bool directory_expected = index == 0; + HANDLE file = CreateFileW(reinterpret_cast(buffer.data()), + (directory_expected ? FILE_READ_ATTRIBUTES : GENERIC_READ) | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | (directory_expected ? FILE_FLAG_BACKUP_SEMANTICS : FILE_FLAG_SEQUENTIAL_SCAN), + nullptr); + LARGE_INTEGER size{}; + FileIdInfo identity{}; + AttributeTagInfo tag{}; + const bool directory_valid = directory_expected && file != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && FileIdentity(file, &identity) + && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 && SecureObjectAcl(file); + const bool file_valid = !directory_expected && file != INVALID_HANDLE_VALUE + && GetFileSizeEx(file, &size) && size.QuadPart > 0 && size.QuadPart <= 32ll * 1024 * 1024 + && SecureRegularFile(file, static_cast(size.QuadPart), &identity, false); + if (!directory_valid && !file_valid) { + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + CloseFileLeases(leases); delete leases; Throw(env, "LEASE_AUTHORITY"); return nullptr; + } + leases->handles.push_back(file); + } + napi_value result; + napi_create_external(env, leases, FinalizeFileLeases, nullptr, &result); + return result; +} + +napi_value CloseFileLease(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + void* data = nullptr; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_get_value_external(env, args[0], &data) != napi_ok) { + Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + CloseFileLeases(static_cast(data)); + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value DangerousAclForTest(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring sddl; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "sddl", &sddl) || sddl.size() > 4096) { + Throw(env, "ACL_TEST_ARGUMENT"); return nullptr; + } + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + const bool parsed = ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &descriptor, nullptr) && GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted) && present && dacl; + if (!parsed) { + if (descriptor) LocalFree(descriptor); + Throw(env, "ACL_TEST_PARSE"); return nullptr; + } + const bool dangerous = DangerousUntrustedAcl(dacl, false); + LocalFree(descriptor); + napi_value result; + napi_get_boolean(env, dangerous, &result); + return result; +} + +napi_value VerifyModule(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring expected_path; + std::string expected_hash; + std::string publisher, certificate_pin, spki_pin; + uint32_t expected_size = 0; + bool production = false; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &expected_path) + || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) + || !BoolValue(env, args[0], "production", &production)) { + Throw(env, "MODULE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + HMODULE module = nullptr; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&VerifyModule), &module)) { Throw(env, "MODULE_IMAGE"); return nullptr; } + std::array path{}; + const DWORD length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size() || _wcsicmp(path.data(), expected_path.c_str()) != 0) { + Throw(env, "MODULE_IMAGE"); return nullptr; + } + HANDLE file = CreateFileW(path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + FileIdInfo identity{}; + std::string hash; + const bool valid = file != INVALID_HANDLE_VALUE && SecureRegularFile(file, expected_size, &identity, false) + && ExpectedArchitecture(file) && Sha256Handle(file, expected_size, &hash) && hash == expected_hash + && (!production || VerifyPinnedSignature(path.data(), file, publisher, certificate_pin, spki_pin)); + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + if (!valid) { Throw(env, "MODULE_AUTHORITY"); return nullptr; } + napi_value result, value; + napi_create_object(env, &result); + napi_create_string_utf8(env, hash.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, result, "sha256", value); +#if defined(_M_ARM64) + napi_create_string_utf8(env, "arm64", NAPI_AUTO_LENGTH, &value); +#else + napi_create_string_utf8(env, "x64", NAPI_AUTO_LENGTH, &value); +#endif + napi_set_named_property(env, result, "architecture", value); + napi_create_string_utf8(env, Hex(identity.id, sizeof(identity.id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "fileId128", value); + return result; +} + +napi_value Init(napi_env env, napi_value exports) { +#if defined(PROPR_WINDOWS_MALICIOUS_BOOTSTRAP) + std::array side_effect{}; + const DWORD side_effect_length = GetEnvironmentVariableW(L"PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT", + side_effect.data(), static_cast(side_effect.size())); + if (side_effect_length > 0 && side_effect_length < side_effect.size()) { + HANDLE marker = CreateFileW(side_effect.data(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (marker != INVALID_HANDLE_VALUE) CloseHandle(marker); + } +#endif +#if defined(PROPR_WINDOWS_BOOTSTRAP_ONLY) + napi_property_descriptor properties[] = { + {"loadVerifiedModule", nullptr, LoadVerifiedModule, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; +#else + napi_property_descriptor properties[] = { + {"probeSystemDirectory", nullptr, ProbeSystemDirectory, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"protectPrivateDirectory", nullptr, ProtectPrivateDirectory, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"verifyPrivateDirectoryForTest", nullptr, VerifyPrivateDirectoryForTest, + nullptr, nullptr, nullptr, napi_default, nullptr}, + {"launch", nullptr, Launch, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"status", nullptr, Status, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"closeInput", nullptr, CloseInput, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"terminate", nullptr, Terminate, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"close", nullptr, Close, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"compileHeld", nullptr, CompileHeld, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"leaseFiles", nullptr, LeaseFiles, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"closeFileLease", nullptr, CloseFileLease, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"dangerousAclForTest", nullptr, DangerousAclForTest, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"microsoftSystemComponentForTest", nullptr, MicrosoftSystemComponentForTest, + nullptr, nullptr, nullptr, napi_default, nullptr}, + }; +#endif + napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties); + return exports; +} +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/apps/desktop/src/packaged-approval-session.test.ts b/apps/desktop/src/packaged-approval-session.test.ts new file mode 100644 index 000000000..07de6bc20 --- /dev/null +++ b/apps/desktop/src/packaged-approval-session.test.ts @@ -0,0 +1,395 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; +import type { BrowserWindow, Session } from 'electron'; +import { + clearPackagedApprovalStorage, + createPackagedApprovalNavigation, + createPackagedApprovalTaskTracker, + packagedApprovalPartition, +} from './packaged-approval-session'; + +const approvalUrl = `http://127.0.0.1:41731/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`; + +type Callback = (decision: T) => void; +type RequestHandler = (details: Record, callback: Callback>) => void; +type RedirectHandler = (details: Record) => void; +type CompletedHandler = (details: Record) => void; + +class FakeWebRequest { + beforeRequest: RequestHandler | null = null; + beforeSendHeaders: RequestHandler | null = null; + sendHeaders: RedirectHandler | null = null; + headersReceived: RequestHandler | null = null; + beforeRedirect: RedirectHandler | null = null; + completed: CompletedHandler | null = null; + + onBeforeRequest(handler: RequestHandler | null): void { this.beforeRequest = handler; } + onBeforeSendHeaders(handler: RequestHandler | null): void { this.beforeSendHeaders = handler; } + onSendHeaders(handler: RedirectHandler | null): void { this.sendHeaders = handler; } + onHeadersReceived(handler: RequestHandler | null): void { this.headersReceived = handler; } + onBeforeRedirect(handler: RedirectHandler | null): void { this.beforeRedirect = handler; } + onCompleted(handler: CompletedHandler | null): void { this.completed = handler; } +} + +class FakeSession extends EventEmitter { + readonly webRequest = new FakeWebRequest(); + permissionCheck: ((...values: unknown[]) => boolean) | null = null; + permissionRequest: ((...values: unknown[]) => void) | null = null; + clearCount = 0; + + setPermissionCheckHandler(handler: ((...values: unknown[]) => boolean) | null): void { + this.permissionCheck = handler; + } + + setPermissionRequestHandler(handler: ((...values: unknown[]) => void) | null): void { + this.permissionRequest = handler; + } + + async clearStorageData(): Promise { this.clearCount += 1; } +} + +class FakeContents extends EventEmitter { + readonly id = 91; + readonly mainFrame = { detached: false, parent: null }; + currentUrl = ''; + openHandler: (() => { action: 'deny' }) | null = null; + + constructor(readonly session: FakeSession) { super(); } + + setWindowOpenHandler(handler: () => { action: 'deny' }): void { this.openHandler = handler; } + getURL(): string { return this.currentUrl; } +} + +class FakeWindow { + readonly webContents: FakeContents; + destroyed = false; + destroyCount = 0; + load: (url: string) => Promise = async () => undefined; + + constructor(approvalSession: FakeSession) { + this.webContents = new FakeContents(approvalSession); + } + + loadURL(url: string): Promise { return this.load(url); } + isDestroyed(): boolean { return this.destroyed; } + destroy(): void { this.destroyed = true; this.destroyCount += 1; } +} + +const event = () => { + let prevented = false; + return { + preventDefault: () => { prevented = true; }, + get prevented() { return prevented; }, + }; +}; + +const decision = async ( + handler: RequestHandler | null, + details: Record, +): Promise> => { + assert.ok(handler); + return await new Promise(resolve => handler(details, resolve)); +}; + +interface Harness { + approvalSession: FakeSession; + defaultSession: FakeSession; + window: FakeWindow; + requestHeaders: Record; + responseHeaders: Record; +} + +const harness = (statusCode = 200): Harness => { + const approvalSession = new FakeSession(); + const defaultSession = new FakeSession(); + const window = new FakeWindow(approvalSession); + const requestHeaders: Record = { Accept: 'text/html' }; + const responseHeaders: Record = { + 'Content-Type': ['text/html'], + 'Set-Cookie': ['approval=secret'], + }; + window.load = async url => { + const details = { + id: 7, + url, + method: 'GET', + webContentsId: window.webContents.id, + webContents: window.webContents, + frame: window.webContents.mainFrame, + resourceType: 'mainFrame', + }; + const start = await decision(approvalSession.webRequest.beforeRequest, details); + if (start.cancel === true) throw new Error('cancelled'); + const outgoing = await decision(approvalSession.webRequest.beforeSendHeaders, { + ...details, + requestHeaders, + }); + if (outgoing.cancel === true) throw new Error('cancelled'); + Object.assign(requestHeaders, outgoing.requestHeaders); + approvalSession.webRequest.sendHeaders?.({ + ...details, + requestHeaders, + }); + const incoming = await decision(approvalSession.webRequest.headersReceived, { + ...details, + statusCode, + responseHeaders, + }); + if (incoming.cancel === true) throw new Error('cancelled'); + for (const name of Object.keys(responseHeaders)) delete responseHeaders[name]; + Object.assign(responseHeaders, incoming.responseHeaders); + window.webContents.currentUrl = url; + window.webContents.emit('did-frame-navigate', event(), url, statusCode, 'OK', true, 1, 1); + approvalSession.webRequest.completed?.({ ...details, statusCode }); + }; + return { approvalSession, defaultSession, window, requestHeaders, responseHeaders }; +}; + +const controllerFor = (value: Harness) => createPackagedApprovalNavigation({ + approvalUrl, + approvalSession: value.approvalSession as unknown as Session, + approvalWindow: value.window as unknown as BrowserWindow, + defaultSession: value.defaultSession as unknown as Session, +}); + +describe('packaged pairing approval isolated session', () => { + it('uses non-persistent unique partition names and rejects invalid entropy', () => { + const first = packagedApprovalPartition('a'.repeat(32)); + const second = packagedApprovalPartition('b'.repeat(32)); + assert.notEqual(first, second); + assert.equal(first.startsWith('persist:'), false); + assert.throws(() => packagedApprovalPartition('../shared')); + }); + + it('allows one exact credentialless main-frame GET and strips response cookies', async () => { + const value = harness(); + const controller = controllerFor(value); + assert.equal(value.approvalSession.permissionCheck?.(), false); + let permissionAllowed = true; + value.approvalSession.permissionRequest?.(null, 'notifications', (allowed: boolean) => { + permissionAllowed = allowed; + }); + assert.equal(permissionAllowed, false); + + await controller.navigate(); + + assert.equal(Object.keys(value.requestHeaders).some(name => /^(authorization|cookie)$/iu.test(name)), false); + assert.equal(Object.keys(value.responseHeaders).some(name => /^set-cookie2?$/iu.test(name)), false); + await controller.cleanup(); + }); + + it('waits for an exact completion event that arrives after loadURL resolves', async () => { + const value = harness(); + const controller = controllerFor(value); + const originalLoad = value.window.load; + let finishCompletion: (() => void) | undefined; + value.window.load = async url => { + const onCompleted = value.approvalSession.webRequest.completed; + assert.ok(onCompleted); + value.approvalSession.webRequest.completed = details => { + finishCompletion = () => onCompleted(details); + }; + await originalLoad(url); + }; + + const navigation = controller.navigate(); + let settled = false; + void navigation.finally(() => { settled = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(finishCompletion); + assert.equal(settled, false); + finishCompletion(); + await navigation; + assert.equal(settled, true); + await controller.cleanup(); + }); + + it('reports owned approval readiness and drains delayed work before the next pairing case', async () => { + const requested: string[] = []; + const releases: Array<() => void> = []; + const tracker = createPackagedApprovalTaskTracker(async request => { + requested.push(request); + await new Promise(resolve => { releases.push(resolve); }); + }); + + for (const request of ['expiry', 'cancel', 'success']) { + let ready = false; + const readiness = tracker.waitForNextOpen().then(() => { ready = true; }); + const concurrentReadiness = tracker.waitForNextOpen(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(ready, false); + const opened = tracker.open(request); + await Promise.all([readiness, concurrentReadiness]); + assert.equal(ready, true); + let idle = false; + const drained = tracker.waitForIdle().then(() => { idle = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(idle, false); + assert.equal(requested.filter(value => value === request).length, 1); + releases.shift()?.(); + await Promise.all([opened, drained]); + assert.equal(idle, true); + } + + assert.deepEqual(requested, ['expiry', 'cancel', 'success']); + }); + + it('cancels an incidental resource without invalidating the exact main-frame approval', async () => { + const value = harness(); + const originalLoad = value.window.load; + value.window.load = async url => { + const incidental = await decision(value.approvalSession.webRequest.beforeRequest, { + id: 6, + url: 'http://127.0.0.1:41731/favicon.ico', + method: 'GET', + webContentsId: value.window.webContents.id, + webContents: value.window.webContents, + frame: value.window.webContents.mainFrame, + resourceType: 'image', + }); + assert.deepEqual(incidental, { cancel: true }); + await originalLoad(url); + }; + + const controller = controllerFor(value); + await controller.navigate(); + await controller.cleanup(); + }); + + it('rejects redirects, alternate origins and paths, methods, subframes, and credential headers', async t => { + for (const scenario of [ + 'redirect', + 'off-origin', + 'path', + 'method', + 'subframe', + 'status', + 'authorization', + 'cookie', + ] as const) { + await t.test(scenario, async () => { + const value = harness(scenario === 'status' ? 204 : 200); + const original = value.window.load; + if (scenario === 'redirect') { + value.window.load = async url => { + value.approvalSession.webRequest.beforeRedirect?.({ + id: 7, + url, + method: 'GET', + redirectURL: 'https://attacker.example.test/', + }); + const redirect = event(); + value.window.webContents.emit('will-redirect', redirect); + assert.equal(redirect.prevented, true); + throw new Error('redirect cancelled'); + }; + } else if (scenario === 'status') { + // The default loader supplies a non-exact successful response status. + } else if (scenario === 'authorization' || scenario === 'cookie') { + value.requestHeaders[scenario === 'authorization' ? 'Authorization' : 'Cookie'] = 'secret'; + } else { + value.window.load = async () => { + const changed = { + id: 7, + url: scenario === 'off-origin' + ? 'http://127.0.0.2:41731/api/desktop/pairings/other/browser' + : scenario === 'path' + ? `${approvalUrl}/extra` + : approvalUrl, + method: scenario === 'method' ? 'POST' : 'GET', + webContentsId: value.window.webContents.id, + webContents: value.window.webContents, + frame: scenario === 'subframe' ? { parent: value.window.webContents.mainFrame } : value.window.webContents.mainFrame, + resourceType: scenario === 'subframe' ? 'subFrame' : 'mainFrame', + }; + const result = await decision(value.approvalSession.webRequest.beforeRequest, changed); + assert.deepEqual(result, { cancel: true }); + throw new Error('cancelled'); + }; + } + const controller = controllerFor(value); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + await controller.cleanup(); + value.window.load = original; + }); + } + }); + + it('rejects popups, downloads, webviews, and external renderer navigation', async t => { + for (const scenario of ['popup', 'download', 'webview', 'navigation'] as const) { + await t.test(scenario, async () => { + const value = harness(); + const original = value.window.load; + value.window.load = async url => { + await original(url); + const blocked = event(); + if (scenario === 'popup') { + assert.deepEqual(value.window.webContents.openHandler?.(), { action: 'deny' }); + } else if (scenario === 'download') { + value.approvalSession.emit('will-download', blocked); + } else if (scenario === 'webview') { + value.window.webContents.emit('will-attach-webview', blocked); + } else { + value.window.webContents.emit('will-navigate', blocked); + } + if (scenario !== 'popup') assert.equal(blocked.prevented, true); + }; + const controller = controllerFor(value); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + await controller.cleanup(); + }); + } + }); + + it('rejects default/mismatched/reused sessions and cleans up idempotently', async () => { + const defaultValue = harness(); + assert.throws(() => createPackagedApprovalNavigation({ + approvalUrl, + approvalSession: defaultValue.defaultSession as unknown as Session, + approvalWindow: defaultValue.window as unknown as BrowserWindow, + defaultSession: defaultValue.defaultSession as unknown as Session, + }), { message: 'Packaged pairing browser approval was rejected' }); + + const value = harness(); + const controller = controllerFor(value); + assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' }); + await controller.navigate(); + await assert.rejects(controller.navigate(), { message: 'Packaged pairing browser approval was rejected' }); + const firstCleanup = controller.cleanup(); + const secondCleanup = controller.cleanup(); + assert.equal(firstCleanup, secondCleanup); + await Promise.all([firstCleanup, secondCleanup]); + + assert.equal(value.window.destroyCount, 1); + assert.equal(value.approvalSession.clearCount, 1); + assert.equal(value.approvalSession.permissionCheck, null); + assert.equal(value.approvalSession.permissionRequest, null); + assert.equal(value.approvalSession.listenerCount('will-download'), 0); + assert.equal(value.window.webContents.listenerCount('will-navigate'), 0); + assert.equal(value.approvalSession.webRequest.beforeRequest, null); + assert.equal(value.approvalSession.webRequest.beforeSendHeaders, null); + assert.equal(value.approvalSession.webRequest.sendHeaders, null); + assert.equal(value.approvalSession.webRequest.headersReceived, null); + assert.equal(value.approvalSession.webRequest.beforeRedirect, null); + assert.equal(value.approvalSession.webRequest.completed, null); + }); + + it('bounds storage cleanup, reports failures, and never makes the session reusable', async () => { + const stalled = new FakeSession(); + stalled.clearStorageData = () => new Promise(() => undefined); + await assert.rejects( + clearPackagedApprovalStorage(stalled as unknown as Session, 5), + { message: 'Packaged pairing browser approval cleanup failed' }, + ); + + const value = harness(); + value.approvalSession.clearStorageData = async () => { throw new Error('private cleanup detail'); }; + const controller = controllerFor(value); + await controller.navigate(); + const firstCleanup = controller.cleanup(); + assert.equal(firstCleanup, controller.cleanup()); + await assert.rejects(firstCleanup, { message: 'Packaged pairing browser approval cleanup failed' }); + assert.throws(() => controllerFor(value), { message: 'Packaged pairing browser approval was rejected' }); + }); +}); diff --git a/apps/desktop/src/packaged-approval-session.ts b/apps/desktop/src/packaged-approval-session.ts new file mode 100644 index 000000000..230d0283c --- /dev/null +++ b/apps/desktop/src/packaged-approval-session.ts @@ -0,0 +1,389 @@ +import type { + BrowserWindow, + Event as ElectronEvent, + OnBeforeRedirectListenerDetails, + OnBeforeRequestListenerDetails, + OnBeforeSendHeadersListenerDetails, + OnCompletedListenerDetails, + OnHeadersReceivedListenerDetails, + OnSendHeadersListenerDetails, + Session, + WebContentsWillNavigateEventParams, + WebContentsWillRedirectEventParams, +} from 'electron'; + +const APPROVAL_REJECTED = 'Packaged pairing browser approval was rejected'; +const APPROVAL_CLEANUP_REJECTED = 'Packaged pairing browser approval cleanup failed'; +const APPROVAL_STATUS = 200; +const APPROVAL_COMPLETION_TIMEOUT_MS = 5_000; +const APPROVAL_CLEANUP_TIMEOUT_MS = 5_000; +const claimedSessions = new WeakSet(); + +export const packagedApprovalPartition = (nonce: string): string => { + if (!/^[a-f0-9]{32}$/u.test(nonce)) throw rejected(); + return `propr-packaged-approval-${nonce}`; +}; + +export interface PackagedApprovalNavigation { + navigate(): Promise; + cleanup(): Promise; +} + +export interface PackagedApprovalTaskTracker { + open(request: Request): Promise; + waitForNextOpen(): Promise; + waitForIdle(): Promise; +} + +interface PackagedApprovalNavigationOptions { + approvalUrl: string; + approvalSession: Session; + approvalWindow: BrowserWindow; + defaultSession: Session; +} + +function rejected(): Error { + return new Error(APPROVAL_REJECTED); +} + +function cleanupRejected(): Error { + return new Error(APPROVAL_CLEANUP_REJECTED); +} + +/** + * Keep acceptance-only browser work owned after pairing expiry/cancellation. + * The pairing protocol deliberately stops awaiting a browser callback once its + * lifetime ends, but a packaged smoke must still observe the one HTTP approval + * that it started before advancing the shared fixture or publishing READY. + */ +export const createPackagedApprovalTaskTracker = ( + open: (request: Request) => Promise, +): PackagedApprovalTaskTracker => { + const active = new Set>(); + const nextOpenWaiters = new Set<{ after: number; resolve(): void }>(); + let openGeneration = 0; + let rejectedTask = false; + + return { + open(request) { + const task = Promise.resolve().then(() => open(request)); + active.add(task); + openGeneration += 1; + for (const waiter of nextOpenWaiters) { + if (waiter.after >= openGeneration) continue; + nextOpenWaiters.delete(waiter); + waiter.resolve(); + } + void task.then( + () => active.delete(task), + () => { + rejectedTask = true; + active.delete(task); + }, + ); + return task; + }, + waitForNextOpen() { + const after = openGeneration; + return new Promise(resolve => nextOpenWaiters.add({ after, resolve })); + }, + async waitForIdle() { + while (active.size > 0) { + await Promise.all(Array.from(active, task => task.then( + () => undefined, + () => undefined, + ))); + } + if (rejectedTask) throw rejected(); + }, + }; +}; + +export const clearPackagedApprovalStorage = async ( + approvalSession: Pick, + timeoutMs = APPROVAL_CLEANUP_TIMEOUT_MS, +): Promise => { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > APPROVAL_CLEANUP_TIMEOUT_MS) { + throw cleanupRejected(); + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + approvalSession.clearStorageData(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(cleanupRejected()), timeoutMs); + }), + ]); + } catch { + throw cleanupRejected(); + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +const containsCredentialHeaders = (headers: Record): boolean => + Object.keys(headers).some(name => { + const normalized = name.toLowerCase(); + return normalized === 'authorization' || normalized === 'cookie' || normalized === 'proxy-authorization'; + }); + +const withoutSetCookie = ( + headers: Record | undefined, +): Record => Object.fromEntries( + Object.entries(headers ?? {}).filter(([name]) => { + const normalized = name.toLowerCase(); + return normalized !== 'set-cookie' && normalized !== 'set-cookie2'; + }), +); + +/** + * Constrain the packaged acceptance harness to one isolated, credentialless browser + * navigation. This session is deliberately unrelated to the production renderer + * and credential transport session. + */ +export const createPackagedApprovalNavigation = ({ + approvalUrl, + approvalSession, + approvalWindow, + defaultSession, +}: PackagedApprovalNavigationOptions): PackagedApprovalNavigation => { + const contents = approvalWindow.webContents; + if (approvalSession === defaultSession + || contents.session !== approvalSession + || claimedSessions.has(approvalSession)) { + throw rejected(); + } + claimedSessions.add(approvalSession); + + let active = true; + let navigated = false; + let allowedRequestId: number | null = null; + let responseStatus: number | null = null; + let requestSent = false; + let committedStatus: number | null = null; + let committedUrl: string | null = null; + let completedStatus: number | null = null; + let boundaryRejected = false; + let completionResolve: (() => void) | null = null; + let cleanupPromise: Promise | null = null; + + const releaseCompletionWait = (): void => { + completionResolve?.(); + completionResolve = null; + }; + const rejectBoundary = (): void => { + boundaryRejected = true; + releaseCompletionWait(); + }; + const ownsMainFrame = (details: { + webContentsId?: number; + webContents?: Electron.WebContents; + frame?: Electron.WebFrameMain | null; + resourceType: string; + }): boolean => details.webContentsId === contents.id + && (details.webContents === undefined || details.webContents === contents) + && details.resourceType === 'mainFrame' + && (details.frame === undefined || details.frame === contents.mainFrame); + + const exactAllowedRequest = (details: { + id: number; + url: string; + method: string; + webContentsId?: number; + webContents?: Electron.WebContents; + frame?: Electron.WebFrameMain | null; + resourceType: string; + }): boolean => active + && details.id === allowedRequestId + && details.url === approvalUrl + && details.method === 'GET' + && ownsMainFrame(details); + + approvalSession.setPermissionCheckHandler(() => false); + approvalSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + + const onBeforeRequest = (details: OnBeforeRequestListenerDetails, callback: (decision: { + cancel?: boolean; + }) => void): void => { + if (details.resourceType !== 'mainFrame') { + if (details.resourceType === 'subFrame') rejectBoundary(); + callback({ cancel: true }); + return; + } + const allowed = active + && allowedRequestId === null + && details.url === approvalUrl + && details.method === 'GET' + && ownsMainFrame(details); + if (!allowed) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + allowedRequestId = details.id; + callback({}); + }; + + const onBeforeSendHeaders = ( + details: OnBeforeSendHeadersListenerDetails, + callback: (decision: { cancel?: boolean; requestHeaders?: Record }) => void, + ): void => { + if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + callback({ requestHeaders: details.requestHeaders }); + }; + + const onHeadersReceived = ( + details: OnHeadersReceivedListenerDetails, + callback: (decision: { cancel?: boolean; responseHeaders?: Record }) => void, + ): void => { + if (!exactAllowedRequest(details) || details.statusCode !== APPROVAL_STATUS) { + rejectBoundary(); + callback({ cancel: true }); + return; + } + responseStatus = details.statusCode; + callback({ responseHeaders: withoutSetCookie(details.responseHeaders) }); + }; + + const onSendHeaders = (details: OnSendHeadersListenerDetails): void => { + if (!exactAllowedRequest(details) || containsCredentialHeaders(details.requestHeaders)) { + rejectBoundary(); + return; + } + requestSent = true; + }; + + const onBeforeRedirect = (_details: OnBeforeRedirectListenerDetails): void => { + rejectBoundary(); + }; + const onCompleted = (details: OnCompletedListenerDetails): void => { + if (!exactAllowedRequest(details) || details.statusCode !== responseStatus) { + rejectBoundary(); + return; + } + completedStatus = details.statusCode; + releaseCompletionWait(); + }; + approvalSession.webRequest.onBeforeRequest(onBeforeRequest); + approvalSession.webRequest.onBeforeSendHeaders(onBeforeSendHeaders); + approvalSession.webRequest.onSendHeaders(onSendHeaders); + approvalSession.webRequest.onHeadersReceived(onHeadersReceived); + approvalSession.webRequest.onBeforeRedirect(onBeforeRedirect); + approvalSession.webRequest.onCompleted(onCompleted); + + const onWillNavigate = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onWillRedirect = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onDidFrameNavigate = ( + _event: ElectronEvent, + url: string, + status: number, + _statusText: string, + isMainFrame: boolean, + ): void => { + if (!isMainFrame || url !== approvalUrl || status !== responseStatus) { + rejectBoundary(); + return; + } + committedUrl = url; + committedStatus = status; + }; + const onDidNavigateInPage = ( + _event: ElectronEvent, + url: string, + isMainFrame: boolean, + ): void => { + // An exact no-op history replacement is the only same-document behavior allowed. + if (!isMainFrame || url !== approvalUrl) rejectBoundary(); + }; + const onWillAttachWebview = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + const onWillDownload = (event: ElectronEvent): void => { + rejectBoundary(); + event.preventDefault(); + }; + + contents.setWindowOpenHandler(() => { + rejectBoundary(); + return { action: 'deny' }; + }); + contents.on('will-navigate', onWillNavigate); + contents.on('will-redirect', onWillRedirect); + contents.on('did-frame-navigate', onDidFrameNavigate); + contents.on('did-navigate-in-page', onDidNavigateInPage); + contents.on('will-attach-webview', onWillAttachWebview); + approvalSession.on('will-download', onWillDownload); + + return { + async navigate() { + if (!active || navigated) throw rejected(); + navigated = true; + const completion = completedStatus !== null || boundaryRejected + ? Promise.resolve() + : new Promise(resolve => { completionResolve = resolve; }); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + (async () => { + await approvalWindow.loadURL(approvalUrl); + await completion; + })(), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(rejected()), APPROVAL_COMPLETION_TIMEOUT_MS); + }), + ]); + } catch { + throw rejected(); + } finally { + if (timeout) clearTimeout(timeout); + completionResolve = null; + } + if (!active + || boundaryRejected + || allowedRequestId === null + || !requestSent + || responseStatus === null + || responseStatus !== committedStatus + || responseStatus !== completedStatus + || committedUrl !== approvalUrl + || contents.getURL() !== approvalUrl) { + throw rejected(); + } + }, + cleanup() { + if (cleanupPromise) return cleanupPromise; + cleanupPromise = (async () => { + active = false; + releaseCompletionWait(); + if (!approvalWindow.isDestroyed()) approvalWindow.destroy(); + contents.off('will-navigate', onWillNavigate); + contents.off('will-redirect', onWillRedirect); + contents.off('did-frame-navigate', onDidFrameNavigate); + contents.off('did-navigate-in-page', onDidNavigateInPage); + contents.off('will-attach-webview', onWillAttachWebview); + approvalSession.off('will-download', onWillDownload); + approvalSession.setPermissionCheckHandler(null); + approvalSession.setPermissionRequestHandler(null); + approvalSession.webRequest.onBeforeRequest(null); + approvalSession.webRequest.onBeforeSendHeaders(null); + approvalSession.webRequest.onSendHeaders(null); + approvalSession.webRequest.onHeadersReceived(null); + approvalSession.webRequest.onBeforeRedirect(null); + approvalSession.webRequest.onCompleted(null); + await clearPackagedApprovalStorage(approvalSession); + })(); + return cleanupPromise; + }, + }; +}; diff --git a/apps/desktop/src/pairing-browser.test.ts b/apps/desktop/src/pairing-browser.test.ts new file mode 100644 index 000000000..8d0c7c873 --- /dev/null +++ b/apps/desktop/src/pairing-browser.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { openApprovedDesktopPairingUrl } from './pairing-browser'; + +const pairingId = `dpr_${'A'.repeat(22)}`; +const fallback = `https://api.example.test/api/desktop/pairings/${pairingId}/browser`; + +describe('desktop pairing browser final sink', () => { + it('opens only the exact canonical API browser route', async () => { + const opened: string[] = []; + await openApprovedDesktopPairingUrl({ + apiBaseUrl: 'https://api.example.test', + pairingId, + approvalUrl: fallback, + }, { openExternal: async url => { opened.push(url); } }); + + assert.deepEqual(opened, [fallback]); + }); + + it('opens the exact hosted Connect approval bound to the verified tunnel', async () => { + const approvalUrl = `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.propr.dev`; + const opened: string[] = []; + await openApprovedDesktopPairingUrl({ + apiBaseUrl: 'https://t-instance123.propr.dev', + pairingId, + approvalUrl, + }, { openExternal: async url => { opened.push(url); } }); + + assert.deepEqual(opened, [approvalUrl]); + }); + + it('rejects replacement, mutation, noncanonical, and reserved-host values without opening', async () => { + const opened: string[] = []; + for (const approvalUrl of [ + `https://api.example.test/api/desktop/pairings/dpr_${'B'.repeat(22)}/browser`, + `${fallback}?next=https://attacker.example`, + `https://api.example.test:443/api/desktop/pairings/${pairingId}/browser`, + `https://x.t-instance123.propr.dev/api/desktop/pairings/${pairingId}/browser`, + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-replaced456.propr.dev`, + ]) { + await assert.rejects( + openApprovedDesktopPairingUrl({ + apiBaseUrl: approvalUrl.includes('app.propr.dev') + ? 'https://t-instance123.propr.dev' + : 'https://api.example.test', + pairingId, + approvalUrl, + }, { openExternal: async url => { opened.push(url); } }), + (error: unknown) => (error as Error).message === 'Desktop pairing browser request was rejected', + ); + } + assert.deepEqual(opened, []); + }); +}); diff --git a/apps/desktop/src/pairing-browser.ts b/apps/desktop/src/pairing-browser.ts new file mode 100644 index 000000000..d3e5f92f1 --- /dev/null +++ b/apps/desktop/src/pairing-browser.ts @@ -0,0 +1,20 @@ +import { normalizeDesktopPairingApprovalUrl } from '@propr/shared'; +import type { DesktopPairingBrowserRequest } from './credential-service'; + +const REJECTED_PAIRING_URL_ERROR = 'Desktop pairing browser request was rejected'; + +interface ExternalShell { + openExternal(url: string): Promise; +} + +/** Revalidate the exact API response at the final host sink before navigation. */ +export async function openApprovedDesktopPairingUrl( + request: DesktopPairingBrowserRequest, + shell: ExternalShell, +): Promise { + const approved = normalizeDesktopPairingApprovalUrl(request); + if (approved === null || approved !== request.approvalUrl) { + throw new Error(REJECTED_PAIRING_URL_ERROR); + } + await shell.openExternal(approved); +} diff --git a/apps/desktop/src/pairing-response-lifecycle.test.ts b/apps/desktop/src/pairing-response-lifecycle.test.ts new file mode 100644 index 000000000..da6a7c738 --- /dev/null +++ b/apps/desktop/src/pairing-response-lifecycle.test.ts @@ -0,0 +1,493 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { describe, it } from 'node:test'; +import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; +import type { PairingProtocolRequestOptions } from '@propr/client'; +import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; +import { DesktopCredentialService } from './credential-service'; +import { registerIpcHandlers } from './ipc'; +import type { LocalLifecycleController } from './lifecycle'; +import type { DesktopLogger } from './logger'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { IPC_CHANNELS } from './shared/contract'; +import { createDesktopShutdownCoordinator } from './shutdown'; + +type Endpoint = 'start' | 'poll' | 'activate' | 'cancel'; +type BarrierPhase = 'header' | 'body' | 'reader-cancel' | 'body-cancel'; + +interface Scenario { + name: string; + endpoint: Endpoint; + phase: BarrierPhase; +} + +const scenarios: readonly Scenario[] = [ + { name: 'start-header', endpoint: 'start', phase: 'header' }, + { name: 'start-body', endpoint: 'start', phase: 'body' }, + { name: 'poll-header', endpoint: 'poll', phase: 'header' }, + { name: 'poll-body', endpoint: 'poll', phase: 'body' }, + { name: 'activate-header', endpoint: 'activate', phase: 'header' }, + { name: 'activate-body', endpoint: 'activate', phase: 'body' }, + { name: 'cancel-header', endpoint: 'cancel', phase: 'header' }, + { name: 'cancel-body', endpoint: 'cancel', phase: 'body' }, + { name: 'never-settling-reader-cancel', endpoint: 'activate', phase: 'reader-cancel' }, + { name: 'never-settling-body-cancel', endpoint: 'activate', phase: 'body-cancel' }, +]; + +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(Buffer.from(value, 'utf8').toString('base64url'), 'utf8'), + decrypt: value => Buffer.from(value.toString(), 'base64url').toString('utf8'), +}; + +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((settle, fail) => { resolve = settle; reject = fail; }); + return { promise, resolve, reject }; +}; + +class ProtocolClock { + #now = 0; + #nextId = 1; + readonly #timers = new Map void }>(); + + readonly source: NonNullable = { + now: () => this.#now, + setTimeout: (callback, milliseconds) => { + const id = this.#nextId++; + this.#timers.set(id, { at: this.#now + milliseconds, callback }); + return id as unknown as ReturnType; + }, + clearTimeout: timer => { this.#timers.delete(timer as unknown as number); }, + }; + + get pending(): number { return this.#timers.size; } + + async advance(milliseconds: number): Promise { + const target = this.#now + milliseconds; + while (true) { + const due = [...this.#timers.entries()] + .filter(([, timer]) => timer.at <= target) + .sort(([leftId, left], [rightId, right]) => left.at - right.at || leftId - rightId)[0]; + if (!due) break; + this.#now = due[1].at; + this.#timers.delete(due[0]); + due[1].callback(); + await Promise.resolve(); + await Promise.resolve(); + } + this.#now = target; + await Promise.resolve(); + await Promise.resolve(); + } +} + +const bounded = async (promise: Promise, milliseconds = 1_000): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('desktop shutdown did not settle')), milliseconds); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +}; + +const durableBytes = async (root: string): Promise> => { + const snapshot: Record = {}; + const visit = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else snapshot[relative(root, path)] = (await readFile(path)).toString('base64'); + } + }; + await visit(root); + return Object.fromEntries(Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right))); +}; + +const immediate = (): Promise => new Promise(resolve => setImmediate(resolve)); + +describe('desktop pairing service IPC native shutdown lifecycle', () => { + assert.equal(scenarios.length, 10); + + for (const scenario of scenarios) { + it(`${scenario.name} drains through the real service, IPC gate, and before-quit order`, async () => { + const directory = await mkdtemp(join(tmpdir(), `propr-${scenario.name}-`)); + const clock = new ProtocolClock(); + const barrier = deferred(); + const lateHeader = deferred(); + const lateCancellation = deferred(); + const cancellationStarted = deferred(); + const protocolNow = Date.parse('2026-01-01T00:00:00.000Z'); + const expiresAt = new Date(protocolNow + 10_000).toISOString(); + const profileId = `profile-${scenario.name}`; + const origin = 'https://a.example.test'; + const provisionalToken = `propr_it_${'C'.repeat(43)}`; + const counts = { + fetchStart: 0, + fetchAbort: 0, + bodyPull: 0, + bodyCancel: 0, + profileRead: 0, + profileWrite: 0, + profileIO: 0, + ipcEntry: 0, + ipcExit: 0, + rendererPublication: 0, + sessionNetwork: 0, + }; + const order: string[] = []; + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown): void => { unhandled.push(error); }; + process.on('unhandledRejection', onUnhandled); + + const rawStore = new ProfileStore(directory, encryption, { + beforeIO: () => { counts.profileIO += 1; }, + }); + const readMethods = new Set([ + 'list', 'readCredential', 'readProfileCredential', 'pendingRevocations', 'security', + ]); + const store = new Proxy(rawStore, { + get(target, property) { + const value = Reflect.get(target, property, target) as unknown; + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + if (readMethods.has(String(property))) counts.profileRead += 1; + else counts.profileWrite += 1; + return (value as (...values: unknown[]) => unknown).apply(target, args); + }; + }, + }) as ProfileStore; + + let targetSignal: AbortSignal | undefined; + let pairingBinding: Record = {}; + let activationFailures = 0; + let cancellationCanSettle = false; + const stalledBody = (beforeReader: boolean): Response => new Response( + new ReadableStream({ + pull() { + counts.bodyPull += 1; + if (!beforeReader) barrier.resolve(undefined); + }, + cancel() { + counts.bodyCancel += 1; + if (beforeReader) barrier.resolve(undefined); + cancellationStarted.resolve(undefined); + return lateCancellation.promise; + }, + }), + { + headers: { + 'Content-Type': 'application/json', + ...(beforeReader ? { 'Content-Length': '4097' } : {}), + }, + }, + ); + + const fetchImplementation: typeof globalThis.fetch = async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }); + counts.fetchStart += 1; + const url = input.toString(); + const signal = init?.signal ?? undefined; + signal?.addEventListener('abort', () => { counts.fetchAbort += 1; }, { once: true }); + const endpoint: Endpoint = url.endsWith('/poll') + ? 'poll' + : url.endsWith('/activate') + ? 'activate' + : url.endsWith('/cancel') + ? 'cancel' + : 'start'; + if (endpoint === scenario.endpoint) { + targetSignal = signal; + if (scenario.phase === 'header') { + barrier.resolve(undefined); + return lateHeader.promise; + } + if (scenario.phase === 'body-cancel') return stalledBody(true); + return stalledBody(false); + } + if (endpoint === 'start') { + const request = JSON.parse(String(init?.body)) as Record; + pairingBinding = { + instanceId: request.instanceId, + origin: request.origin, + scope: request.scope, + credentialGeneration: request.credentialGeneration, + }; + return json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: `${origin}/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`, + expiresAt, + interval: 1, + }, 201); + } + if (endpoint === 'poll') { + return json({ + status: 'provisional', + token: provisionalToken, + tokenType: 'Bearer', + activationTicket: 'T'.repeat(43), + activationExpiresAt: expiresAt, + ...pairingBinding, + }); + } + if (endpoint === 'activate') { + if (scenario.endpoint === 'cancel') { + activationFailures += 1; + return json({ code: 'ACTIVATION_FAILED', error: 'activation failed' }, 500); + } + return json({ + status: 'active', + receipt: 'R'.repeat(22), + activatedAt: '2026-01-01T00:00:01.000Z', + expiresAt: null, + }); + } + return json({ status: 'cancelled', cancelledAt: '2026-01-01T00:00:01.000Z' }); + }; + + const handlers = new Map unknown>(); + let service!: DesktopCredentialService; + try { + const profile = await store.save({ id: profileId, label: scenario.name, apiBaseUrl: origin }); + service = new DesktopCredentialService({ + profiles: store, + clientName: `Native ${scenario.name}`, + openPairingBrowser: async () => undefined, + fetch: fetchImplementation, + pairingTiming: { now: () => protocolNow, sleep: async () => undefined }, + pairingProtocol: { + overallTimeoutMs: 1_000, + deadlines: { headerMs: 500, bodyMs: 500, cancellationMs: 100 }, + clock: clock.source, + reportDiagnostic: () => undefined, + }, + }); + assert.deepEqual(await service.initialize(), { status: 'ready', retryPending: false }); + + const desktopSession = { + fetch: async () => { counts.sessionNetwork += 1; return new Response(null, { status: 204 }); }, + clearStorageData: async () => undefined, + } as unknown as Session; + const registered = registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain: { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: store, + credentials: service, + connectDiscovery: { + discover: async () => [], + rediscover: async () => null, + }, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + observeInvocation: phase => { counts[phase === 'entry' ? 'ipcEntry' : 'ipcExit'] += 1; }, + }); + const event = { + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const invoke = (channel: string, ...args: unknown[]): Promise => + Promise.resolve(handlers.get(channel)!(event, ...args)); + + const admitted = invoke(IPC_CHANNELS.authenticationPair, { + id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl, + }).then(value => { + counts.rendererPublication += 1; + return { status: 'fulfilled' as const, value }; + }, error => ({ status: 'rejected' as const, error })); + await bounded(barrier.promise); + + const provisionalCouldExist = ['activate', 'cancel'].includes(scenario.endpoint); + const pendingBeforeShutdown = await store.pendingRevocations(); + assert.equal(pendingBeforeShutdown.length, provisionalCouldExist ? 1 : 0); + if (provisionalCouldExist) { + assert.deepEqual(pendingBeforeShutdown[0].credential, { + version: 2, + profileId, + origin, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + token: provisionalToken, + }); + } + assert.equal(await store.readCredential(profileId), null); + + let windowDestroyed = false; + let shutdownFinished = false; + let finalQuitCalls = 0; + let allowedFinalQuits = 0; + let shutdown!: ReturnType; + shutdown = createDesktopShutdownCoordinator({ + credentials: { + dispose: () => { order.push('credentials-dispose'); return service.dispose(); }, + }, + lifecycle: { + shutdown: async () => { order.push('lifecycle-shutdown'); }, + }, + ipc: { + close: () => { order.push('ipc-close'); registered.close(); }, + awaitIdle: () => { order.push('ipc-drain'); return registered.awaitIdle(); }, + dispose: () => { order.push('ipc-dispose'); registered.dispose(); }, + }, + profiles: { + close: () => { order.push('profiles-close'); return store.close(); }, + }, + sessionSecurity: { + close: () => { order.push('session-close'); }, + dispose: () => { order.push('session-dispose'); }, + }, + disposeRendererProtocol: () => { order.push('protocol-dispose'); }, + getWindow: () => ({ + isDestroyed: () => windowDestroyed, + destroy: () => { windowDestroyed = true; order.push('window-destroy'); }, + }), + quit: () => { + finalQuitCalls += 1; + order.push('app-quit'); + let finalQuitPrevented = false; + shutdown.beforeQuit({ preventDefault: () => { finalQuitPrevented = true; } }); + if (!finalQuitPrevented) { + allowedFinalQuits += 1; + shutdownFinished = true; + } + }, + onStarted: () => { order.push('shutdown-started'); }, + log: () => undefined, + }); + let prevented = 0; + shutdown.beforeQuit({ preventDefault: () => { prevented += 1; } }); + assert.equal(prevented, 1); + assert.equal(shutdown.started, true); + assert.deepEqual(order.slice(0, 4), [ + 'shutdown-started', 'ipc-close', 'session-close', 'protocol-dispose', + ]); + + const callsBeforeLate = { + fetchStart: counts.fetchStart, + profileRead: counts.profileRead, + profileWrite: counts.profileWrite, + sessionNetwork: counts.sessionNetwork, + }; + await Promise.all([ + assert.rejects(invoke(IPC_CHANNELS.profilesList), /DESKTOP_CLOSING/), + assert.rejects(invoke(IPC_CHANNELS.authenticationPair, { + id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl, + }), /DESKTOP_CLOSING/), + assert.rejects(invoke(IPC_CHANNELS.authLogout, origin), /DESKTOP_CLOSING/), + ]); + assert.deepEqual({ + fetchStart: counts.fetchStart, + profileRead: counts.profileRead, + profileWrite: counts.profileWrite, + sessionNetwork: counts.sessionNetwork, + }, callsBeforeLate); + + const cancellationExpected = scenario.phase !== 'header'; + if (cancellationExpected) { + await bounded(cancellationStarted.promise); + shutdown.beforeQuit({ preventDefault: () => { prevented += 1; } }); + assert.equal(prevented, 2, 'repeated before-quit was not prevented during cancellation'); + cancellationCanSettle = true; + await clock.advance(99); + await Promise.resolve(); + assert.equal(shutdownFinished, false, 'untrusted cancellation escaped its 100ms budget'); + await clock.advance(1); + } else { + shutdown.beforeQuit({ preventDefault: () => { prevented += 1; } }); + assert.equal(prevented, 2, 'repeated before-quit was not prevented during header drain'); + } + await bounded(shutdown.awaitFinished()); + const original = await bounded(admitted); + assert.equal(original.status, 'rejected'); + if (original.status === 'rejected') { + assert.match(String(original.error), /Desktop operation failed \[IPC_OPERATION_FAILED\]/); + assert.doesNotMatch(String(original.error), /Desktop pairing was cancelled/i); + } + assert.equal(targetSignal?.aborted, true); + assert.equal(counts.rendererPublication, 0); + assert.equal(counts.ipcEntry, 1); + assert.equal(counts.ipcExit, 1); + assert.equal(handlers.size, 0); + assert.equal(windowDestroyed, true); + assert.equal(shutdownFinished, true); + assert.equal(finalQuitCalls, 1); + assert.equal(allowedFinalQuits, 1); + assert.equal(activationFailures, scenario.endpoint === 'cancel' ? 2 : 0); + for (const step of [ + 'shutdown-started', 'ipc-close', 'session-close', 'protocol-dispose', + 'credentials-dispose', 'lifecycle-shutdown', 'ipc-drain', 'profiles-close', + 'session-dispose', 'ipc-dispose', 'window-destroy', 'app-quit', + ]) { + assert.equal(order.filter(entry => entry === step).length, 1, `${step} ran more than once`); + } + assert.equal(order.indexOf('profiles-close') > order.indexOf('ipc-drain'), true); + assert.equal(order.indexOf('session-dispose') > order.indexOf('profiles-close'), true); + assert.equal(order.indexOf('window-destroy') > order.indexOf('ipc-dispose'), true); + assert.equal(order.at(-1), 'app-quit'); + await bounded(service.awaitIdle()); + await bounded(registered.awaitIdle()); + assert.deepEqual(service.prepareRequest(`${origin}/api/tasks`, {}), { cancel: true }); + assert.equal(clock.pending, 0); + + let extraQuitPrevented = false; + shutdown.beforeQuit({ preventDefault: () => { extraQuitPrevented = true; } }); + assert.equal(extraQuitPrevented, true, 'more than the deliberate final quit was allowed'); + assert.equal(finalQuitCalls, 1); + assert.equal(allowedFinalQuits, 1); + + const countsAtDispose = { ...counts }; + const bytesAtDispose = await durableBytes(directory); + if (scenario.phase === 'header') lateHeader.reject(new Error('late private header failure')); + if (cancellationCanSettle) lateCancellation.reject(new Error('late private cancellation failure')); + await clock.advance(2_000); + await immediate(); + await immediate(); + + assert.deepEqual(counts, countsAtDispose); + assert.deepEqual(await durableBytes(directory), bytesAtDispose); + assert.deepEqual(unhandled, []); + assert.equal(clock.pending, 0); + console.log(`NATIVE_PAIRING_SHUTDOWN ${scenario.name}`); + } finally { + process.removeListener('unhandledRejection', onUnhandled); + await service?.dispose().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }); + } +}); diff --git a/apps/desktop/src/pending-revocation-crash-fixture.ts b/apps/desktop/src/pending-revocation-crash-fixture.ts new file mode 100644 index 000000000..6710aee21 --- /dev/null +++ b/apps/desktop/src/pending-revocation-crash-fixture.ts @@ -0,0 +1,54 @@ +import { DesktopCredentialService } from './credential-service'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; + +const [directory, mode] = process.argv.slice(2) as [string, 'during-revoke' | 'after-remote-success']; +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; +const store = new ProfileStore(directory, encryption); +const profiles = mode === 'after-remote-success' + ? new Proxy(store, { + get(target, property) { + if (property === 'completePendingRevocation') return async () => { + process.kill(process.pid, 'SIGKILL'); + return false; + }; + const value = Reflect.get(target, property); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) + : store; +const service = new DesktopCredentialService({ + profiles, + clientName: 'Crash fixture', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) { + return new Response(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: '2026-08-01', + uiCompatibility: '2026-08-01', + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }), { headers: { 'Content-Type': 'application/json' } }); + } + const authorization = new Headers(init?.headers).get('Authorization'); + if (authorization !== `Bearer propr_it_${'A'.repeat(43)}`) { + throw new Error('Pending revocation used the wrong credential'); + } + if (mode === 'during-revoke') process.kill(process.pid, 'SIGKILL'); + return new Response(null, { status: 204 }); + }, +}); +await service.initialize(); diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts new file mode 100644 index 000000000..1da9cfa34 --- /dev/null +++ b/apps/desktop/src/preload-bridge.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createDesktopBridge, type PreloadIpc } from './preload-bridge'; +import { IPC_CHANNELS } from './shared/contract'; + +class FakeIpc implements PreloadIpc { + readonly invocations: Array<{ channel: string; args: unknown[] }> = []; + readonly listeners = new Map void>(); + + async invoke(channel: string, ...args: unknown[]): Promise { + this.invocations.push({ channel, args }); + return undefined; + } + + on(channel: string, listener: (event: unknown, value: string) => void): void { + this.listeners.set(channel, listener); + } + + removeListener(channel: string, listener: (event: unknown, value: string) => void): void { + if (this.listeners.get(channel) === listener) this.listeners.delete(channel); + } +} + +describe('desktop preload bridge', () => { + it('exposes only the narrow frozen namespaces', () => { + const bridge = createDesktopBridge(new FakeIpc()); + assert.deepEqual(Object.keys(bridge).sort(), ['app', 'auth', 'authentication', 'connection', 'discovery', 'external', 'lifecycle', 'profiles', 'storage']); + assert.equal(Object.isFrozen(bridge), true); + assert.equal(Object.values(bridge).every(Object.isFrozen), true); + assert.equal('fs' in bridge, false); + assert.equal('exec' in bridge, false); + }); + + it('maps profile and main-process authentication operations to fixed channels', async () => { + const ipc = new FakeIpc(); + const bridge = createDesktopBridge(ipc); + await bridge.auth.logout('http://localhost:4000'); + await bridge.profiles.save({ label: 'Local', apiBaseUrl: 'http://localhost:4000' }); + await bridge.authentication.pair({ id: 'profile-1', label: 'Local', apiBaseUrl: 'http://localhost:4000' }); + await bridge.connection.activate('activation-ticket'); + await bridge.connection.discard({ profileId: 'profile-1', transportScope: 'transport-scope' }); + await bridge.discovery.discover(); + await bridge.discovery.rediscover('profile-1'); + await bridge.lifecycle.start(); + assert.deepEqual(ipc.invocations, [ + { channel: IPC_CHANNELS.authLogout, args: ['http://localhost:4000'] }, + { + channel: IPC_CHANNELS.profilesSave, + args: [{ label: 'Local', apiBaseUrl: 'http://localhost:4000' }], + }, + { + channel: IPC_CHANNELS.authenticationPair, + args: [{ id: 'profile-1', label: 'Local', apiBaseUrl: 'http://localhost:4000' }], + }, + { channel: IPC_CHANNELS.connectionActivate, args: ['activation-ticket'] }, + { + channel: IPC_CHANNELS.connectionDiscard, + args: [{ profileId: 'profile-1', transportScope: 'transport-scope' }], + }, + { channel: IPC_CHANNELS.connectDiscover, args: [] }, + { channel: IPC_CHANNELS.connectRediscover, args: ['profile-1'] }, + { channel: IPC_CHANNELS.lifecycleStart, args: [] }, + ]); + assert.equal(bridge.discovery.supported, true); + }); + + it('can advertise an unsupported host without exposing a renderer-selected root', () => { + const bridge = createDesktopBridge(new FakeIpc(), false); + assert.equal(bridge.discovery.supported, false); + assert.deepEqual(Object.keys(bridge.discovery).sort(), ['discover', 'rediscover', 'supported']); + }); + + it('exposes only a fixed stage reporter when packaged Connect acceptance is authorized', async () => { + const ipc = new FakeIpc(); + const bridge = createDesktopBridge(ipc, true, true); + assert.deepEqual(Object.keys(bridge.acceptance ?? {}), ['reportJourneyStage']); + await bridge.acceptance?.reportJourneyStage('CREDENTIAL_COMMITTED'); + assert.deepEqual(ipc.invocations, [{ + channel: IPC_CHANNELS.acceptanceJourneyStage, + args: ['CREDENTIAL_COMMITTED'], + }]); + }); + + it('does not expose Electron event objects to deep-link listeners', () => { + const ipc = new FakeIpc(); + const bridge = createDesktopBridge(ipc); + const received: string[] = []; + const unsubscribe = bridge.app.onDeepLink(value => received.push(value)); + ipc.listeners.get(IPC_CHANNELS.deepLink)?.({ sender: 'must-not-leak' }, 'propr://open?path=%2Ftasks'); + assert.deepEqual(received, ['propr://open?path=%2Ftasks']); + unsubscribe(); + assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); + }); + + it('buffers startup and second-instance deep links until the renderer subscribes', () => { + const ipc = new FakeIpc(); + const bridge = createDesktopBridge(ipc); + const receiveDeepLink = ipc.listeners.get(IPC_CHANNELS.deepLink); + assert.ok(receiveDeepLink, 'preload must register its IPC listener eagerly'); + + receiveDeepLink({}, 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000'); + receiveDeepLink({}, 'propr://open?path=%2Ftasks'); + + const received: string[] = []; + bridge.app.onDeepLink(value => received.push(value)); + assert.deepEqual(received, [ + 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + 'propr://open?path=%2Ftasks', + ]); + }); +}); diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts new file mode 100644 index 000000000..3a6e6e3d3 --- /dev/null +++ b/apps/desktop/src/preload-bridge.ts @@ -0,0 +1,84 @@ +import type { DesktopBridge } from './shared/contract'; +import { IPC_CHANNELS } from './shared/contract'; + +export interface PreloadIpc { + invoke(channel: string, ...args: unknown[]): Promise; + on(channel: string, listener: (event: unknown, value: string) => void): void; + removeListener(channel: string, listener: (event: unknown, value: string) => void): void; +} + +const invoke = (ipc: PreloadIpc, channel: string, ...args: unknown[]): Promise => + ipc.invoke(channel, ...args) as Promise; + +export const createDesktopBridge = ( + ipc: PreloadIpc, + connectDiscoverySupported = process.platform === 'darwin' + || process.platform === 'linux' + || process.platform === 'win32', + connectJourneyAcceptance = false, +): DesktopBridge => { + const deepLinkListeners = new Set<(url: string) => void>(); + const pendingDeepLinks: string[] = []; + ipc.on(IPC_CHANNELS.deepLink, (_event, value) => { + if (deepLinkListeners.size === 0) { + pendingDeepLinks.push(value); + return; + } + deepLinkListeners.forEach(listener => listener(value)); + }); + + const bridge: DesktopBridge = { + app: { + getMetadata: () => invoke(ipc, IPC_CHANNELS.appMetadata), + onDeepLink: (listener) => { + deepLinkListeners.add(listener); + pendingDeepLinks.splice(0).forEach(value => listener(value)); + return () => deepLinkListeners.delete(listener); + }, + }, + auth: { + logout: (apiBaseUrl) => invoke(ipc, IPC_CHANNELS.authLogout, apiBaseUrl), + }, + external: { + open: (url) => invoke(ipc, IPC_CHANNELS.openExternal, url), + }, + storage: { + security: () => invoke(ipc, IPC_CHANNELS.storageSecurity), + }, + profiles: { + list: () => invoke(ipc, IPC_CHANNELS.profilesList), + save: (profile) => invoke(ipc, IPC_CHANNELS.profilesSave, profile), + remove: (profileId) => invoke(ipc, IPC_CHANNELS.profilesRemove, profileId), + setActive: (profileId) => invoke(ipc, IPC_CHANNELS.profilesSetActive, profileId), + }, + authentication: { + pair: (profile) => invoke(ipc, IPC_CHANNELS.authenticationPair, profile), + cancel: (profileId) => invoke(ipc, IPC_CHANNELS.authenticationCancel, profileId), + }, + connection: { + probe: (profile) => invoke(ipc, IPC_CHANNELS.connectionProbe, profile), + activate: (activationTicket) => invoke(ipc, IPC_CHANNELS.connectionActivate, activationTicket), + discard: (value) => invoke(ipc, IPC_CHANNELS.connectionDiscard, value), + invalidate: (value) => invoke(ipc, IPC_CHANNELS.connectionInvalidate, value), + }, + discovery: { + supported: connectDiscoverySupported, + discover: () => invoke(ipc, IPC_CHANNELS.connectDiscover), + rediscover: (profileId) => invoke(ipc, IPC_CHANNELS.connectRediscover, profileId), + }, + lifecycle: { + status: () => invoke(ipc, IPC_CHANNELS.lifecycleStatus), + start: () => invoke(ipc, IPC_CHANNELS.lifecycleStart), + stop: () => invoke(ipc, IPC_CHANNELS.lifecycleStop), + restart: () => invoke(ipc, IPC_CHANNELS.lifecycleRestart), + }, + ...(connectJourneyAcceptance ? { + acceptance: { + reportJourneyStage: (stage) => invoke(ipc, IPC_CHANNELS.acceptanceJourneyStage, stage), + }, + } : {}), + }; + + Object.values(bridge).forEach(Object.freeze); + return Object.freeze(bridge); +}; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 000000000..165e7f187 --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -0,0 +1,11 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { createDesktopBridge } from './preload-bridge'; + +const connectJourneyAcceptance = process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST === '1' + && (process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'pair' + || process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE === 'reprobe'); + +contextBridge.exposeInMainWorld( + 'proprDesktop', + createDesktopBridge(ipcRenderer, undefined, connectJourneyAcceptance), +); diff --git a/apps/desktop/src/profile-store-crash-fixture.ts b/apps/desktop/src/profile-store-crash-fixture.ts new file mode 100644 index 000000000..ded27579c --- /dev/null +++ b/apps/desktop/src/profile-store-crash-fixture.ts @@ -0,0 +1,94 @@ +import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ProfileStore, type EncryptionProvider, type ProfileStoreDurabilityStep } from './profile-store'; + +const [directory, requestedStep] = process.argv.slice(2) as [string, string]; +const crashStep = requestedStep.split(':').at(-1) as ProfileStoreDurabilityStep; +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(Buffer.from(value, 'utf8').toString('base64url'), 'utf8'), + decrypt: value => Buffer.from(value.toString(), 'base64url').toString('utf8'), +}; +const store = new ProfileStore(directory, encryption, { + afterDurabilityStep: step => { + if (!requestedStep.startsWith('visibility:') && step === crashStep) process.kill(process.pid, 'SIGKILL'); + }, +}); +if (requestedStep.startsWith('recovery:')) { + await store.list(); + throw new Error(`Recovery fixture did not reach ${crashStep}`); +} +const desktop = join(directory, 'desktop'); +const stateA = requestedStep.startsWith('visibility:') + ? await readFile(join(desktop, 'profiles.json')) + : null; +const journalsA = requestedStep.startsWith('visibility:') + ? await Promise.all([0, 1].map(async index => { + try { return await readFile(join(desktop, `profiles.journal.${index}`)); } catch { return null; } + })) + : []; +const baseline = await store.readProfileCredential('profile-1'); +if (requestedStep.startsWith('detach:')) { + await store.detachProfile('profile-1'); + throw new Error(`Detach fixture did not reach ${crashStep}`); +} +await store.commitPairedProfile( + { id: 'profile-1', label: 'Replacement', apiBaseUrl: 'https://propr.example.com' }, + { + version: 2, + profileId: 'profile-1', + origin: 'https://propr.example.com', + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + token: `propr_it_${'B'.repeat(43)}`, + }, + baseline, + () => true, +); +if (requestedStep.startsWith('visibility:')) { + const mode = requestedStep.slice('visibility:'.length); + const stateB = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { + credentialSlots: Record; + }; + if (mode === 'pointer-rollback' && stateA) { + await writeFile(join(desktop, 'profiles.json'), stateA); + } else if (mode === 'pointer-corruption' || mode === 'mirror-malformed') { + await writeFile(join(desktop, 'profiles.json'), '{corrupt'); + } else if (mode === 'mirror-missing') { + await unlink(join(desktop, 'profiles.json')); + } else if (mode === 'mirror-truncated') { + await writeFile(join(desktop, 'profiles.json'), '{"version":3'); + } else if (mode === 'mirror-stale' && stateA) { + await writeFile(join(desktop, 'profiles.json'), stateA); + } else if (mode === 'mirror-schema-invalid') { + const contents = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as Record; + await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ + ...contents, version: 99, + })); + } else if (mode === 'mirror-attacker') { + const contents = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as Record; + const profiles = contents.profiles as Array>; + await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ + ...contents, profiles: profiles.map(profile => ({ ...profile, label: 'Attacker' })), + })); + } else if (mode === 'missing-target') { + await unlink(join(desktop, 'credentials', stateB.credentialSlots['profile-1'])); + } else if (mode === 'state-before-journal') { + for (const [index, bytes] of journalsA.entries()) { + const path = join(desktop, `profiles.journal.${index}`); + if (bytes) await writeFile(path, bytes); + else await unlink(path).catch(() => undefined); + } + } else if (mode === 'alternate-slot-rollback') { + const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { generation: string }; + const newest = Number(BigInt(state.generation) % 2n); + const older = (newest + 1) % 2; + await writeFile( + join(desktop, `profiles.journal.${newest}`), + await readFile(join(desktop, `profiles.journal.${older}`)), + ); + } else { + throw new Error(`Unknown visibility mode: ${mode}`); + } + process.kill(process.pid, 'SIGKILL'); +} diff --git a/apps/desktop/src/profile-store.test.ts b/apps/desktop/src/profile-store.test.ts new file mode 100644 index 000000000..5c486350d --- /dev/null +++ b/apps/desktop/src/profile-store.test.ts @@ -0,0 +1,1059 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import { + flushFileData, + ProfileStore, + type EncryptionProvider, + type ProfileStoreDurabilityStep, + type ProfileStoreIOOperation, +} from './profile-store'; + +const temporaryDirectories: string[] = []; +const NATIVE_VISIBILITY_SCENARIOS = [ + 'pointer-rollback', 'pointer-corruption', 'missing-target', 'state-before-journal', + 'mirror-missing', 'mirror-truncated', 'mirror-malformed', 'mirror-stale', + 'mirror-schema-invalid', 'mirror-attacker', 'alternate-slot-rollback', +] as const; +const RECOVERY_KILL_STEPS: ProfileStoreDurabilityStep[] = [ + 'state-written', 'state-fsynced', + 'journal-written', 'journal-fsynced', 'journal-closed', 'journal-reopened', + 'journal-prepared-verified', 'journal-committed', 'journal-commit-fsynced', + 'journal-commit-verified', 'journal-commit-closed', 'state-renamed', + ...(process.platform === 'win32' ? [] : ['state-directory-fsynced'] as const), +]; +const RECOVERY_KILL_MODES = ['bootstrap', 'migration-v1', 'migration-v2'] as const; + +const createDirectory = async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-test-')); + temporaryDirectories.push(directory); + return directory; +}; + +const encryption = (available = true, backend = 'keychain'): EncryptionProvider => ({ + isEncryptionAvailable: () => available, + backend: () => backend, + encrypt: value => Buffer.from(Buffer.from(value, 'utf8').toString('base64url'), 'utf8'), + decrypt: value => Buffer.from(value.toString(), 'base64url').toString('utf8'), +}); + +const credential = (profileId: string, tokenCharacter = 'A') => ({ + version: 2 as const, + profileId, + origin: 'https://propr.example.com', + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + token: `propr_it_${tokenCharacter.repeat(43)}`, +}); +const legacyCredential = (profileId: string, tokenCharacter = 'A') => ({ + version: 1 as const, + profileId, + origin: 'https://propr.example.com', + token: `propr_it_${tokenCharacter.repeat(43)}`, +}); + +const bounded = (promise: Promise, milliseconds = 1_000): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Profile store operation did not settle')), milliseconds); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +}; + +const legacyProfile = { + id: 'profile-1', label: 'Legacy', apiBaseUrl: 'https://propr.example.com', + createdAt: '2026-08-29T00:00:00.000Z', updatedAt: '2026-08-29T00:00:00.000Z', +}; + +const seedRecoveryMode = async ( + directory: string, + mode: (typeof RECOVERY_KILL_MODES)[number], +): Promise => { + if (mode === 'bootstrap') return; + const desktop = join(directory, 'desktop'); + const credentials = join(desktop, 'credentials'); + await mkdir(credentials, { recursive: true }); + if (mode === 'migration-v1') { + await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ + version: 1, activeProfileId: legacyProfile.id, profiles: [legacyProfile], + })); + await writeFile( + join(credentials, `${legacyProfile.id}.bin`), + encryption().encrypt(JSON.stringify(legacyCredential(legacyProfile.id))), + ); + return; + } + const slot = `${legacyProfile.id}.00000000-0000-4000-8000-000000000001.bin`; + await writeFile(join(credentials, slot), encryption().encrypt(JSON.stringify(legacyCredential(legacyProfile.id)))); + await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ + version: 2, + activeProfileId: legacyProfile.id, + profiles: [legacyProfile], + credentialSlots: { [legacyProfile.id]: slot }, + })); +}; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); +}); + +describe('desktop profile store', () => { + it('matches the shared canonical origin parity table at the persistence boundary', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + let index = 0; + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + const save = store.save({ id: `parity-${index++}`, label: name, apiBaseUrl: input }); + if (expected === null) await assert.rejects(save, /HTTPS|URL/, name); + else assert.equal((await save).apiBaseUrl, expected, name); + } + }); + it('persists validated profiles and active selection', async () => { + const directory = await createDirectory(); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ label: ' Local ', apiBaseUrl: 'http://localhost:4000/' }); + const ipv6Profile = await store.save({ label: 'IPv6', apiBaseUrl: 'http://[::1]:4000/' }); + await store.setActive(profile.id); + assert.deepEqual(await store.list(), { profiles: [profile, ipv6Profile], activeProfileId: profile.id }); + assert.equal(profile.label, 'Local'); + assert.equal(profile.apiBaseUrl, 'http://localhost:4000'); + assert.equal(ipv6Profile.apiBaseUrl, 'http://[::1]:4000'); + }); + + it('encrypts credentials before writing app-owned storage', async () => { + const directory = await createDirectory(); + const barrierProof = join(directory, 'writable-file-barrier-proof'); + const barrierBytes = Buffer.from('native writable fsync proof'); + await writeFile(barrierProof, barrierBytes); + await flushFileData(barrierProof); + assert.deepEqual(await readFile(barrierProof), barrierBytes); + + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ label: 'Secure', apiBaseUrl: 'https://propr.example.com' }); + const storedCredential = credential(profile.id); + assert.deepEqual(await store.writeCredential(storedCredential), { stored: true }); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + const files = await readdir(join(directory, 'desktop', 'credentials')); + assert.equal(files.length, 1); + const onDisk = await readFile(join(directory, 'desktop', 'credentials', files[0]), 'utf8'); + assert.equal(onDisk.includes(storedCredential.token), false); + }); + + it('atomically refuses activation when the credential origin differs from the profile origin', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + const profile = await store.save({ + id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test', + }); + const staleCredential = { + ...credential(profile.id), + origin: 'https://a.example.test', + }; + await store.writeCredential(staleCredential); + + const activated = await store.activateProfile( + staleCredential, + (await store.readProfileCredential(profile.id)).identityEpoch!, + profile.apiBaseUrl, + null, + () => true, + ); + + assert.equal(activated, null); + assert.equal((await store.list()).activeProfileId, null); + assert.deepEqual(await store.readCredential(profile.id), staleCredential); + }); + + it('serializes concurrent credential writes with last-write semantics', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + + const first = store.writeCredential(credential('profile-1', 'A')); + const secondCredential = credential('profile-1', 'B'); + const second = store.writeCredential(secondCredential); + assert.deepEqual(await Promise.all([first, second]), [{ stored: true }, { stored: true }]); + assert.deepEqual(await store.readCredential('profile-1'), secondCredential); + }); + + it('orders concurrent credential writes and removals by invocation', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + + await Promise.all([ + store.writeCredential(credential('profile-1')), + store.removeCredential('profile-1'), + ]); + assert.equal(await store.readCredential('profile-1'), null); + + await Promise.all([ + store.removeCredential('profile-1'), + store.writeCredential(credential('profile-1', 'B')), + ]); + assert.deepEqual(await store.readCredential('profile-1'), credential('profile-1', 'B')); + }); + + it('serializes concurrent paired replacements without mixing profile and credential generations', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + const baseline = await store.readProfileCredential(profile.id); + const [first, second] = await Promise.all([ + store.commitPairedProfile( + { id: profile.id, label: 'Replacement B', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ), + store.commitPairedProfile( + { id: profile.id, label: 'Replacement C', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'C'), baseline, () => true, + ), + ]); + assert.equal(first && !('stored' in first) ? first.profile.label : null, 'Replacement B'); + assert.equal(second, null); + assert.equal((await store.list()).profiles[0].label, 'Replacement B'); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, 'B')); + }); + + it('commits encrypted pending revocation material atomically with B and unlinks A only after durable completion', async () => { + const directory = await createDirectory(); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profile.id, 'A'); + await store.writeCredential(credentialA); + const baseline = await store.readProfileCredential(profile.id); + + const committed = await store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ); + assert.ok(committed && !('stored' in committed)); + if (!committed || 'stored' in committed) return; + assert.notEqual(committed.identityEpoch, baseline.identityEpoch); + + const pending = await store.pendingRevocations(); + assert.equal(pending.length, 1); + assert.deepEqual(pending[0].credential, credentialA); + assert.equal(pending[0].credentialGeneration, baseline.identityEpoch); + assert.notEqual(pending[0].credentialGeneration, committed.identityEpoch); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, 'B')); + const desktop = join(directory, 'desktop'); + for (const file of await readdir(desktop)) { + if (!file.startsWith('profiles.')) continue; + const contents = await readFile(join(desktop, file), 'utf8'); + assert.equal(contents.includes(credentialA.token), false); + assert.equal(contents.includes(credential(profile.id, 'B').token), false); + } + assert.equal((await readdir(join(desktop, 'credentials'))).length, 2); + + assert.equal(await store.completePendingRevocation( + pending[0].id, credentialA, pending[0].credentialGeneration, + ), true); + assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, 'B')); + assert.deepEqual(await store.pendingRevocations(), []); + assert.equal((await readdir(join(desktop, 'credentials'))).length, 1); + }); + + it('fails legacy unbound credentials closed while preserving profile metadata', async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + const credentials = join(desktop, 'credentials'); + await mkdir(credentials, { recursive: true }); + const profile = { + id: 'profile-1', label: 'Legacy', apiBaseUrl: 'https://propr.example.com', + createdAt: '2026-08-29T00:00:00.000Z', updatedAt: '2026-08-29T00:00:00.000Z', + }; + await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ + version: 1, activeProfileId: profile.id, profiles: [profile], + })); + const oldCredential = legacyCredential(profile.id, 'A'); + await writeFile(join(credentials, `${profile.id}.bin`), encryption().encrypt(JSON.stringify(oldCredential))); + + const store = new ProfileStore(directory, encryption()); + const migrated = await store.readProfileCredential(profile.id); + assert.deepEqual(migrated, { + profile, credential: null, identityEpoch: null, activeProfileId: null, + }); + const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { + version: number; credentialSlots: Record; + }; + assert.equal(state.version, 3); + assert.deepEqual(state.credentialSlots, {}); + assert.deepEqual(await readdir(credentials), []); + }); + + it('migrates the exact-head numeric unsealed journal only when its valid mirror matches exactly', async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + await mkdir(join(desktop, 'credentials'), { recursive: true }); + const profile = { + id: 'profile-1', label: 'Legacy journal', apiBaseUrl: 'https://propr.example.com', + createdAt: '2026-08-29T00:00:00.000Z', updatedAt: '2026-08-29T00:00:00.000Z', + }; + const state = { + version: 3, generation: 7, activeProfileId: null, profiles: [profile], + credentialSlots: {}, credentialEpochs: {}, pendingRevocations: {}, + }; + const payload = { version: 1, state, encryptedSlots: {} }; + const checksum = createHash('sha256').update(JSON.stringify(payload)).digest('base64url'); + await writeFile(join(desktop, 'profiles.json'), JSON.stringify(state)); + await writeFile(join(desktop, 'profiles.journal.1'), JSON.stringify({ ...payload, checksum })); + + const restarted = new ProfileStore(directory, encryption()); + assert.deepEqual(await restarted.list(), { profiles: [profile], activeProfileId: null }); + const migrated = await readFile(join(desktop, 'profiles.journal.0'), 'utf8'); + assert.equal(migrated.startsWith('C{"version":2'), true); + assert.equal(migrated.includes(profile.label), false); + }); + + it('settles conditional credential removal and profile removal in the former lock-order interleaving', async () => { + const store = new ProfileStore(await createDirectory(), encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Remote', apiBaseUrl: 'https://propr.example.com', + }); + const storedCredential = credential(profile.id); + await store.writeCredential(storedCredential); + + // Both calls are deliberately made in one turn. Previously the conditional + // removal could own the state queue while remove() owned the credential + // queue and awaited the state operation queued behind it. + const conditional = store.removeCredentialIfCurrent( + storedCredential, + profile.apiBaseUrl, + () => true, + ); + const removal = store.remove(profile.id); + + assert.deepEqual(await bounded(Promise.all([conditional, removal])), [true, undefined]); + assert.deepEqual(await store.list(), { profiles: [], activeProfileId: null }); + assert.equal(await store.readCredential(profile.id), null); + }); + + it('refuses plaintext fallback when encryption is unavailable or basic_text', async () => { + for (const provider of [encryption(false, 'unavailable'), encryption(true, 'basic_text')]) { + const directory = await createDirectory(); + const store = new ProfileStore(directory, provider); + assert.equal(store.security().available, false); + assert.deepEqual(await store.writeCredential(credential('profile-1')), { + stored: false, + reason: 'encryption-unavailable', + }); + assert.equal(await store.readCredential('profile-1'), null); + } + }); + + it('rejects unsafe endpoints and path-like profile identifiers', async () => { + const directory = await createDirectory(); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ label: 'Remote', apiBaseUrl: 'https://propr.example.com/' }); + await assert.rejects( + store.save({ label: 'Remote HTTP', apiBaseUrl: 'http://example.com' }), + /HTTPS/, + ); + await assert.rejects( + store.save({ id: profile.id, label: 'Path bearing', apiBaseUrl: 'https://propr.example.com/base' }), + /HTTPS/, + ); + await assert.rejects( + store.save({ label: 'Encoded Connect', apiBaseUrl: 'https://t-%69nstance123.propr.dev' }), + /HTTPS/, + ); + await assert.rejects( + store.save({ label: 'Port Connect', apiBaseUrl: 'https://t-instance123.propr.dev:443' }), + /HTTPS/, + ); + assert.deepEqual((await store.list()).profiles, [profile]); + assert.doesNotMatch(await readFile(join(directory, 'desktop', 'profiles.json'), 'utf8'), /\/base/); + await assert.rejects(store.writeCredential(credential('../escape')), /Invalid desktop profile id/); + }); + + for (const failure of ['corrupt-json', 'decrypt'] as const) { + it(`removes an active profile despite a ${failure} credential failure`, async () => { + const directory = await createDirectory(); + let rejectCredential = false; + const provider: EncryptionProvider = { + ...encryption(), + decrypt: value => { + const plaintext = Buffer.from(value.toString(), 'base64url').toString('utf8'); + if (rejectCredential && plaintext.includes('"token":"propr_it_')) { + if (failure === 'decrypt') throw new Error('keychain decrypt failed'); + return '{not-json'; + } + return plaintext; + }, + }; + const store = new ProfileStore(directory, provider); + const profile = await store.save({ id: 'profile-1', label: 'Remote', apiBaseUrl: 'https://propr.example.com' }); + await store.writeCredential(credential(profile.id)); + await store.setActive(profile.id); + rejectCredential = true; + + const detached = await store.detachProfile(profile.id); + + assert.equal(detached?.profile.id, profile.id); + assert.equal(detached?.credential, null); + assert.deepEqual(await store.list(), { profiles: [], activeProfileId: null }); + assert.equal(await store.readCredential(profile.id), null); + }); + } + + it('preserves the complete profile and credential when state publication fails before commit', async () => { + const directory = await createDirectory(); + let failStateFsync = false; + const store = new ProfileStore(directory, encryption(), { + afterDurabilityStep: step => { + if (failStateFsync && step === 'state-fsynced') throw new Error('injected state fsync failure'); + }, + }); + const profile = await store.save({ id: 'profile-1', label: 'Remote', apiBaseUrl: 'https://propr.example.com' }); + const storedCredential = credential(profile.id); + await store.writeCredential(storedCredential); + await store.setActive(profile.id); + failStateFsync = true; + + await assert.rejects(store.detachProfile(profile.id), /injected state fsync failure/); + failStateFsync = false; + assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + }); + + it('preserves the complete profile and credential when precommit origin cleanup fails', async () => { + const directory = await createDirectory(); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ id: 'profile-1', label: 'Remote', apiBaseUrl: 'https://propr.example.com' }); + const storedCredential = credential(profile.id); + await store.writeCredential(storedCredential); + await store.setActive(profile.id); + + await assert.rejects( + store.detachProfile(profile.id, async origin => { + assert.equal(origin, profile.apiBaseUrl); + throw new Error('origin storage clear failed'); + }), + /origin storage clear failed/, + ); + + assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + + const observed: string[][] = []; + await assert.rejects(store.saveAndDetachCredential({ + id: profile.id, label: 'Edited', apiBaseUrl: 'https://edited.example.com', + }, async (previousOrigin, nextOrigin) => { + observed.push([previousOrigin, nextOrigin]); + throw new Error('origin edit storage clear failed'); + }), /origin edit storage clear failed/); + assert.deepEqual(observed, [[profile.apiBaseUrl, 'https://edited.example.com']]); + assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); + assert.deepEqual(await store.readCredential(profile.id), storedCredential); + assert.deepEqual(await store.pendingRevocations(), []); + }); + + it('keeps A authoritative across every injected pre-commit paired replacement failure', async () => { + const directory = await createDirectory(); + let failure: string | null = null; + const store = new ProfileStore(directory, encryption(), { + afterDurabilityStep: step => { + if (step === failure) throw new Error(`injected ${step}`); + }, + }); + const profile = await store.save({ id: 'profile-1', label: 'Remote', apiBaseUrl: 'https://propr.example.com' }); + const credentialA = credential(profile.id, 'A'); + await store.writeCredential(credentialA); + await store.setActive(profile.id); + const baseline = await store.readProfileCredential(profile.id); + for (const step of [ + 'credential-encrypted', 'credential-written', 'credential-fsynced', + 'credential-renamed', + ...(process.platform === 'win32' ? [] : ['credential-directory-fsynced'] as const), + 'state-written', 'state-fsynced', + 'journal-written', 'journal-fsynced', 'journal-closed', 'journal-reopened', + 'journal-prepared-verified', + ]) { + failure = step; + await assert.rejects(store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ), /injected/); + failure = null; + const restarted = new ProfileStore(directory, encryption()); + assert.deepEqual(await restarted.readCredential(profile.id), credentialA, step); + assert.deepEqual(await restarted.list(), { profiles: [profile], activeProfileId: profile.id }, step); + } + }); + + it('fails closed before C and preserves fully verified B when the C flush fails', async () => { + const failures: ProfileStoreIOOperation[] = [ + 'credential-write', 'credential-flush', 'credential-replace', + 'mirror-write', 'mirror-flush', 'metadata-flush', + 'journal-write', 'journal-flush', 'journal-reopen', 'journal-verify', 'journal-commit', + ]; + let completedFailures = 0; + for (const operation of failures) { + const directory = await createDirectory(); + let injected: ProfileStoreIOOperation | null = null; + let published = false; + const store = new ProfileStore(directory, encryption(), { + beforeIO: current => { + if (current === injected) throw new Error(`injected ${current} failure`); + }, + }); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profile.id, 'A'); + await store.writeCredential(credentialA); + await store.setActive(profile.id); + const baseline = await store.readProfileCredential(profile.id); + injected = operation; + await assert.rejects(store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, undefined, () => { published = true; }, + ), /injected/); + injected = null; + assert.equal(published, false, operation); + const restarted = new ProfileStore(directory, encryption()); + assert.equal((await restarted.list()).profiles[0].label, 'Original', operation); + assert.deepEqual(await restarted.readCredential(profile.id), credentialA, operation); + assert.deepEqual(await restarted.pendingRevocations(), [], operation); + completedFailures += 1; + } + + const directory = await createDirectory(); + let injected: ProfileStoreIOOperation | null = null; + let published = false; + const store = new ProfileStore(directory, encryption(), { + beforeIO: current => { + if (current === injected) throw new Error(`injected ${current} failure`); + }, + }); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + const baseline = await store.readProfileCredential(profile.id); + injected = 'journal-commit-flush'; + await assert.rejects(store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, undefined, () => { published = true; }, + ), /injected journal-commit-flush/); + injected = null; + assert.equal(published, true); + const restarted = new ProfileStore(directory, encryption()); + assert.equal((await restarted.list()).profiles[0].label, 'Replacement'); + assert.deepEqual(await restarted.readCredential(profile.id), credential(profile.id, 'B')); + assert.equal((await restarted.pendingRevocations()).length, 1); + completedFailures += 1; + + const corruptDirectory = await createDirectory(); + const corruptDesktop = join(corruptDirectory, 'desktop'); + let corruptPrepared = false; + const corruptingStore = new ProfileStore(corruptDirectory, encryption(), { + afterDurabilityStep: async step => { + if (!corruptPrepared || step !== 'journal-closed') return; + corruptPrepared = false; + for (const name of ['profiles.journal.0', 'profiles.journal.1']) { + const path = join(corruptDesktop, name); + try { + const bytes = await readFile(path); + if (bytes[0] !== 'P'.charCodeAt(0)) continue; + bytes[Math.min(20, bytes.length - 1)] ^= 1; + await writeFile(path, bytes); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + throw new Error('prepared journal was not found'); + }, + }); + const corruptProfile = await corruptingStore.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const corruptA = credential(corruptProfile.id, 'A'); + await corruptingStore.writeCredential(corruptA); + const corruptBaseline = await corruptingStore.readProfileCredential(corruptProfile.id); + corruptPrepared = true; + await assert.rejects(corruptingStore.commitPairedProfile( + { id: corruptProfile.id, label: 'Replacement', apiBaseUrl: corruptProfile.apiBaseUrl }, + credential(corruptProfile.id, 'B'), corruptBaseline, () => true, + ), /Desktop profile recovery state is unavailable/); + const corruptRestart = new ProfileStore(corruptDirectory, encryption()); + assert.equal((await corruptRestart.list()).profiles[0].label, 'Original'); + assert.deepEqual(await corruptRestart.readCredential(corruptProfile.id), corruptA); + completedFailures += 1; + console.log(`NATIVE_CATEGORY barriers expected=${failures.length + 2} executed=${completedFailures}`); + }); + + it('treats mirror replace and directory-flush failures after the journal commit as recoverable mirror failures', async () => { + for (const operation of ['mirror-replace', 'metadata-flush'] as const) { + const directory = await createDirectory(); + let injected: ProfileStoreIOOperation | null = null; + let journalCommitted = false; + const store = new ProfileStore(directory, encryption(), { + afterDurabilityStep: step => { if (step === 'journal-commit-fsynced') journalCommitted = true; }, + beforeIO: current => { + if (journalCommitted && current === injected) throw new Error(`injected ${current} failure`); + }, + }); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + const baseline = await store.readProfileCredential(profile.id); + journalCommitted = false; + injected = operation; + const result = await store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ); + assert.ok(result && !('stored' in result), operation); + injected = null; + const restarted = new ProfileStore(directory, encryption()); + assert.equal((await restarted.list()).profiles[0].label, 'Replacement', operation); + assert.deepEqual(await restarted.readCredential(profile.id), credential(profile.id, 'B'), operation); + } + }); + + it('recovers real process crashes as complete A before the pointer commit and complete B after it', async () => { + const steps: ProfileStoreDurabilityStep[] = [ + 'credential-encrypted', 'credential-written', 'credential-fsynced', 'credential-renamed', + ...(process.platform === 'win32' ? [] : ['credential-directory-fsynced'] as const), + 'state-written', 'state-fsynced', 'journal-written', 'journal-fsynced', + 'journal-closed', 'journal-reopened', 'journal-prepared-verified', + 'journal-committed', 'journal-commit-fsynced', 'journal-commit-verified', + 'journal-commit-closed', 'state-renamed', + ...(process.platform === 'win32' ? [] : ['state-directory-fsynced'] as const), + ]; + assert.equal(steps.length, process.platform === 'win32' ? 16 : 18); + let completed = 0; + for (const step of steps) { + const directory = await createDirectory(); + const setup = new ProfileStore(directory, encryption()); + const profileA = await setup.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profileA.id, 'A'); + await setup.writeCredential(credentialA); + const child = spawn(process.execPath, [ + '--import', 'tsx', join(import.meta.dirname, 'profile-store-crash-fixture.ts'), directory, step, + ], { stdio: 'ignore' }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal( + result.signal === 'SIGKILL' || (process.platform === 'win32' && result.code !== 0), + true, + `${step}: child did not crash at the requested boundary`, + ); + + const restarted = new ProfileStore(directory, encryption()); + const snapshot = await restarted.readProfileCredential(profileA.id); + const committed = step === 'journal-committed' + || step === 'journal-commit-fsynced' + || step === 'journal-commit-verified' + || step === 'journal-commit-closed' + || step === 'state-renamed' + || step === 'state-directory-fsynced'; + assert.equal(snapshot.profile?.label, committed ? 'Replacement' : 'Original', step); + assert.deepEqual(snapshot.credential, credential(profileA.id, committed ? 'B' : 'A'), step); + assert.equal((await restarted.pendingRevocations()).length, committed ? 1 : 0, step); + const files = await readdir(join(directory, 'desktop', 'credentials')); + assert.equal(files.length, committed ? 2 : 1, `${step}: recovery did not retain exactly the authoritative and pending slots`); + const desktopFiles = await readdir(join(directory, 'desktop')); + assert.equal(desktopFiles.some(file => file.endsWith('.tmp')), false, `${step}: recovery left staging files`); + completed += 1; + } + assert.equal(completed, steps.length, 'a native durability boundary fixture was skipped'); + console.log(`NATIVE_CATEGORY transaction-boundaries expected=${steps.length} executed=${completed}`); + }); + + it('recovers profile deletion crashes as active A or detached pending A at the journal commit', async () => { + const steps = RECOVERY_KILL_STEPS; + let completed = 0; + for (const step of steps) { + const directory = await createDirectory(); + const setup = new ProfileStore(directory, encryption()); + const profile = await setup.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profile.id, 'A'); + await setup.writeCredential(credentialA); + await setup.setActive(profile.id); + const child = spawn(process.execPath, [ + '--import', 'tsx', join(import.meta.dirname, 'profile-store-crash-fixture.ts'), + directory, `detach:${step}`, + ], { stdio: 'ignore' }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal( + result.signal === 'SIGKILL' || (process.platform === 'win32' && result.code !== 0), + true, + `${step}: detach child did not crash at the requested boundary`, + ); + const committed = step === 'journal-committed' + || step === 'journal-commit-fsynced' + || step === 'journal-commit-verified' + || step === 'journal-commit-closed' + || step === 'state-renamed' + || step === 'state-directory-fsynced'; + const restarted = new ProfileStore(directory, encryption()); + const snapshot = await restarted.readProfileCredential(profile.id); + assert.equal(snapshot.profile?.id ?? null, committed ? null : profile.id, step); + assert.deepEqual(snapshot.credential, committed ? null : credentialA, step); + const pending = await restarted.pendingRevocations(); + assert.equal(pending.length, committed ? 1 : 0, step); + if (committed) assert.deepEqual(pending[0].credential, credentialA, step); + console.log('NATIVE_SCENARIO detach-crash'); + completed += 1; + } + assert.equal(completed, steps.length); + }); + + it('recovers every first bootstrap and v1/v2 migration child-process kill without activating prepared B', async () => { + let completed = 0; + for (const mode of RECOVERY_KILL_MODES) { + for (const step of RECOVERY_KILL_STEPS) { + const directory = await createDirectory(); + await seedRecoveryMode(directory, mode); + const child = spawn(process.execPath, [ + '--import', 'tsx', join(import.meta.dirname, 'profile-store-crash-fixture.ts'), + directory, `recovery:${mode}:${step}`, + ], { stdio: 'ignore' }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal( + result.signal === 'SIGKILL' || (process.platform === 'win32' && result.code !== 0), + true, + `${mode}/${step}: child did not crash at the requested boundary`, + ); + + const desktop = join(directory, 'desktop'); + const committed = step === 'journal-committed' + || step === 'journal-commit-fsynced' + || step === 'journal-commit-verified' + || step === 'journal-commit-closed' + || step === 'state-renamed' + || step === 'state-directory-fsynced'; + const journals = await Promise.all([0, 1].map(async index => { + try { return await readFile(join(desktop, `profiles.journal.${index}`), 'utf8'); } catch { return null; } + })); + if (committed) assert.equal(journals.some(value => value?.startsWith('C')), true, `${mode}/${step}`); + else assert.equal(journals.some(value => value?.startsWith('C')), false, `${mode}/${step}`); + + for (let restart = 0; restart < 3; restart += 1) { + const recovered = new ProfileStore(directory, encryption()); + if (mode === 'bootstrap') { + assert.deepEqual(await recovered.list(), { profiles: [], activeProfileId: null }, `${mode}/${step}/${restart}`); + } else { + const snapshot = await recovered.readProfileCredential(legacyProfile.id); + assert.deepEqual(snapshot.profile, legacyProfile, `${mode}/${step}/${restart}`); + assert.equal(snapshot.credential, null, `${mode}/${step}/${restart}`); + assert.equal(snapshot.activeProfileId, null, `${mode}/${step}/${restart}`); + assert.equal(snapshot.identityEpoch, null, `${mode}/${step}/${restart}`); + } + const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { version: number }; + assert.equal(state.version, 3, `${mode}/${step}/${restart}`); + } + completed += 1; + } + } + assert.equal(completed, RECOVERY_KILL_MODES.length * RECOVERY_KILL_STEPS.length); + console.log(`NATIVE_CATEGORY bootstrap-migration expected=${completed} executed=${completed}`); + }); + + it('binds verified prepared bytes to one handle across same-size swaps and path-restoration ABA', async () => { + let completed = 0; + for (const restoreOriginalPath of [false, true]) { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + let swapPrepared = false; + let attackerPath = ''; + const store = new ProfileStore(directory, encryption(), { + afterDurabilityStep: async step => { + if (!swapPrepared || step !== 'journal-prepared-verified') return; + swapPrepared = false; + const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { generation: string }; + const preparedPath = join(desktop, `profiles.journal.${Number((BigInt(state.generation) + 1n) % 2n)}`); + const preparedContents = await readFile(preparedPath, 'utf8'); + assert.equal(preparedContents[0], 'P'); + const envelope = JSON.parse(preparedContents.slice(1)) as { + version: 2; generation: string; encryptedPayload: string; checksum: string; + }; + const payload = JSON.parse(encryption().decrypt(Buffer.from(envelope.encryptedPayload, 'base64url'))) as { + state: { profiles: Array<{ label: string }>; credentialSlots: Record }; + encryptedSlots: Record; + }; + payload.state.profiles[0].label = 'Attacker!!!'; + const slot = payload.state.credentialSlots['profile-1']; + const attackerCredential = JSON.parse( + encryption().decrypt(Buffer.from(payload.encryptedSlots[slot], 'base64url')), + ) as ReturnType; + attackerCredential.token = `propr_it_${'X'.repeat(43)}`; + payload.encryptedSlots[slot] = encryption().encrypt(JSON.stringify(attackerCredential)).toString('base64url'); + const encryptedPayload = encryption().encrypt(JSON.stringify(payload)).toString('base64url'); + const attackerContents = `P${JSON.stringify({ + ...envelope, + encryptedPayload, + checksum: createHash('sha256').update(encryptedPayload).digest('base64url'), + })}\n`; + assert.equal(Buffer.byteLength(attackerContents), Buffer.byteLength(preparedContents)); + const heldPath = `${preparedPath}.held`; + attackerPath = restoreOriginalPath ? `${preparedPath}.attacker` : preparedPath; + await rename(preparedPath, heldPath); + await writeFile(preparedPath, attackerContents, { mode: 0o600 }); + if (restoreOriginalPath) { + await rename(preparedPath, attackerPath); + await rename(heldPath, preparedPath); + } + }, + }); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profile.id, 'A'); + await store.writeCredential(credentialA); + const baseline = await store.readProfileCredential(profile.id); + swapPrepared = true; + const transaction = store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ); + if (restoreOriginalPath) { + const committed = await transaction; + assert.ok(committed && !('stored' in committed)); + } else { + await assert.rejects(transaction, /Desktop profile recovery state is unavailable/); + } + assert.equal((await readFile(attackerPath, 'utf8')).startsWith('P'), true); + + const restarted = new ProfileStore(directory, encryption()); + const snapshot = await restarted.readProfileCredential(profile.id); + assert.equal(snapshot.profile?.label, restoreOriginalPath ? 'Replacement' : 'Original'); + assert.deepEqual(snapshot.credential, credential(profile.id, restoreOriginalPath ? 'B' : 'A')); + assert.notEqual(snapshot.profile?.label, 'Attacker!!!'); + assert.notDeepEqual(snapshot.credential, credential(profile.id, 'X')); + completed += 1; + } + assert.equal(completed, 2); + console.log(`NATIVE_CATEGORY verified-handle-swap expected=2 executed=${completed}`); + }); + + for (const visibility of ['pointer-rollback', 'missing-target', 'state-before-journal'] as const) { + it(`recovers a ${visibility} durability view as complete A or complete B`, async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + const credentialsDirectory = join(desktop, 'credentials'); + const store = new ProfileStore(directory, encryption()); + const profileA = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + const credentialA = credential(profileA.id, 'A'); + await store.writeCredential(credentialA); + const stateA = await readFile(join(desktop, 'profiles.json')); + const journalsA = await Promise.all([0, 1].map(async index => { + try { return await readFile(join(desktop, `profiles.journal.${index}`)); } catch { return null; } + })); + const baseline = await store.readProfileCredential(profileA.id); + await store.commitPairedProfile( + { id: profileA.id, label: 'Replacement', apiBaseUrl: profileA.apiBaseUrl }, + credential(profileA.id, 'B'), baseline, () => true, + ); + const stateB = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { + credentialSlots: Record; + }; + + if (visibility === 'pointer-rollback') { + await writeFile(join(desktop, 'profiles.json'), stateA); + } else if (visibility === 'missing-target') { + await unlink(join(credentialsDirectory, stateB.credentialSlots[profileA.id])); + } else { + for (const [index, bytes] of journalsA.entries()) { + const path = join(desktop, `profiles.journal.${index}`); + if (bytes) await writeFile(path, bytes); + else await unlink(path).catch(() => undefined); + } + } + + const restarted = new ProfileStore(directory, encryption()); + const recovered = await restarted.readProfileCredential(profileA.id); + const expectsB = visibility !== 'state-before-journal'; + assert.equal(recovered.profile?.label, expectsB ? 'Replacement' : 'Original'); + assert.deepEqual(recovered.credential, credential(profileA.id, expectsB ? 'B' : 'A')); + const activeSlotFiles = (await readdir(credentialsDirectory)).filter(file => file.endsWith('.bin')); + assert.equal(activeSlotFiles.length, expectsB ? 2 : 1); + }); + } + + for (const mirrorView of [ + 'missing', 'truncated', 'malformed', 'stale', 'schema-invalid', 'attacker-modified', + ] as const) { + it(`recovers the authoritative encrypted journal before a ${mirrorView} mirror`, async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + const mirror = join(desktop, 'profiles.json'); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + const stale = await readFile(mirror); + const baseline = await store.readProfileCredential(profile.id); + await store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ); + const current = JSON.parse(await readFile(mirror, 'utf8')) as Record; + if (mirrorView === 'missing') await unlink(mirror); + else if (mirrorView === 'truncated') await writeFile(mirror, '{"version":3'); + else if (mirrorView === 'malformed') await writeFile(mirror, 'not-json'); + else if (mirrorView === 'stale') await writeFile(mirror, stale); + else if (mirrorView === 'schema-invalid') { + await writeFile(mirror, JSON.stringify({ + ...current, version: 99, + })); + } else { + const profiles = current.profiles as Array>; + await writeFile(mirror, JSON.stringify({ + ...current, + profiles: profiles.map(value => ({ ...value, label: 'Attacker' })), + })); + } + + const restarted = new ProfileStore(directory, encryption()); + assert.equal((await restarted.list()).profiles[0].label, 'Replacement', mirrorView); + assert.deepEqual(await restarted.readCredential(profile.id), credential(profile.id, 'B'), mirrorView); + assert.equal((await restarted.pendingRevocations()).length, 1, mirrorView); + assert.equal((await readFile(mirror, 'utf8')).includes('Attacker'), false, mirrorView); + console.log('NATIVE_SCENARIO mirror-repair'); + }); + } + + it('fails with one fixed redacted error when neither mirror nor journal authenticates', async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + for (const name of ['profiles.journal.0', 'profiles.journal.1']) { + const path = join(desktop, name); + try { + const bytes = await readFile(path); + if (bytes[0] === 'C'.charCodeAt(0)) bytes[Math.min(20, bytes.length - 1)] ^= 1; + await writeFile(path, bytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + await assert.rejects( + new ProfileStore(directory, encryption()).list(), + error => (error as Error).message === 'Desktop profile recovery state is unavailable', + ); + await writeFile(join(desktop, 'profiles.json'), '{attacker'); + const restarted = new ProfileStore(directory, encryption()); + await assert.rejects(restarted.list(), error => { + assert.equal((error as Error).message, 'Desktop profile recovery state is unavailable'); + assert.equal((error as Error).message.includes(profile.id), false); + return true; + }); + + const ioDirectory = await createDirectory(); + const ioStore = new ProfileStore(ioDirectory, encryption()); + await ioStore.save({ + id: 'profile-io', label: 'I/O failure', apiBaseUrl: 'https://propr.example.com', + }); + const ioMirror = join(ioDirectory, 'desktop', 'profiles.json'); + await unlink(ioMirror); + await mkdir(ioMirror); + await assert.rejects(new ProfileStore(ioDirectory, encryption()).list(), error => { + assert.equal((error as Error).message, 'Desktop profile recovery state is unavailable'); + assert.equal((error as Error).message.includes('EISDIR'), false); + assert.equal((error as Error).message.includes(ioMirror), false); + return true; + }); + }); + + it('selects a lossless newest valid generation and survives alternate-slot rollback', async () => { + const directory = await createDirectory(); + const desktop = join(directory, 'desktop'); + const store = new ProfileStore(directory, encryption()); + const profile = await store.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await store.writeCredential(credential(profile.id, 'A')); + const baseline = await store.readProfileCredential(profile.id); + await store.commitPairedProfile( + { id: profile.id, label: 'Replacement', apiBaseUrl: profile.apiBaseUrl }, + credential(profile.id, 'B'), baseline, () => true, + ); + const mirror = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { generation: string }; + const newest = Number(BigInt(mirror.generation) % 2n); + const older = (newest + 1) % 2; + await writeFile( + join(desktop, `profiles.journal.${newest}`), + await readFile(join(desktop, `profiles.journal.${older}`)), + ); + const restarted = new ProfileStore(directory, encryption()); + const recovered = await restarted.readProfileCredential(profile.id); + assert.equal(recovered.profile?.label, 'Original'); + assert.deepEqual(recovered.credential, credential(profile.id, 'A')); + }); + + it('runs every native child-termination visibility fixture with an explicit scenario count', async () => { + assert.equal(NATIVE_VISIBILITY_SCENARIOS.length, 11); + if (process.env.PROPR_NATIVE_WINDOWS_DURABILITY_REQUIRED === '1') { + assert.equal(process.platform, 'win32', 'native Windows durability cannot run on a non-Windows host'); + assert.equal(process.arch, 'x64', 'native Windows durability must execute x64 production Node'); + } + let completed = 0; + for (const visibility of NATIVE_VISIBILITY_SCENARIOS) { + const directory = await createDirectory(); + const setup = new ProfileStore(directory, encryption()); + const profileA = await setup.save({ + id: 'profile-1', label: 'Original', apiBaseUrl: 'https://propr.example.com', + }); + await setup.writeCredential(credential(profileA.id, 'A')); + const child = spawn(process.execPath, [ + '--import', 'tsx', join(import.meta.dirname, 'profile-store-crash-fixture.ts'), + directory, `visibility:${visibility}`, + ], { stdio: 'ignore' }); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal(result.code === 0, false, `${visibility}: Windows child did not terminate`); + + const restarted = new ProfileStore(directory, encryption()); + const snapshot = await restarted.readProfileCredential(profileA.id); + const expectsB = visibility !== 'state-before-journal' && visibility !== 'alternate-slot-rollback'; + assert.equal(snapshot.profile?.label, expectsB ? 'Replacement' : 'Original', visibility); + assert.deepEqual(snapshot.credential, credential(profileA.id, expectsB ? 'B' : 'A'), visibility); + assert.equal((await restarted.pendingRevocations()).length, expectsB ? 1 : 0, visibility); + completed += 1; + } + assert.equal(completed, NATIVE_VISIBILITY_SCENARIOS.length, 'a native visibility fixture was skipped'); + console.log( + `NATIVE_CATEGORY reordered-visibility expected=${NATIVE_VISIBILITY_SCENARIOS.length} executed=${completed}`, + ); + }); + + it('removes an orphan credential before allowing same-ID recreation', async () => { + const directory = await createDirectory(); + const store = new ProfileStore(directory, encryption()); + await store.writeCredential(credential('profile-1')); + + assert.equal(await store.detachProfile('profile-1'), null); + const recreated = await store.save({ id: 'profile-1', label: 'Recreated', apiBaseUrl: 'https://propr.example.com' }); + + assert.equal(recreated.id, 'profile-1'); + assert.equal(await store.readCredential('profile-1'), null); + }); + +}); diff --git a/apps/desktop/src/profile-store.ts b/apps/desktop/src/profile-store.ts new file mode 100644 index 000000000..c76f0916b --- /dev/null +++ b/apps/desktop/src/profile-store.ts @@ -0,0 +1,1526 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + open, + readFile, + readdir, + rename, + stat, + unlink, + writeFile, + type FileHandle, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { isPublicInstanceIdentity } from '@propr/shared'; +import type { + DesktopProfile, + DesktopProfileInput, + DesktopProfileList, + StorageSecurity, +} from './shared/contract'; +import { normalizeApiBaseUrl } from './security'; + +const PROFILE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; +const MAX_CREDENTIAL_LENGTH = 65_536; + +export interface StoredCredential { + version: 2; + profileId: string; + origin: string; + publicInstanceIdentity: string; + token: string; +} + +export interface DetachedProfile { + profile: DesktopProfile; + credential: StoredCredential | null; +} + +export interface SavedProfileTransaction { + profile: DesktopProfile; + detachedCredential: StoredCredential | null; + originChanged: boolean; +} + +export interface PairedProfileTransaction { + profile: DesktopProfile; + identityEpoch: string; + originChanged: boolean; +} + +export interface ProfileCredentialSnapshot { + profile: DesktopProfile | null; + credential: StoredCredential | null; + identityEpoch: string | null; + activeProfileId: string | null; +} + +interface LegacyPersistedState { + version: 1; + activeProfileId: string | null; + profiles: DesktopProfile[]; +} + +interface VersionTwoPersistedState { + version: 2; + activeProfileId: string | null; + profiles: DesktopProfile[]; + credentialSlots: Record; +} + +interface PendingRevocationRecord { + version: 1; + profileId: string; + origin: string; + slot: string; + credentialGeneration: string; + deferred: boolean; +} + +interface PersistedState { + version: 3; + generation: string; + activeProfileId: string | null; + profiles: DesktopProfile[]; + credentialSlots: Record; + credentialEpochs: Record; + pendingRevocations: Record; +} + +interface JournalPayload { + version: 1; + state: PersistedState; + encryptedSlots: Record; +} + +interface LegacyJournalRecord extends JournalPayload { + checksum: string; +} + +interface JournalRecord { + version: 2; + generation: string; + encryptedPayload: string; + checksum: string; +} + +interface AuthenticatedJournal { + generation: bigint; + state: PersistedState; + encryptedSlots: Record; +} + +export interface PendingCredentialRevocation { + id: string; + credential: StoredCredential; + credentialGeneration: string; + deferred: boolean; +} + +export interface EncryptionProvider { + isEncryptionAvailable(): boolean; + backend(): string; + encrypt(value: string): Buffer; + decrypt(value: Buffer): string; +} + +export type ProfileStoreDurabilityStep = + | 'credential-encrypted' + | 'credential-written' + | 'credential-fsynced' + | 'credential-renamed' + | 'credential-directory-fsynced' + | 'state-written' + | 'state-fsynced' + | 'journal-written' + | 'journal-fsynced' + | 'journal-closed' + | 'journal-reopened' + | 'journal-prepared-verified' + | 'journal-committed' + | 'journal-commit-fsynced' + | 'journal-commit-verified' + | 'journal-commit-closed' + | 'state-renamed' + | 'state-directory-fsynced' + | 'old-credential-removed'; + +export interface ProfileStoreOptions { + afterDurabilityStep?(step: ProfileStoreDurabilityStep): void | Promise; + beforeIO?(operation: ProfileStoreIOOperation): void | Promise; +} + +export type ProfileStoreIOOperation = + | 'credential-write' + | 'credential-flush' + | 'credential-replace' + | 'journal-write' + | 'journal-flush' + | 'journal-reopen' + | 'journal-commit' + | 'journal-commit-flush' + | 'journal-verify' + | 'mirror-write' + | 'mirror-flush' + | 'mirror-replace' + | 'metadata-flush'; + +const emptyState = (): PersistedState => ({ + version: 3, + generation: '0', + activeProfileId: null, + profiles: [], + credentialSlots: {}, + credentialEpochs: {}, + pendingRevocations: {}, +}); + +const SLOT_PATTERN = /^([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})\.[0-9a-f-]{36}\.bin$/i; +const IDENTITY_EPOCH_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const MAX_PENDING_REVOCATIONS = 64; +const MAX_JOURNAL_BYTES = (MAX_PENDING_REVOCATIONS + 1) * (MAX_CREDENTIAL_LENGTH * 2 + 4_096); +const RECOVERY_ERROR = 'Desktop profile recovery state is unavailable'; + +/** + * Flush an existing file through a writable handle. Windows rejects fsync on + * the read-only handle Node creates for `open(path, 'r')`; O_WRONLY is the + * minimum access libuv needs for FlushFileBuffers and works on POSIX too. + */ +export const flushFileData = async (path: string): Promise => { + const handle = await open(path, constants.O_WRONLY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +const validDate = (value: unknown): value is string => + typeof value === 'string' && !Number.isNaN(Date.parse(value)); + +const validProfile = (value: unknown): value is DesktopProfile => { + if (!value || typeof value !== 'object') return false; + const profile = value as Record; + return typeof profile.id === 'string' + && PROFILE_ID_PATTERN.test(profile.id) + && typeof profile.label === 'string' + && profile.label.length > 0 + && profile.label.length <= 80 + && typeof profile.apiBaseUrl === 'string' + && normalizeApiBaseUrl(profile.apiBaseUrl) === profile.apiBaseUrl + && validDate(profile.createdAt) + && validDate(profile.updatedAt); +}; + +const validCredentialSlots = (value: unknown): value is Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const slots = new Set(); + for (const [profileId, slot] of Object.entries(value as Record)) { + if (!PROFILE_ID_PATTERN.test(profileId) || typeof slot !== 'string' + || SLOT_PATTERN.exec(slot)?.[1] !== profileId || slots.has(slot)) return false; + slots.add(slot); + } + return true; +}; + +const parseState = (contents: string): PersistedState | VersionTwoPersistedState | LegacyPersistedState => { + const value = JSON.parse(contents) as unknown; + if (!value || typeof value !== 'object') throw new Error('Desktop profile store is invalid'); + const state = value as Record; + if ((state.version !== 1 && state.version !== 2 && state.version !== 3) + || !Array.isArray(state.profiles) || !state.profiles.every(validProfile)) { + throw new Error('Desktop profile store is invalid'); + } + if (state.activeProfileId !== null && ( + typeof state.activeProfileId !== 'string' + || !state.profiles.some((profile: DesktopProfile) => profile.id === state.activeProfileId) + )) { + throw new Error('Desktop active profile is invalid'); + } + if (state.version === 2 && !validCredentialSlots(state.credentialSlots)) { + throw new Error('Desktop credential state is invalid'); + } + if (state.version === 3) { + if (!((typeof state.generation === 'string' && /^(?:0|[1-9][0-9]{0,30})$/.test(state.generation)) + || (Number.isSafeInteger(state.generation) && (state.generation as number) >= 0)) + || !validCredentialSlots(state.credentialSlots) + || !state.credentialEpochs || typeof state.credentialEpochs !== 'object' + || Array.isArray(state.credentialEpochs) + || !state.pendingRevocations || typeof state.pendingRevocations !== 'object' + || Array.isArray(state.pendingRevocations)) throw new Error('Desktop credential state is invalid'); + const slots = state.credentialSlots as Record; + const epochs = state.credentialEpochs as Record; + if (Object.keys(slots).length !== Object.keys(epochs).length + || Object.entries(epochs).some(([profileId, epoch]) => !(profileId in slots) + || typeof epoch !== 'string' || !IDENTITY_EPOCH_PATTERN.test(epoch))) { + throw new Error('Desktop credential identity state is invalid'); + } + const pending = Object.entries(state.pendingRevocations as Record); + if (pending.length > MAX_PENDING_REVOCATIONS) throw new Error('Desktop revocation state is invalid'); + const pendingSlots = new Set(); + for (const [id, raw] of pending) { + if (!/^[0-9a-f-]{36}$/i.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Desktop revocation state is invalid'); + } + const record = raw as Record; + if (record.credentialGeneration === undefined && typeof record.slot === 'string') { + record.credentialGeneration = createHash('sha256') + .update(record.slot) + .digest() + .subarray(0, 16) + .toString('base64url'); + } + if (record.deferred === undefined) record.deferred = false; + if (record.version !== 1 || typeof record.profileId !== 'string' + || !PROFILE_ID_PATTERN.test(record.profileId) || typeof record.origin !== 'string' + || normalizeApiBaseUrl(record.origin) !== record.origin || typeof record.slot !== 'string' + || typeof record.credentialGeneration !== 'string' + || !IDENTITY_EPOCH_PATTERN.test(record.credentialGeneration) + || typeof record.deferred !== 'boolean' + || SLOT_PATTERN.exec(record.slot)?.[1] !== record.profileId + || Object.values(slots).includes(record.slot) || pendingSlots.has(record.slot)) { + throw new Error('Desktop revocation state is invalid'); + } + pendingSlots.add(record.slot); + } + state.generation = String(state.generation); + } + return state as unknown as PersistedState | VersionTwoPersistedState | LegacyPersistedState; +}; + +const journalChecksum = (value: string | Buffer): string => + createHash('sha256').update(value).digest('base64url'); + +const parseLegacyJournal = (contents: string): LegacyJournalRecord => { + const value = JSON.parse(contents) as unknown; + if (!value || typeof value !== 'object') throw new Error('Desktop transaction journal is invalid'); + const record = value as LegacyJournalRecord; + const rawPayload = { version: 1 as const, state: record.state, encryptedSlots: record.encryptedSlots }; + if (record.checksum !== journalChecksum(JSON.stringify(rawPayload))) { + throw new Error('Desktop transaction journal checksum failed'); + } + const state = parseState(JSON.stringify(record.state)); + if (record.version !== 1 || state.version !== 3 || !record.encryptedSlots + || typeof record.encryptedSlots !== 'object' || Array.isArray(record.encryptedSlots) + || Object.entries(record.encryptedSlots).some(([slot, bytes]) => !SLOT_PATTERN.test(slot) + || typeof bytes !== 'string' || !/^[A-Za-z0-9_-]*$/.test(bytes))) { + throw new Error('Desktop transaction journal is invalid'); + } + const payload: JournalPayload = { version: 1, state, encryptedSlots: record.encryptedSlots }; + return { ...payload, checksum: record.checksum }; +}; + +const parseJournalEnvelope = (contents: string): JournalRecord => { + if (Buffer.byteLength(contents) > MAX_JOURNAL_BYTES) throw new Error('Desktop transaction journal is invalid'); + const value = JSON.parse(contents) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Desktop transaction journal is invalid'); + } + const record = value as Record; + if (record.version !== 2 || typeof record.generation !== 'string' + || !/^(?:0|[1-9][0-9]{0,30})$/.test(record.generation) + || typeof record.encryptedPayload !== 'string' + || record.encryptedPayload.length === 0 + || !/^[A-Za-z0-9_-]+$/.test(record.encryptedPayload) + || typeof record.checksum !== 'string' + || record.checksum !== journalChecksum(record.encryptedPayload)) { + throw new Error('Desktop transaction journal is invalid'); + } + return record as unknown as JournalRecord; +}; + +const encryptionStatus = (encryption: EncryptionProvider): StorageSecurity => { + const backend = encryption.backend(); + if (!encryption.isEncryptionAvailable()) { + return { available: false, backend, reason: 'os-encryption-unavailable' }; + } + if (backend === 'basic_text') { + return { available: false, backend, reason: 'insecure-basic-text-backend' }; + } + return { available: true, backend }; +}; + +const assertProfileId: (profileId: unknown) => asserts profileId is string = (profileId) => { + if (typeof profileId !== 'string' || !PROFILE_ID_PATTERN.test(profileId)) { + throw new Error('Invalid desktop profile id'); + } +}; + +const normalizedProfileInput = (input: DesktopProfileInput): Omit => { + if (!input || typeof input !== 'object') throw new Error('Invalid desktop profile'); + const label = input.label?.trim(); + const apiBaseUrl = normalizeApiBaseUrl(input.apiBaseUrl ?? ''); + if (!label || label.length > 80) throw new Error('Profile label must contain 1 to 80 characters'); + if (!apiBaseUrl) throw new Error('Use HTTPS, or HTTP on localhost, for the ProPR API URL'); + const id = input.id ?? randomUUID(); + assertProfileId(id); + return { id, label, apiBaseUrl }; +}; + +export class ProfileStore { + readonly #directory: string; + readonly #statePath: string; + readonly #journalPaths: readonly [string, string]; + readonly #credentialsDirectory: string; + readonly #encryption: EncryptionProvider; + readonly #options: ProfileStoreOptions; + readonly #authenticatedJournalCache = new Map(); + #mutation = Promise.resolve(); + #closed = false; + #closePromise: Promise | null = null; + + constructor(userDataPath: string, encryption: EncryptionProvider, options: ProfileStoreOptions = {}) { + this.#directory = join(userDataPath, 'desktop'); + this.#statePath = join(this.#directory, 'profiles.json'); + this.#journalPaths = [ + join(this.#directory, 'profiles.journal.0'), + join(this.#directory, 'profiles.journal.1'), + ]; + this.#credentialsDirectory = join(this.#directory, 'credentials'); + this.#encryption = encryption; + this.#options = options; + } + + security(): StorageSecurity { + return encryptionStatus(this.#encryption); + } + + /** Resolves after every queued recovery, mutation, and cleanup operation has settled. */ + awaitIdle(): Promise { + return this.#mutation; + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.#closed = true; + this.#closePromise = this.awaitIdle(); + return this.#closePromise; + } + + list(): Promise { + return this.#mutate(async () => { + const state = await this.#readState(); + return { + profiles: state.profiles.map(profile => ({ ...profile })), + activeProfileId: state.activeProfileId, + }; + }); + } + + save(input: DesktopProfileInput): Promise { + return this.saveAndDetachCredential(input).then(result => result.profile); + } + + saveAndDetachCredential( + input: DesktopProfileInput, + beforeOriginChangeCommit?: (previousOrigin: string, nextOrigin: string) => Promise, + ): Promise { + return this.#mutate(async () => { + const normalized = normalizedProfileInput(input); + const state = await this.#readState(); + const existing = state.profiles.find(profile => profile.id === normalized.id); + const originChanged = existing !== undefined && existing.apiBaseUrl !== normalized.apiBaseUrl; + if (originChanged) { + await beforeOriginChangeCommit?.(existing.apiBaseUrl, normalized.apiBaseUrl); + } + let detachedCredential: StoredCredential | null = null; + if (!existing || originChanged) { + detachedCredential = (await this.#moveCredentialToPending(state, normalized.id))?.credential ?? null; + if (originChanged && state.activeProfileId === normalized.id) state.activeProfileId = null; + } + const now = new Date().toISOString(); + const profile: DesktopProfile = { + ...normalized, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + state.profiles = [...state.profiles.filter(item => item.id !== profile.id), profile]; + const durable = await this.#writeState(state); + return { profile: { ...profile }, detachedCredential: durable ? detachedCredential : null, originChanged }; + }); + } + + commitPairedProfile( + input: DesktopProfileInput, + credential: StoredCredential, + expected: ProfileCredentialSnapshot, + isCurrent: () => boolean, + beginPublish?: () => (() => void) | null, + onPublished?: () => void, + pendingRevocationId?: string, + ): Promise { + const normalized = normalizedProfileInput(input); + if (credential.version !== 2 + || credential.profileId !== normalized.id + || credential.origin !== normalized.apiBaseUrl + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) + || typeof credential.token !== 'string' + || credential.token.length > MAX_CREDENTIAL_LENGTH + || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { + throw new Error('Credential does not match the paired desktop profile'); + } + if (!this.security().available) return Promise.resolve({ stored: false, reason: 'encryption-unavailable' }); + + return this.#mutate(async () => { + const state = await this.#readState(); + const existing = state.profiles.find(profile => profile.id === normalized.id) ?? null; + const existingCredential = await this.#readCredentialFile(state, normalized.id); + const existingEpoch = state.credentialEpochs[normalized.id] ?? null; + if (!isCurrent() + || state.activeProfileId !== expected.activeProfileId + || !this.#sameProfile(existing, expected.profile) + || !this.#sameOptionalCredential(existingCredential, expected.credential) + || existingEpoch !== expected.identityEpoch) return null; + + const now = new Date().toISOString(); + const profile: DesktopProfile = { + ...normalized, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + const originChanged = existing !== null && existing.apiBaseUrl !== profile.apiBaseUrl; + + const previousSlot = state.credentialSlots[profile.id]; + const pending = pendingRevocationId ? state.pendingRevocations[pendingRevocationId] : undefined; + if (pendingRevocationId && !pending) return null; + const stagedSlot = pending?.slot ?? await this.#stageCredential(credential); + const identityEpoch = pending?.credentialGeneration ?? randomBytes(16).toString('base64url'); + const stagedByThisCall = !pending; + if (pending) { + const pendingCredential = await this.#readCredentialSlot(pending.slot, pending.profileId); + if (pending.profileId !== credential.profileId || pending.origin !== credential.origin + || !this.#sameCredential(pendingCredential, credential)) { + throw new Error('Pending desktop credential does not match the paired profile'); + } + } + let committed = false; + try { + if (!isCurrent()) return null; + // Promote B and detach A through the same pending transition used by + // deletion, origin edits and explicit credential replacement. These + // are only in-memory changes until the single journal commit below. + if (pendingRevocationId) delete state.pendingRevocations[pendingRevocationId]; + if (previousSlot) await this.#moveCredentialToPending(state, profile.id); + state.profiles = [...state.profiles.filter(item => item.id !== profile.id), profile]; + if (originChanged && state.activeProfileId === profile.id) state.activeProfileId = null; + // The staged slot is durable while the old state still names A. This + // single atomic state-file rename is the only A -> B commit point. + state.credentialSlots[profile.id] = stagedSlot; + state.credentialEpochs[profile.id] = identityEpoch; + const durable = await this.#writeState(state, isCurrent, beginPublish, onPublished); + if (durable === null) return null; + committed = true; + return { + profile: { ...profile }, + identityEpoch, + originChanged, + }; + } finally { + if (!committed && stagedByThisCall) { + await this.#unlinkSlot(stagedSlot).catch(() => undefined); + } + } + }); + } + + remove(profileId: string): Promise { + return this.detachProfile(profileId).then(() => undefined); + } + + detachProfile( + profileId: string, + beforeCommit?: (origin: string) => Promise, + ): Promise { + assertProfileId(profileId); + return this.#mutate(async () => { + const state = await this.#readState(); + const profile = state.profiles.find(item => item.id === profileId); + if (profile) await beforeCommit?.(profile.apiBaseUrl); + const previousSlot = state.credentialSlots[profileId]; + const credential = (await this.#moveCredentialToPending(state, profileId))?.credential ?? null; + if (!profile && !previousSlot) return null; + state.profiles = state.profiles.filter(profile => profile.id !== profileId); + if (state.activeProfileId === profileId) state.activeProfileId = null; + const durable = await this.#writeState(state); + if (!profile) return null; + return { profile: { ...profile }, credential: durable ? credential : null }; + }); + } + + activateProfile( + expected: StoredCredential, + expectedIdentityEpoch: string, + expectedProfileOrigin: string, + expectedActiveProfileId: string | null, + isCurrent: () => boolean, + ): Promise { + const profileId = expected?.profileId; + assertProfileId(profileId); + if (normalizeApiBaseUrl(expectedProfileOrigin) !== expectedProfileOrigin) { + throw new Error('Invalid desktop API URL'); + } + if (expectedActiveProfileId !== null) assertProfileId(expectedActiveProfileId); + return this.#mutate(async () => { + const state = await this.#readState(); + const profile = state.profiles.find(item => item.id === profileId); + const credential = await this.#readCredentialFile(state, profileId); + if (!isCurrent() + || state.activeProfileId !== expectedActiveProfileId + || profile?.apiBaseUrl !== expectedProfileOrigin + || expected.origin !== expectedProfileOrigin + || credential?.origin !== profile.apiBaseUrl + || state.credentialEpochs[profileId] !== expectedIdentityEpoch + || !this.#sameCredential(credential, expected)) return null; + + const previousActiveProfileId = state.activeProfileId; + state.activeProfileId = profileId; + await this.#writeState(state); + if (isCurrent()) return expectedIdentityEpoch; + + // A generation/selection change that occurred during the atomic file + // replacement must not leave the candidate selected. + state.activeProfileId = previousActiveProfileId; + await this.#writeState(state); + return null; + }); + } + + setActive(profileId: string | null): Promise { + if (profileId !== null) assertProfileId(profileId); + return this.#mutate(async () => { + const state = await this.#readState(); + if (profileId !== null && !state.profiles.some(profile => profile.id === profileId)) { + throw new Error('Desktop profile does not exist'); + } + state.activeProfileId = profileId; + await this.#writeState(state); + }); + } + + readCredential(profileId: string): Promise { + assertProfileId(profileId); + if (!this.security().available) return Promise.resolve(null); + return this.#mutate(async () => this.#readCredentialFile(await this.#readState(), profileId)); + } + + readProfileCredential(profileId: string): Promise { + assertProfileId(profileId); + return this.#mutate(async () => { + const state = await this.#readState(); + const profile = state.profiles.find(item => item.id === profileId) ?? null; + const credential = this.security().available + ? await this.#readCredentialFile(state, profileId) + : null; + return { + profile: profile ? { ...profile } : null, + credential, + identityEpoch: state.credentialEpochs[profileId] ?? null, + activeProfileId: state.activeProfileId, + }; + }); + } + + async #readCredentialFile(state: PersistedState, profileId: string): Promise { + const slot = state.credentialSlots[profileId]; + if (!slot) return null; + return this.#readCredentialSlot(slot, profileId); + } + + async #readCredentialSlot(slot: string, profileId: string): Promise { + try { + const encrypted = await readFile(join(this.#credentialsDirectory, slot)); + const value = JSON.parse(this.#encryption.decrypt(encrypted)) as unknown; + if (!value || typeof value !== 'object') return null; + const credential = value as Record; + if (credential.version !== 2 || credential.profileId !== profileId + || typeof credential.origin !== 'string' + || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) + || typeof credential.token !== 'string' + || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) return null; + return credential as unknown as StoredCredential; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + if (error instanceof SyntaxError) return null; + throw error; + } + } + + async #moveCredentialToPending( + state: PersistedState, + profileId: string, + ): Promise<(Omit & { credential: StoredCredential | null }) | null> { + const slot = state.credentialSlots[profileId]; + if (!slot) return null; + if (Object.keys(state.pendingRevocations).length >= MAX_PENDING_REVOCATIONS) { + throw new Error('Pending desktop credential revocations must complete before changing profiles.'); + } + let credential: StoredCredential | null = null; + try { + credential = await this.#readCredentialSlot(slot, profileId); + } catch { + // The slot bytes were authenticated by the prior committed journal. Keep + // them durable even while a keychain/backend read is temporarily failing. + } + const credentialGeneration = state.credentialEpochs[profileId]; + const profile = state.profiles.find(item => item.id === profileId); + if (!credentialGeneration || (!credential && !profile)) { + throw new Error('Desktop credential cannot be safely detached for revocation.'); + } + const id = randomUUID(); + state.pendingRevocations[id] = { + version: 1, + profileId, + origin: credential?.origin ?? profile!.apiBaseUrl, + slot, + credentialGeneration, + deferred: false, + }; + delete state.credentialSlots[profileId]; + delete state.credentialEpochs[profileId]; + return { id, credential, credentialGeneration, deferred: false }; + } + + #sameCredential(actual: StoredCredential | null, expected: StoredCredential): boolean { + return actual !== null + && actual.version === expected.version + && actual.profileId === expected.profileId + && actual.origin === expected.origin + && actual.publicInstanceIdentity === expected.publicInstanceIdentity + && actual.token === expected.token; + } + + #sameOptionalCredential(actual: StoredCredential | null, expected: StoredCredential | null): boolean { + return expected === null ? actual === null : this.#sameCredential(actual, expected); + } + + #sameProfile(actual: DesktopProfile | null, expected: DesktopProfile | null): boolean { + return expected === null ? actual === null : actual !== null + && actual.id === expected.id + && actual.label === expected.label + && actual.apiBaseUrl === expected.apiBaseUrl + && actual.createdAt === expected.createdAt + && actual.updatedAt === expected.updatedAt; + } + + async writeCredential(credential: StoredCredential): Promise<{ stored: true } | { stored: false; reason: 'encryption-unavailable' }> { + const profileId = credential?.profileId; + assertProfileId(profileId); + if (credential.version !== 2 || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) + || typeof credential.token !== 'string' || credential.token.length > MAX_CREDENTIAL_LENGTH + || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { + throw new Error('Credential must contain 1 to 65536 characters'); + } + if (!this.security().available) return { stored: false, reason: 'encryption-unavailable' }; + return this.#mutate(async () => { + const state = await this.#readState(); + const previousSlot = state.credentialSlots[profileId]; + if (previousSlot) await this.#moveCredentialToPending(state, profileId); + const stagedSlot = await this.#stageCredential(credential); + let committed = false; + try { + state.credentialSlots[profileId] = stagedSlot; + state.credentialEpochs[profileId] = randomBytes(16).toString('base64url'); + const durable = await this.#writeState(state); + committed = true; + if (!durable) return { stored: true }; + } finally { + if (!committed) { + await this.#unlinkSlot(stagedSlot).catch(() => undefined); + } + } + return { stored: true }; + }); + } + + removeCredential(profileId: string): Promise { + assertProfileId(profileId); + return this.#mutate(async () => { + const state = await this.#readState(); + if (!await this.#moveCredentialToPending(state, profileId)) return; + await this.#writeState(state); + }); + } + + removeCredentialIfCurrent( + expected: StoredCredential, + expectedProfileOrigin: string, + isCurrent: () => boolean, + ): Promise { + const profileId = expected?.profileId; + assertProfileId(profileId); + if (normalizeApiBaseUrl(expectedProfileOrigin) !== expectedProfileOrigin) { + throw new Error('Invalid desktop API URL'); + } + return this.#mutate(async () => { + const state = await this.#readState(); + const profile = state.profiles.find(item => item.id === profileId); + const credential = await this.#readCredentialFile(state, profileId); + if (!isCurrent() + || profile?.apiBaseUrl !== expectedProfileOrigin + || !credential + || credential.version !== expected.version + || credential.profileId !== expected.profileId + || credential.origin !== expected.origin + || credential.publicInstanceIdentity !== expected.publicInstanceIdentity + || credential.token !== expected.token) return false; + await this.#moveCredentialToPending(state, profileId); + await this.#writeState(state); + return true; + }); + } + + journalPendingRevocation( + credential: StoredCredential, + credentialGeneration?: string, + ): Promise { + const profileId = credential?.profileId; + assertProfileId(profileId); + if (credential.version !== 2 || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) + || typeof credential.token !== 'string' || credential.token.length > MAX_CREDENTIAL_LENGTH + || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { + throw new Error('Invalid desktop credential revocation material'); + } + if (credentialGeneration !== undefined && !IDENTITY_EPOCH_PATTERN.test(credentialGeneration)) { + throw new Error('Invalid desktop credential generation'); + } + if (!this.security().available) return Promise.resolve({ stored: false, reason: 'encryption-unavailable' }); + return this.#mutate(async () => { + const state = await this.#readState(); + for (const [id, record] of Object.entries(state.pendingRevocations)) { + if (record.profileId !== profileId || record.origin !== credential.origin) continue; + const existing = await this.#readCredentialSlot(record.slot, record.profileId); + if (this.#sameCredential(existing, credential)) { + return { + id, + credential: { ...credential }, + credentialGeneration: record.credentialGeneration, + deferred: record.deferred, + }; + } + } + if (Object.keys(state.pendingRevocations).length >= MAX_PENDING_REVOCATIONS) { + throw new Error('Pending desktop credential revocations must complete before pairing again.'); + } + const slot = await this.#stageCredential(credential); + const id = randomUUID(); + const generation = credentialGeneration ?? randomBytes(16).toString('base64url'); + let committed = false; + try { + state.pendingRevocations[id] = { + version: 1, + profileId, + origin: credential.origin, + slot, + credentialGeneration: generation, + deferred: true, + }; + await this.#writeState(state); + committed = true; + return { id, credential: { ...credential }, credentialGeneration: generation, deferred: true }; + } finally { + if (!committed) await this.#unlinkSlot(slot).catch(() => undefined); + } + }); + } + + releasePendingRevocation(id: string, credentialGeneration: string): Promise { + if (!/^[0-9a-f-]{36}$/i.test(id) || !IDENTITY_EPOCH_PATTERN.test(credentialGeneration)) { + throw new Error('Invalid desktop revocation release'); + } + return this.#mutate(async () => { + const state = await this.#readState(); + const record = state.pendingRevocations[id]; + if (!record || record.credentialGeneration !== credentialGeneration) return false; + if (!record.deferred) return true; + record.deferred = false; + await this.#writeState(state); + return true; + }); + } + + pendingRevocations(includeDeferred = true): Promise { + if (!this.security().available) return Promise.resolve([]); + return this.#mutate(async () => { + const state = await this.#readState(); + const pending: PendingCredentialRevocation[] = []; + for (const [id, record] of Object.entries(state.pendingRevocations)) { + if (record.deferred && !includeDeferred) continue; + const credential = await this.#readCredentialSlot(record.slot, record.profileId); + if (!credential || credential.origin !== record.origin) { + throw new Error('Desktop pending revocation material is unavailable'); + } + pending.push({ + id, + credential, + credentialGeneration: record.credentialGeneration, + deferred: record.deferred, + }); + } + return pending; + }); + } + + completePendingRevocation( + id: string, + expected: StoredCredential, + expectedCredentialGeneration?: string, + ): Promise { + if (!/^[0-9a-f-]{36}$/i.test(id)) throw new Error('Invalid desktop revocation id'); + return this.#mutate(async () => { + const state = await this.#readState(); + const record = state.pendingRevocations[id]; + if (!record || record.profileId !== expected.profileId || record.origin !== expected.origin + || (expectedCredentialGeneration !== undefined + && record.credentialGeneration !== expectedCredentialGeneration)) return false; + const actual = await this.#readCredentialSlot(record.slot, record.profileId); + if (!this.#sameCredential(actual, expected)) return false; + delete state.pendingRevocations[id]; + await this.#writeState(state); + await this.#unlinkSlot(record.slot); + await this.#step('old-credential-removed').catch(() => undefined); + return true; + }); + } + + async #readState(): Promise { + try { + const state = parseState(await readFile(this.#statePath, 'utf8')); + if (state.version !== 3) throw new Error('Desktop profile store recovery was not completed'); + return state; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState(); + throw error; + } + } + + async #writeState( + state: PersistedState, + isCurrent?: () => boolean, + beginPublish?: () => (() => void) | null, + onPublished?: () => void, + ): Promise { + await this.#ensureDirectories(); + const previousGeneration = state.generation; + state.generation = (BigInt(state.generation) + 1n).toString(); + const temporary = `${this.#statePath}.${process.pid}.${randomUUID()}.tmp`; + let releasePublish: (() => void) | undefined; + try { + await this.#io('mirror-write'); + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await this.#step('state-written'); + await this.#io('mirror-flush'); + await this.#fsyncFile(temporary); + await this.#step('state-fsynced'); + if (beginPublish) { + const release = beginPublish(); + if (!release) { + state.generation = previousGeneration; + return null; + } + releasePublish = release; + } else if (isCurrent && !isCurrent()) { + state.generation = previousGeneration; + return null; + } + + // The alternating, self-contained journal is the durable commit point. + // It uses a write-through file handle supported by Windows and embeds only + // already OS-encrypted credential bytes, so recovery does not depend on a + // directory flush, rename visibility, or the new slot directory entry. + await this.#writeJournal(state, onPublished); + + // profiles.json is a convenient atomic mirror. Once the journal is synced, + // failure or rollback of this rename cannot make the prior state authoritative. + try { + await this.#io('mirror-replace'); + await rename(temporary, this.#statePath); + await this.#step('state-renamed').catch(() => undefined); + const directoryDurable = await this.#flushDirectoryIfSupported(this.#directory); + if (directoryDurable) await this.#step('state-directory-fsynced').catch(() => undefined); + } catch { + // The journal is authoritative and #recover repairs this mirror before + // the next read or mutation. + } + await chmod(this.#statePath, 0o600).catch(() => undefined); + return true; + } finally { + releasePublish?.(); + await unlink(temporary).catch(() => undefined); + } + } + + async #writeJournal(state: PersistedState, onPublished?: () => void): Promise { + const referenced = new Set([ + ...Object.values(state.credentialSlots), + ...Object.values(state.pendingRevocations).map(record => record.slot), + ]); + const encryptedSlots: Record = {}; + for (const slot of referenced) { + encryptedSlots[slot] = (await readFile(join(this.#credentialsDirectory, slot))).toString('base64url'); + } + const payload: JournalPayload = { + version: 1, + state: JSON.parse(JSON.stringify(state)) as PersistedState, + encryptedSlots, + }; + const encryptedPayload = this.#encryption.encrypt(JSON.stringify(payload)).toString('base64url'); + const record: JournalRecord = { + version: 2, + generation: String(state.generation), + encryptedPayload, + checksum: journalChecksum(encryptedPayload), + }; + const path = this.#journalPaths[Number(BigInt(state.generation) % BigInt(this.#journalPaths.length))]; + const preparedContents = `P${JSON.stringify(record)}\n`; + if (Buffer.byteLength(preparedContents) > MAX_JOURNAL_BYTES) { + throw new Error('Desktop transaction journal exceeds its bounded size'); + } + const preparationHandle = await open( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, + 0o600, + ); + try { + await this.#io('journal-write'); + await preparationHandle.writeFile(preparedContents, 'utf8'); + await this.#step('journal-written'); + + await this.#io('journal-flush'); + await preparationHandle.sync(); + await this.#step('journal-fsynced'); + } finally { + await preparationHandle.close(); + } + await this.#step('journal-closed'); + + // Verification deliberately reopens the prepared slot through a writable + // handle and does not use an in-memory authentication cache. The same held + // handle remains bound to the verified bytes through C publication. + await this.#io('journal-reopen'); + const verificationHandle = await open(path, constants.O_RDWR); + try { + await this.#step('journal-reopened'); + const verifiedContents = await this.#readHandleContents(verificationHandle); + await this.#io('journal-verify'); + if (verifiedContents !== preparedContents) throw new Error(RECOVERY_ERROR); + const prepared = await this.#authenticateJournal(verifiedContents, false, false); + if (prepared.generation !== BigInt(state.generation) + || JSON.stringify(prepared.state) !== JSON.stringify(state) + || JSON.stringify(prepared.encryptedSlots) !== JSON.stringify(encryptedSlots)) { + throw new Error(RECOVERY_ERROR); + } + await this.#step('journal-prepared-verified'); + + // Refuse a pathname replacement before the authority transition. The + // marker is nevertheless written through the already verified handle, + // so a same-user same-size/generation replacement can never receive C. + await this.#io('journal-commit'); + await this.#assertHandleStillNamesPath(verificationHandle, path, preparedContents.length); + const written = await verificationHandle.write(Buffer.from('C'), 0, 1, 0); + if (written.bytesWritten !== 1) throw new Error('Desktop transaction journal commit failed'); + // From this point B may be observed after a crash even if the explicit + // flush reports failure. Notify the shared gate before anything fallible + // so the fully verified B credential is never revoked as transient. + onPublished?.(); + await this.#step('journal-committed'); + await this.#io('journal-commit-flush'); + await verificationHandle.sync(); + await this.#step('journal-commit-fsynced'); + const committedContents = await this.#readHandleContents(verificationHandle); + if (committedContents !== `C${preparedContents.slice(1)}`) throw new Error(RECOVERY_ERROR); + const committed = await this.#authenticateJournal(committedContents, true, false); + if (committed.generation !== prepared.generation + || JSON.stringify(committed.state) !== JSON.stringify(prepared.state) + || JSON.stringify(committed.encryptedSlots) !== JSON.stringify(prepared.encryptedSlots)) { + throw new Error(RECOVERY_ERROR); + } + await this.#step('journal-commit-verified'); + } finally { + await verificationHandle.close(); + } + await this.#step('journal-commit-closed'); + await chmod(path, 0o600).catch(() => undefined); + } + + async #readHandleContents(handle: FileHandle): Promise { + const info = await handle.stat({ bigint: true }); + if (info.size > BigInt(MAX_JOURNAL_BYTES) || info.size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(RECOVERY_ERROR); + } + const bytes = Buffer.alloc(Number(info.size)); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (result.bytesRead === 0) throw new Error(RECOVERY_ERROR); + offset += result.bytesRead; + } + return bytes.toString('utf8'); + } + + async #assertHandleStillNamesPath(handle: FileHandle, path: string, expectedSize: number): Promise { + const [held, named] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(path, { bigint: true }), + ]); + if (named.isSymbolicLink() || !named.isFile() + || held.dev !== named.dev || held.ino !== named.ino + || held.size !== BigInt(expectedSize) || named.size !== held.size + || held.mode !== named.mode || held.uid !== named.uid || held.gid !== named.gid + || held.nlink !== named.nlink || held.nlink !== 1n) { + throw new Error(RECOVERY_ERROR); + } + } + + async #stageCredential(credential: StoredCredential): Promise { + await this.#ensureDirectories(); + const slot = `${credential.profileId}.${randomUUID()}.bin`; + const target = join(this.#credentialsDirectory, slot); + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + const encrypted = this.#encryption.encrypt(JSON.stringify(credential)); + await this.#step('credential-encrypted'); + await this.#io('credential-write'); + await writeFile(temporary, encrypted, { mode: 0o600 }); + await this.#step('credential-written'); + await this.#io('credential-flush'); + await this.#fsyncFile(temporary); + await this.#step('credential-fsynced'); + await this.#io('credential-replace'); + await rename(temporary, target); + await this.#step('credential-renamed'); + const directoryDurable = await this.#flushDirectoryIfSupported(this.#credentialsDirectory); + if (directoryDurable) await this.#step('credential-directory-fsynced'); + await chmod(target, 0o600).catch(() => undefined); + return slot; + } finally { + await unlink(temporary).catch(() => undefined); + } + } + + async #authenticateJournal( + contents: string, + committedOnly: boolean, + useCache = true, + ): Promise { + const marker = contents[0]; + if ((committedOnly && marker !== 'C') || (!committedOnly && marker !== 'P' && marker !== 'C')) { + throw new Error('Desktop transaction journal is incomplete'); + } + const envelope = parseJournalEnvelope(contents.slice(1)); + const cached = useCache ? this.#authenticatedJournalCache.get(envelope.checksum) : undefined; + if (cached) { + if (cached.generation !== BigInt(envelope.generation)) throw new Error(RECOVERY_ERROR); + return { + generation: cached.generation, + state: JSON.parse(JSON.stringify(cached.state)) as PersistedState, + encryptedSlots: { ...cached.encryptedSlots }, + }; + } + let plaintext: string; + try { + plaintext = this.#encryption.decrypt(Buffer.from(envelope.encryptedPayload, 'base64url')); + } catch { + throw new Error('Desktop transaction journal authentication failed'); + } + let raw: unknown; + try { + raw = JSON.parse(plaintext) as unknown; + } catch { + throw new Error('Desktop transaction journal authentication failed'); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(RECOVERY_ERROR); + const candidate = raw as Record; + const state = parseState(JSON.stringify(candidate.state)); + if (candidate.version !== 1 || state.version !== 3 + || typeof candidate.encryptedSlots !== 'object' || candidate.encryptedSlots === null + || Array.isArray(candidate.encryptedSlots) + || envelope.generation !== String(state.generation)) throw new Error(RECOVERY_ERROR); + const encryptedSlots = candidate.encryptedSlots as Record; + const referenced = new Set([ + ...Object.values(state.credentialSlots), + ...Object.values(state.pendingRevocations).map(record => record.slot), + ]); + if (Object.keys(encryptedSlots).length !== referenced.size + || Object.keys(encryptedSlots).some(slot => !referenced.has(slot))) throw new Error(RECOVERY_ERROR); + + const authenticatedSlots: Record = {}; + for (const slot of referenced) { + const encoded = encryptedSlots[slot]; + if (typeof encoded !== 'string' || encoded.length === 0 + || encoded.length > Math.ceil(MAX_CREDENTIAL_LENGTH * 2) + || !/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(RECOVERY_ERROR); + const bytes = Buffer.from(encoded, 'base64url'); + if (bytes.toString('base64url') !== encoded) throw new Error(RECOVERY_ERROR); + let credential: (StoredCredential & Record) | Record | null = null; + try { + credential = JSON.parse(this.#encryption.decrypt(bytes)) as Record; + } catch { + if (!this.#wasPreviouslyAuthenticatedSlot(state, slot, encoded)) throw new Error(RECOVERY_ERROR); + } + const profileId = SLOT_PATTERN.exec(slot)?.[1]; + const isLegacyCredential = credential?.version === 1 + && credential.profileId === profileId + && typeof credential.origin === 'string' + && normalizeApiBaseUrl(credential.origin) === credential.origin + && typeof credential.token === 'string' + && /^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token); + if (credential && !isLegacyCredential && (credential.version !== 2 || credential.profileId !== profileId + || typeof credential.origin !== 'string' + || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) + || typeof credential.token !== 'string' + || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token))) throw new Error(RECOVERY_ERROR); + const pending = Object.values(state.pendingRevocations).find(record => record.slot === slot); + if (credential && !isLegacyCredential && pending + && (pending.profileId !== credential.profileId || pending.origin !== credential.origin)) { + throw new Error(RECOVERY_ERROR); + } + authenticatedSlots[slot] = encoded; + } + const authenticated = { generation: BigInt(envelope.generation), state, encryptedSlots: authenticatedSlots }; + this.#authenticatedJournalCache.set(envelope.checksum, { + generation: authenticated.generation, + state: JSON.parse(JSON.stringify(state)) as PersistedState, + encryptedSlots: { ...authenticatedSlots }, + }); + return authenticated; + } + + #wasPreviouslyAuthenticatedSlot(state: PersistedState, slot: string, encoded: string): boolean { + const currentPending = Object.values(state.pendingRevocations).find(record => record.slot === slot); + const currentProfileId = SLOT_PATTERN.exec(slot)?.[1]; + for (const cached of this.#authenticatedJournalCache.values()) { + if (cached.encryptedSlots[slot] !== encoded) continue; + const priorPending = Object.values(cached.state.pendingRevocations).find(record => record.slot === slot); + if (currentPending && priorPending + && currentPending.profileId === priorPending.profileId + && currentPending.origin === priorPending.origin + && currentPending.credentialGeneration === priorPending.credentialGeneration) return true; + if (currentPending && currentProfileId + && cached.state.credentialSlots[currentProfileId] === slot + && cached.state.credentialEpochs[currentProfileId] === currentPending.credentialGeneration + && cached.state.profiles.find(profile => profile.id === currentProfileId)?.apiBaseUrl + === currentPending.origin) return true; + if (!currentPending && currentProfileId + && state.credentialSlots[currentProfileId] === slot + && cached.state.credentialSlots[currentProfileId] === slot + && state.credentialEpochs[currentProfileId] === cached.state.credentialEpochs[currentProfileId]) return true; + } + return false; + } + + async #recover(): Promise { + await this.#ensureDirectories(); + const journalRecords: AuthenticatedJournal[] = []; + const legacyJournalRecords: LegacyJournalRecord[] = []; + const preparedJournalRecords: AuthenticatedJournal[] = []; + let invalidCommittedJournal = false; + let invalidPreparedJournal = false; + let sawPreparedJournal = false; + let sawNonPreparedJournal = false; + for (const path of this.#journalPaths) { + try { + const info = await stat(path); + if (info.size > MAX_JOURNAL_BYTES) throw new Error('Desktop transaction journal is invalid'); + const contents = await readFile(path, 'utf8'); + if (contents.startsWith('C') || contents.startsWith('P')) { + if (contents.startsWith('C')) { + sawNonPreparedJournal = true; + try { + journalRecords.push(await this.#authenticateJournal(contents, true)); + } catch { + invalidCommittedJournal = true; + } + } else { + sawPreparedJournal = true; + try { + preparedJournalRecords.push(await this.#authenticateJournal(contents, false, false)); + } catch { + invalidPreparedJournal = true; + } + } + // A prepared record is deliberately not authoritative. The other + // alternating slot (or the legacy mirror before the first commit) + // remains the complete recovery point. + } else { + sawNonPreparedJournal = true; + legacyJournalRecords.push(parseLegacyJournal(contents)); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') continue; + if (error instanceof SyntaxError + || (error instanceof Error && error.message.startsWith('Desktop transaction journal'))) { + sawNonPreparedJournal = true; + continue; + } + throw new Error(RECOVERY_ERROR); + } + } + journalRecords.sort((left, right) => left.generation < right.generation ? -1 : left.generation > right.generation ? 1 : 0); + const authoritativeJournal = journalRecords.at(-1); + + let parsed: PersistedState | VersionTwoPersistedState | LegacyPersistedState | null = null; + let mirrorMissing = false; + try { + parsed = parseState(await readFile(this.#statePath, 'utf8')); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + mirrorMissing = code === 'ENOENT'; + if (code && code !== 'ENOENT') throw new Error(RECOVERY_ERROR); + if (!(error instanceof SyntaxError) + && !(error instanceof Error && error.message.startsWith('Desktop ')) + && !mirrorMissing) throw new Error(RECOVERY_ERROR); + } + + let state: PersistedState; + if (authoritativeJournal) { + state = authoritativeJournal.state; + for (const [slot, encoded] of Object.entries(authoritativeJournal.encryptedSlots)) { + const expectedBytes = Buffer.from(encoded, 'base64url'); + try { + const actualBytes = await readFile(join(this.#credentialsDirectory, slot)); + if (actualBytes.equals(expectedBytes)) continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new Error(RECOVERY_ERROR); + } + try { + await this.#writeThroughFile(join(this.#credentialsDirectory, slot), expectedBytes); + } catch { + throw new Error(RECOVERY_ERROR); + } + } + const mirrorMatches = parsed?.version === 3 + && JSON.stringify(parsed) === JSON.stringify(state); + if (!mirrorMatches) { + try { + await this.#writeStateMirror(state); + } catch { + throw new Error(RECOVERY_ERROR); + } + } + } else { + if (!parsed) { + const preparedIsOnlyCanonicalEmptyBootstrap = sawPreparedJournal + && !invalidPreparedJournal + && preparedJournalRecords.length > 0 + && preparedJournalRecords.every(record => record.generation === 1n + && JSON.stringify(record.state) === JSON.stringify({ ...emptyState(), generation: '1' }) + && Object.keys(record.encryptedSlots).length === 0); + if (mirrorMissing && !invalidCommittedJournal && legacyJournalRecords.length === 0 + && (!sawPreparedJournal || preparedIsOnlyCanonicalEmptyBootstrap)) { + // An authenticated generation-1 empty P is the one narrow prepared + // bootstrap exception. It is never made authoritative: recovery + // reconstructs empty A and retries publication. Any A-to-B P remains + // ignored and cannot manufacture a missing mirror authority. + parsed = { version: 1, activeProfileId: null, profiles: [] }; + } else { + throw new Error(RECOVERY_ERROR); + } + } + if (invalidCommittedJournal || (sawNonPreparedJournal && legacyJournalRecords.length === 0)) { + throw new Error(RECOVERY_ERROR); + } + if (legacyJournalRecords.length > 0) { + legacyJournalRecords.sort((left, right) => { + const leftGeneration = BigInt(left.state.generation); + const rightGeneration = BigInt(right.state.generation); + return leftGeneration < rightGeneration ? -1 : leftGeneration > rightGeneration ? 1 : 0; + }); + const legacy = legacyJournalRecords.at(-1)!; + if (parsed.version !== 3 || JSON.stringify(parsed) !== JSON.stringify(legacy.state)) { + throw new Error(RECOVERY_ERROR); + } + state = legacy.state; + for (const [slot, encoded] of Object.entries(legacy.encryptedSlots)) { + await this.#writeThroughFile(join(this.#credentialsDirectory, slot), Buffer.from(encoded, 'base64url')); + } + await this.#writeState(state); + } else if (parsed.version === 1) { + state = { + version: 3, + generation: '0', + activeProfileId: parsed.activeProfileId, + profiles: parsed.profiles.map(profile => ({ ...profile })), + credentialSlots: {}, + credentialEpochs: {}, + pendingRevocations: {}, + }; + // Pre-identity credentials cannot safely be presented to any endpoint. + // Keep profiles, but deliberately migrate without their bearer slots. + state.activeProfileId = null; + await this.#writeState(state); + } else if (parsed.version === 2) { + state = { + version: 3, + generation: '0', + activeProfileId: parsed.activeProfileId, + profiles: parsed.profiles.map(profile => ({ ...profile })), + credentialSlots: {}, + credentialEpochs: {}, + pendingRevocations: {}, + }; + if (Object.keys(parsed.credentialSlots).length > 0) state.activeProfileId = null; + await this.#writeState(state); + } else { + state = parsed; + // A v3 file predating journal creation is migrated into the durable + // write-through protocol before any unreferenced slot cleanup. + await this.#writeState(state); + } + } + + // Version-3 stores created before public identity binding authenticate at + // the journal layer, but their credential payloads are intentionally not + // usable. Remove those references locally before any caller can read a + // bearer; re-pairing creates a fresh identity-bound generation. + let removedUnboundCredential = false; + for (const [profileId, slot] of Object.entries(state.credentialSlots)) { + let credential: StoredCredential | null; + try { credential = await this.#readCredentialSlot(slot, profileId); } + catch { continue; } // Preserve material while the OS credential backend is temporarily unavailable. + if (credential) continue; + delete state.credentialSlots[profileId]; + delete state.credentialEpochs[profileId]; + if (state.activeProfileId === profileId) state.activeProfileId = null; + removedUnboundCredential = true; + } + for (const [id, pending] of Object.entries(state.pendingRevocations)) { + let credential: StoredCredential | null; + try { credential = await this.#readCredentialSlot(pending.slot, pending.profileId); } + catch { continue; } + if (credential) continue; + delete state.pendingRevocations[id]; + removedUnboundCredential = true; + } + if (removedUnboundCredential) await this.#writeState(state); + + const referenced = new Set([ + ...Object.values(state.credentialSlots), + ...Object.values(state.pendingRevocations).map(record => record.slot), + ]); + const entries = await readdir(this.#credentialsDirectory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.tmp')) { + await unlink(join(this.#credentialsDirectory, entry.name)); + } + } + const stateEntries = await readdir(this.#directory, { withFileTypes: true }); + for (const entry of stateEntries) { + if (entry.isFile() && /^profiles\.json\..+\.tmp$/.test(entry.name)) { + await unlink(join(this.#directory, entry.name)); + } + } + await this.#flushDirectoryIfSupported(this.#credentialsDirectory); + await this.#flushDirectoryIfSupported(this.#directory); + for (const slot of referenced) { + try { + await readFile(join(this.#credentialsDirectory, slot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Desktop credential state is incomplete'); + } + throw error; + } + } + for (const entry of entries) { + if (!entry.isFile() || referenced.has(entry.name)) continue; + if (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}(?:\.[0-9a-f-]{36})?\.bin$/i.test(entry.name)) { + await unlink(join(this.#credentialsDirectory, entry.name)); + } + } + await this.#flushDirectoryIfSupported(this.#credentialsDirectory); + await this.#flushDirectoryIfSupported(this.#directory); + } + + async #writeStateMirror(state: PersistedState): Promise { + const temporary = `${this.#statePath}.${process.pid}.${randomUUID()}.recovery.tmp`; + try { + await this.#io('mirror-write'); + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await this.#io('mirror-flush'); + await this.#fsyncFile(temporary); + await this.#io('mirror-replace'); + await rename(temporary, this.#statePath); + await this.#flushDirectoryIfSupported(this.#directory); + } finally { + await unlink(temporary).catch(() => undefined); + } + } + + async #writeThroughFile(path: string, bytes: Buffer): Promise { + const handle = await open( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, + 0o600, + ); + try { + await this.#io('journal-write'); + await handle.writeFile(bytes); + await this.#io('journal-flush'); + await handle.sync(); + } finally { + await handle.close(); + } + await this.#io('journal-verify'); + if (!(await readFile(path)).equals(bytes)) throw new Error(RECOVERY_ERROR); + } + + async #fsyncFile(path: string): Promise { + await flushFileData(path); + } + + async #fsyncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { await handle.sync(); } finally { await handle.close(); } + } + + async #flushDirectoryIfSupported(path: string): Promise { + await this.#io('metadata-flush'); + // Node does not expose a supported Windows directory FlushFileBuffers + // handle. No authority transition depends on it: the committed journal is + // self-contained and can recreate both renamed credential entries and the + // profiles.json mirror. POSIX platforms still require and perform fsync. + if (process.platform === 'win32') return false; + await this.#fsyncDirectory(path); + return true; + } + + async #unlinkSlot(slot: string): Promise { + await unlink(join(this.#credentialsDirectory, slot)).catch(error => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + }); + } + + #step(step: ProfileStoreDurabilityStep): Promise { + return Promise.resolve(this.#options.afterDurabilityStep?.(step)); + } + + #io(operation: ProfileStoreIOOperation): Promise { + return Promise.resolve(this.#options.beforeIO?.(operation)); + } + + async #ensureDirectories(): Promise { + await mkdir(this.#credentialsDirectory, { recursive: true, mode: 0o700 }); + await chmod(this.#directory, 0o700).catch(() => undefined); + await chmod(this.#credentialsDirectory, 0o700).catch(() => undefined); + } + + #mutate(operation: () => Promise): Promise { + if (this.#closed) return Promise.reject(new Error('Desktop profile store is closed')); + const recoveredOperation = async () => { + await this.#recover(); + return operation(); + }; + const result = this.#mutation.then(recoveredOperation, recoveredOperation); + this.#mutation = result.then(() => undefined, () => undefined); + return result; + } + +} diff --git a/apps/desktop/src/release-config.test.ts b/apps/desktop/src/release-config.test.ts new file mode 100644 index 000000000..e7e5676df --- /dev/null +++ b/apps/desktop/src/release-config.test.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { describe, test } from 'node:test'; +import { + readCompleteEnvironmentGroup, + parseWindowsSignerPins, + requireProductionReleaseConfiguration, + resolveDesktopVersion, + resolveTrustedUpdateBuildConfig, + WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR, +} from './release-config'; + +const publicKey = generateKeyPairSync('ed25519').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); +const certificatePin = `certificate-sha256:${'1'.repeat(64)}`; +const spkiPin = `spki-sha256:${'2'.repeat(64)}`; + +interface LinuxMaker { + name: 'deb' | 'rpm'; + config: { options?: { bin?: string } }; + prepareConfig: (targetArch: 'x64') => Promise; +} + +const isLinuxMaker = (maker: unknown): maker is LinuxMaker => { + if (typeof maker !== 'object' || maker === null || !('name' in maker)) return false; + return maker.name === 'deb' || maker.name === 'rpm'; +}; + +describe('desktop release configuration', () => { + test('keeps Linux maker executables aligned with the packaged executable', async () => { + const previousDeb = process.env.PROPR_DESKTOP_ENABLE_DEB; + const previousRpm = process.env.PROPR_DESKTOP_ENABLE_RPM; + process.env.PROPR_DESKTOP_ENABLE_DEB = '1'; + process.env.PROPR_DESKTOP_ENABLE_RPM = '1'; + try { + const { default: forgeConfig } = await import('../forge.config'); + const executableName = forgeConfig.packagerConfig?.executableName; + assert.equal(executableName, 'propr-desktop'); + + const linuxMakers = forgeConfig.makers?.filter(isLinuxMaker) ?? []; + assert.deepEqual(linuxMakers.map(maker => maker.name).sort(), ['deb', 'rpm']); + for (const maker of linuxMakers) { + await maker.prepareConfig('x64'); + assert.equal(maker.config.options?.bin, executableName); + assert.notEqual(maker.config.options?.bin, '@propr/desktop'); + } + } finally { + if (previousDeb === undefined) delete process.env.PROPR_DESKTOP_ENABLE_DEB; + else process.env.PROPR_DESKTOP_ENABLE_DEB = previousDeb; + if (previousRpm === undefined) delete process.env.PROPR_DESKTOP_ENABLE_RPM; + else process.env.PROPR_DESKTOP_ENABLE_RPM = previousRpm; + } + }); + + test('propagates an explicit independent desktop version', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + assert.equal(resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '2.3.4' }, platform), '2.3.4'); + } + }); + + test('accepts the exact MSI ProductVersion numeric boundary for Windows releases', () => { + assert.equal( + resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '255.255.65535' }, 'win32'), + '255.255.65535', + ); + }); + + test('preserves the stable SemVer diagnostic for malformed Windows release versions', () => { + for (const version of [ + '01.2.3', + '1.02.3', + '1.2.03', + 'v1.2.3', + '+1.2.3', + '-1.2.3', + '1.-2.3', + '1.2.+3', + '1.2.3.4', + '1.2.3.', + '1.2', + '1.2.3-rc.1', + '1.2.3+build.1', + '255.255.65535-rc.1', + ]) { + assert.throws( + () => resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: version }, 'win32'), + /canonical stable semver/, + ); + } + }); + + test('rejects canonical stable Windows versions outside MSI bounds with one fixed actionable diagnostic', () => { + for (const version of [ + '256.0.0', + '0.256.0', + '0.0.65536', + `${'9'.repeat(10_000)}.0.0`, + ]) { + assert.throws( + () => resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: version }, 'win32'), + { message: WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR }, + ); + } + }); + + test('preserves stable SemVer policy outside the Windows MSI path', () => { + for (const platform of ['darwin', 'linux'] as const) { + assert.equal( + resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '256.256.65536' }, platform), + '256.256.65536', + ); + assert.throws( + () => resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '256.256.65536-rc.1' }, platform), + /canonical stable semver/, + ); + } + }); + + test('keeps updates disabled unless they are explicitly enabled', () => { + assert.deepEqual(resolveTrustedUpdateBuildConfig({}), { + enabled: false, + manifestUrl: '', + publicKey: '', + signingIdentity: '', + windowsSignerPins: [], + }); + }); + + test('requires a signed build and a complete trusted update configuration', () => { + const base = { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'Example Publisher', + }; + assert.throws(() => resolveTrustedUpdateBuildConfig(base, 'darwin'), /CODE_SIGNED/); + assert.deepEqual(resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1' }, 'darwin'), { + enabled: true, + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + publicKey, + signingIdentity: 'Example Publisher', + windowsSignerPins: [], + }); + assert.throws( + () => resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1', PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'http://example.test/update.json' }, 'darwin'), + /HTTPS/, + ); + assert.throws( + () => resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1', PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://example.test/update.json?channel=stable' }, 'darwin'), + /query/, + ); + }); + + test('parses canonical Windows certificate or SPKI SHA-256 pin allowlists for artifact signing', () => { + assert.deepEqual(parseWindowsSignerPins(`${certificatePin},${spkiPin}`), [certificatePin, spkiPin]); + for (const value of [ + undefined, + '', + `certificate-sha256:${'A'.repeat(64)}`, + `certificate-sha256:${'1'.repeat(63)}`, + `${spkiPin},${certificatePin}`, + `${certificatePin},${certificatePin}`, + ` ${certificatePin}`, + `sha256:${'1'.repeat(64)}`, + ]) assert.throws(() => parseWindowsSignerPins(value), /required|sorted, unique/); + + const base = { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'CN=Example Publisher', + }; + assert.deepEqual( + resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_WINDOWS_SIGNER_PINS: certificatePin }, 'win32'), + { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }, + ); + }); + + test('fails closed to unsupported Windows updates even when every update variable is configured or malformed', () => { + for (const env of [ + { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'CN=Example Publisher', + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: certificatePin, + }, + { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'http://unsafe.example.test/update.json?configured=1', + }, + ]) { + assert.deepEqual(resolveTrustedUpdateBuildConfig(env, 'win32'), { + enabled: false, + manifestUrl: '', + publicKey: '', + signingIdentity: '', + windowsSignerPins: [], + }); + } + }); + + test('preserves opaque signing credentials while normalizing non-secret members', () => { + const password = ' certificate password '; + assert.deepEqual( + readCompleteEnvironmentGroup( + { + CERT: ' /tmp/cert.pfx ', + PASSWORD: password, + KEY_ID: ' key-id ', + }, + ['CERT', 'PASSWORD', 'KEY_ID'], + 'Windows signing', + { opaqueNames: ['PASSWORD'] }, + ), + { + CERT: '/tmp/cert.pfx', + PASSWORD: password, + KEY_ID: 'key-id', + }, + ); + }); + + test('rejects whitespace-only and partially configured signing groups with fixed diagnostics', () => { + assert.equal(readCompleteEnvironmentGroup({}, ['CERT', 'PASSWORD'], 'Windows signing'), undefined); + assert.throws( + () => readCompleteEnvironmentGroup({ CERT: '/tmp/cert.pfx' }, ['CERT', 'PASSWORD'], 'Windows signing'), + { message: 'Windows signing configuration is incomplete; missing PASSWORD' }, + ); + assert.throws( + () => readCompleteEnvironmentGroup( + { CERT: ' /tmp/cert.pfx ', PASSWORD: ' \t ' }, + ['CERT', 'PASSWORD'], + 'Windows signing', + { opaqueNames: ['PASSWORD'] }, + ), + { message: 'Windows signing configuration is incomplete; missing PASSWORD' }, + ); + assert.throws( + () => readCompleteEnvironmentGroup( + { CERT: ' ', PASSWORD: ' credential ', KEY_ID: '' }, + ['CERT', 'PASSWORD', 'KEY_ID'], + 'Windows signing', + { opaqueNames: ['PASSWORD'] }, + ), + { message: 'Windows signing configuration is incomplete; missing CERT, KEY_ID' }, + ); + }); + + test('fails closed when a production signing or notarization condition is absent', () => { + const enabledUpdates = resolveTrustedUpdateBuildConfig({ + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'TEAM123456', + }, 'darwin'); + const disabledWindowsUpdates = resolveTrustedUpdateBuildConfig({ + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'CN=Example Publisher', + }, 'win32'); + const group = { configured: 'yes' }; + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: enabledUpdates, macSigning: group }), + /notarization/, + ); + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }, macSigning: group, macNotarization: group }), + /signed updates/, + ); + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'win32', updateConfig: disabledWindowsUpdates }), + /Authenticode/, + ); + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'win32', updateConfig: disabledWindowsUpdates, windowsSigning: group }), + /artifact signer pin/, + ); + assert.doesNotThrow( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: enabledUpdates, macSigning: group, macNotarization: group }), + ); + assert.doesNotThrow( + () => requireProductionReleaseConfiguration({ + platform: 'win32', + updateConfig: disabledWindowsUpdates, + windowsSigning: group, + windowsSignerPins: [certificatePin], + }), + ); + }); +}); diff --git a/apps/desktop/src/release-config.ts b/apps/desktop/src/release-config.ts new file mode 100644 index 000000000..c74ef0da2 --- /dev/null +++ b/apps/desktop/src/release-config.ts @@ -0,0 +1,161 @@ +import { createPublicKey } from 'node:crypto'; +import { + assertWindowsInstallerProductVersion, + WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR, +} from '../scripts/windows-installer-version.mjs'; + +export { WINDOWS_INSTALLER_PRODUCT_VERSION_ERROR }; + +export type Environment = Readonly>; + +export interface TrustedUpdateBuildConfig { + enabled: boolean; + manifestUrl: string; + publicKey: string; + signingIdentity: string; + windowsSignerPins: readonly string[]; +} + +const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const WINDOWS_SIGNER_PIN_PATTERN = /^(?:certificate|spki)-sha256:[a-f0-9]{64}$/; +const MAX_WINDOWS_SIGNER_PINS = 16; + +export const parseWindowsSignerPins = ( + value: string | undefined, + label = 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', +): readonly string[] => { + if (!value) throw new Error(`${label} is required`); + const pins = value.split(','); + if (pins.length > MAX_WINDOWS_SIGNER_PINS + || pins.some(pin => !WINDOWS_SIGNER_PIN_PATTERN.test(pin)) + || new Set(pins).size !== pins.length + || pins.join(',') !== [...pins].sort().join(',')) { + throw new Error( + `${label} must be a sorted, unique comma-separated allowlist of canonical certificate-sha256 or spki-sha256 fingerprints`, + ); + } + return pins; +}; + +export const resolveDesktopVersion = ( + packageVersion: string, + env: Environment = process.env, + platform: NodeJS.Platform = process.platform, +): string => { + const version = env.PROPR_DESKTOP_VERSION?.trim() || packageVersion; + if (!RELEASE_VERSION_PATTERN.test(version)) { + throw new Error(`ProPR Desktop version must be canonical stable semver (received ${JSON.stringify(version)})`); + } + if (platform === 'win32') assertWindowsInstallerProductVersion(version); + return version; +}; + +const validateHttpsUrl = (value: string, label: string): string => { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an absolute HTTPS URL`); + } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search) { + throw new Error(`${label} must be an HTTPS URL without credentials, a fragment, or a query`); + } + return url.toString(); +}; + +const validateEd25519PublicKey = (value: string): string => { + try { + const key = createPublicKey({ key: Buffer.from(value, 'base64'), format: 'der', type: 'spki' }); + if (key.asymmetricKeyType !== 'ed25519') throw new Error('wrong key type'); + } catch { + throw new Error('PROPR_DESKTOP_UPDATE_PUBLIC_KEY must be a base64-encoded Ed25519 SPKI DER public key'); + } + return value; +}; + +export const resolveTrustedUpdateBuildConfig = ( + env: Environment = process.env, + platform: NodeJS.Platform = process.platform, +): TrustedUpdateBuildConfig => { + // Windows self-update is deliberately outside the first-release MVP. This + // check precedes every update environment validation so even a fully (or + // partially) configured Windows build embeds no update endpoint or key. + if (platform === 'win32') { + return { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }; + } + if (env.PROPR_DESKTOP_ENABLE_UPDATES !== '1') { + return { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }; + } + if (env.PROPR_DESKTOP_CODE_SIGNED !== '1') { + throw new Error('Signed updates require PROPR_DESKTOP_CODE_SIGNED=1 from the trusted signing job'); + } + + const manifestUrl = env.PROPR_DESKTOP_UPDATE_MANIFEST_URL?.trim(); + const publicKey = env.PROPR_DESKTOP_UPDATE_PUBLIC_KEY?.trim(); + const signingIdentity = env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY?.trim(); + if (!manifestUrl || !publicKey || !signingIdentity) { + throw new Error( + 'Signed updates require PROPR_DESKTOP_UPDATE_MANIFEST_URL, PROPR_DESKTOP_UPDATE_PUBLIC_KEY, and PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY', + ); + } + + return { + enabled: true, + manifestUrl: validateHttpsUrl(manifestUrl, 'PROPR_DESKTOP_UPDATE_MANIFEST_URL'), + publicKey: validateEd25519PublicKey(publicKey), + signingIdentity, + windowsSignerPins: [], + }; +}; + +interface CompleteEnvironmentGroup { + [name: string]: string; +} + +interface CompleteEnvironmentGroupOptions { + opaqueNames?: readonly Name[]; +} + +export const readCompleteEnvironmentGroup = ( + env: Environment, + names: readonly Name[], + label: string, + { opaqueNames = [] }: CompleteEnvironmentGroupOptions = {}, +): Record | undefined => { + const present = names.filter(name => Boolean(env[name]?.trim())); + if (present.length === 0) return undefined; + if (present.length !== names.length) { + const missing = names.filter(name => !env[name]?.trim()); + throw new Error(`${label} configuration is incomplete; missing ${missing.join(', ')}`); + } + const opaque = new Set(opaqueNames); + return Object.fromEntries( + names.map(name => [name, opaque.has(name) ? env[name]! : env[name]!.trim()]), + ) as Record; +}; + +export const requireProductionReleaseConfiguration = ({ + platform, + updateConfig, + macSigning, + macNotarization, + windowsSigning, + windowsSignerPins = [], +}: { + platform: NodeJS.Platform; + updateConfig: TrustedUpdateBuildConfig; + macSigning?: CompleteEnvironmentGroup; + macNotarization?: CompleteEnvironmentGroup; + windowsSigning?: CompleteEnvironmentGroup; + windowsSignerPins?: readonly string[]; +}): void => { + if (platform === 'darwin' && (!macSigning || !macNotarization || !updateConfig.enabled)) { + throw new Error('Production macOS releases require signing, notarization, and signed updates'); + } + if (platform === 'win32' && !windowsSigning) { + throw new Error('Production Windows releases require Authenticode signing; Windows self-update is unsupported'); + } + if (platform === 'win32' && windowsSignerPins.length === 0) { + throw new Error('Production Windows releases require an Authenticode certificate or SPKI SHA-256 artifact signer pin'); + } +}; diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts new file mode 100644 index 000000000..bf0a28a03 --- /dev/null +++ b/apps/desktop/src/release-workflow.test.ts @@ -0,0 +1,1791 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { PACKAGED_SMOKE_EVIDENCE_EVENTS } from './smoke-test-evidence'; + +const normalizeWorkflowText = (contents: string): string => contents.replace(/\r\n?/g, '\n'); +const platformArchitecturePattern = /platform: (linux|darwin|win32)\n\s+arch: (x64|arm64)/g; +const workflow = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../../../.github/workflows/desktop-release-guard.yml', import.meta.url)), + 'utf8', +)); +const releaseArchitecture = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-architecture.mjs', import.meta.url)), + 'utf8', +)); +const releaseArtifacts = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-artifacts.mjs', import.meta.url)), + 'utf8', +)); +const makeDmg = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/make-dmg.mjs', import.meta.url)), + 'utf8', +)); +const verifyDarwinImage = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/verify-darwin-image.mjs', import.meta.url)), + 'utf8', +)); +const releasePreflight = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-preflight.mjs', import.meta.url)), + 'utf8', +)); +const forgeConfig = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../forge.config.ts', import.meta.url)), + 'utf8', +)); +const windowsMachineInstaller = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/build-windows-machine-installer.mjs', import.meta.url)), + 'utf8', +)); +const installedWindowsAppTest = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisor = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-harness.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppWorkflowCleanupWrapper = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppWorkflowCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup-body.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisorFixture = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor-fixture.ps1', import.meta.url)), + 'utf8', +)); + +const preflightAppTokenPermissions = (preflight: string): string[] => ( + [...preflight.matchAll(/^\s+permission-([a-z-]+): (read|write)$/gm)] + .map(match => `${match[1]}:${match[2]}`) +); + +const environmentApiPermissionFixtures = [ + { + endpoint: 'GET /repos/{owner}/{repo}/environments/{environment_name}', + sources: [/request\(`\/environments\/\$\{environmentName\}`\)/], + permission: 'environments:read', + }, + { + endpoint: 'GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies', + sources: [ + /`\/environments\/\$\{environmentName\}\/deployment-branch-policies`/, + /paginatedDeploymentPolicies\(request, environmentName\)/, + ], + permission: 'environments:read', + }, +] as const; + +const job = (name: string, next?: string): string => { + const start = workflow.indexOf(`\n ${name}:`); + const end = next ? workflow.indexOf(`\n ${next}:`, start + 1) : workflow.length; + assert.notEqual(start, -1, `missing ${name} job`); + assert.notEqual(end, -1, `missing ${next} job`); + return workflow.slice(start, end); +}; + +describe('desktop trusted release workflow', () => { + test('keeps pull-request packaging unsigned and completely secretless', () => { + const validation = `${job('validation-version', 'package')}\n${job('package', 'finalize')}\n${job('finalize', 'preflight')}`; + assert.match(validation, /github\.event_name == 'pull_request'/); + assert.match(validation, /Prove pull-request validation is secretless/); + assert.ok(!validation.includes('secrets.'), 'PR jobs must not reference any GitHub secret'); + assert.ok(!validation.includes('PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.ok(!validation.includes('environment:\n')); + assert.ok(!validation.includes('PROPR_DESKTOP_ENABLE_UPDATES=1')); + }); + + test('allows production only from a new protected-main desktop tag after protected read-only preflight', () => { + const preflight = job('preflight', 'release-package'); + const production = job('release-package', 'release-finalize'); + assert.ok(!workflow.includes('workflow_dispatch:')); + assert.match(preflight, /github\.event_name == 'push'/); + assert.match(preflight, /release-preflight\.mjs/); + assert.match(preflight, /ref: \$\{\{ github\.sha \}\}/); + assert.match(preflight, /environment:\s+name: desktop-release-preflight/); + assert.match(preflight, /actions\/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1/); + assert.match(preflight, /app-id: \$\{\{ vars\.PROPR_DESKTOP_PREFLIGHT_APP_ID \}\}/); + assert.match(preflight, /private-key: \$\{\{ secrets\.PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY \}\}/); + assert.match(preflight, /permission-administration: read/); + assert.match(preflight, /permission-contents: read/); + assert.match(preflight, /permission-environments: read/); + assert.deepEqual( + preflightAppTokenPermissions(preflight), + ['administration:read', 'contents:read', 'environments:read'], + ); + assert.match(preflight, /GITHUB_TOKEN: \$\{\{ steps\.preflight-app-token\.outputs\.token \}\}/); + assert.equal(workflow.match(/steps\.preflight-app-token\.outputs\.token/g)?.length, 1); + assert.equal(preflight.match(/secrets\./g)?.length, 1); + assert.ok(!preflight.includes('PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.ok(!preflight.includes('PROPR_DESKTOP_MAC_CERTIFICATE')); + assert.ok(!preflight.includes('PROPR_DESKTOP_WINDOWS_CERTIFICATE')); + assert.ok(!preflight.includes('permission-administration: write')); + assert.ok(!preflight.includes('permission-contents: write')); + assert.ok(!preflight.includes('permission-environments: write')); + assert.match(production, /needs: preflight/); + assert.match(production, /environment:\s+name: desktop-release/); + assert.match(production, /ref: \$\{\{ needs\.preflight\.outputs\.release_sha \}\}/); + assert.match(production, /gh api .*commits\/\$RELEASE_TAG/); + assert.match(production, /! gh release view/); + }); + + test('grants the preflight token Environments read for both environment API calls without exposing it', () => { + const preflight = job('preflight', 'release-package'); + const permissions = preflightAppTokenPermissions(preflight); + for (const fixture of environmentApiPermissionFixtures) { + for (const source of fixture.sources) { + assert.match(releasePreflight, source, `missing ${fixture.endpoint}`); + } + assert.ok(permissions.includes(fixture.permission), `${fixture.endpoint} requires ${fixture.permission}`); + } + assert.deepEqual(permissions, ['administration:read', 'contents:read', 'environments:read']); + assert.match(preflight, /persist-credentials: false/); + assert.equal(preflight.match(/steps\.preflight-app-token\.outputs\.token/g)?.length, 1); + assert.ok(!/^\s+token:\s+\$\{\{ steps\.preflight-app-token\.outputs\.token \}\}/m.test(preflight)); + assert.ok(!preflight.includes('permission-actions:')); + }); + + test('keeps every certificate and the update private key inside preflight-dependent environment jobs', () => { + const packageJob = job('release-package', 'release-finalize'); + const signing = job('sign', 'publish'); + for (const secret of [ + 'PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64', + 'PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD', + 'PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64', + 'PROPR_DESKTOP_APPLE_API_KEY_ID', + 'PROPR_DESKTOP_APPLE_API_ISSUER_ID', + 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64', + 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD', + ]) { + assert.equal(workflow.match(new RegExp(`secrets\\.${secret}`, 'g'))?.length, 1); + assert.ok(packageJob.includes(`secrets.${secret}`)); + } + assert.equal(workflow.match(/secrets\.PROPR_DESKTOP_UPDATE_PRIVATE_KEY/g)?.length, 1); + assert.ok(signing.includes('secrets.PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.match(signing, /needs: \[preflight, release-finalize\]/); + assert.match(signing, /environment:\s+name: desktop-release/); + }); + + test('fails closed for every production signing, notarization, update, and signer condition', () => { + const production = job('release-package', 'release-finalize'); + for (const field of [ + 'CERTIFICATE_P12_BASE64', + 'CERTIFICATE_PASSWORD', + 'APPLE_API_KEY_P8_BASE64', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER_ID', + 'UPDATE_MAC_SIGNING_IDENTITY', + 'UPDATE_MAC_TEAM_ID', + 'CERTIFICATE_PFX_BASE64', + 'UPDATE_WINDOWS_SIGNING_IDENTITY', + 'UPDATE_WINDOWS_SIGNER_PINS', + 'UPDATE_PUBLIC_KEY', + 'UPDATE_MANIFEST_URL', + ]) assert.ok(production.includes(field), `missing fail-closed production field ${field}`); + assert.match( + production, + /for name in CERTIFICATE_P12_BASE64 CERTIFICATE_PASSWORD APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID UPDATE_MAC_SIGNING_IDENTITY UPDATE_MAC_TEAM_ID; do\s+test -n "\$\{!name\}"/, + ); + assert.match(production, /foreach \(\$entry in \$values\.GetEnumerator\(\)\) \{ if \(!\$entry\.Value\) \{ throw/); + assert.ok(!production.includes('signing_present')); + assert.ok(!production.includes('notarization_present')); + assert.match(production, /Production updates require a code-signed build/); + assert.match(production, /codesign --verify --deep --strict/); + assert.match(production, /spctl --assess/); + assert.match(production, /stapler validate/); + assert.match(production, /Authenticode signer does not match the configured build pin/); + assert.match(production, /TimeStamperCertificate/); + assert.match(production, /CertificateSha256/); + assert.match(production, /SpkiSha256/); + assert.match(production, /Windows artifacts have mixed Authenticode signers/); + assert.match(production, /certificate\|spki\)-sha256:\[a-f0-9\]\{64\}/); + assert.match(production, /release-architecture\.mjs inspect[\s\S]*--kind msi/); + assert.doesNotMatch(production, /--kind nupkg|full\.nupkg|\*Setup\.exe/); + assert.equal(production.match(/Expand-Archive -LiteralPath \$archive -DestinationPath \$wixDirectory/g)?.length, 1); + assert.match(production, /PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1'/); + }); + + test('preserves the opaque Windows certificate password for package and MSI signing', () => { + assert.match( + forgeConfig, + /readCompleteEnvironmentGroup\([\s\S]*?\['PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE', 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'\],[\s\S]*?\{ opaqueNames: \['PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'\] \},\n\);/, + ); + assert.match( + forgeConfig, + /certificatePassword: windowsSigning\.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD/, + ); + assert.match(forgeConfig, /\.\.\.\(windowsSign \? \{ windowsSign \} : \{\}\)/); + assert.match(forgeConfig, /await sign\(\{ files: \[machineInstaller\], \.\.\.windowsSign \}\)/); + assert.doesNotMatch(forgeConfig, /PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD[^\n]*\.trim\(/); + }); + + test('rechecks package architecture in staging and finalization and publishes only signed new releases', () => { + assert.equal(workflow.match(platformArchitecturePattern)?.length, 12); + assert.equal(workflow.match(/release-artifacts\.mjs stage/g)?.length, 2); + assert.equal(workflow.match(/release-artifacts\.mjs finalize/g)?.length, 2); + assert.match(job('finalize', 'preflight'), /needs: \[validation-version, package\]/); + assert.match(job('release-finalize', 'sign'), /needs: \[preflight, release-package\]/); + assert.equal(workflow.match(/sudo apt-get install --yes cpio msitools p7zip-full rpm/g)?.length, 2); + assert.equal(workflow.match(/test -x \/usr\/bin\/msiextract/g)?.length, 2); + const publish = job('publish'); + assert.match(publish, /test -s desktop-release-final\/desktop-release\.json\.sig/); + assert.match(publish, /ref: \$\{\{ needs\.preflight\.outputs\.release_sha \}\}/); + assert.match(publish, /release-publish\.mjs/); + assert.ok(!publish.includes('gh release create')); + assert.ok(!publish.includes('desktop-release-final/*')); + assert.ok(!publish.includes('--clobber')); + assert.ok(!publish.includes('gh release upload')); + }); + + test('retains the exact native matrix when the workflow checkout uses CRLF', () => { + const crlfFixture = workflow.replaceAll('\n', '\r\n'); + const normalizedFixture = normalizeWorkflowText(crlfFixture); + assert.equal(normalizedFixture.match(platformArchitecturePattern)?.length, 12); + assert.equal(normalizedFixture, workflow); + }); + + test('runs the native DMG layout suite on both macOS architectures', () => { + for (const [jobName, section] of [ + ['unsigned validation', job('package', 'finalize')], + ['trusted production', job('release-package', 'release-finalize')], + ] as const) { + assert.equal(section.match(platformArchitecturePattern)?.length, 6, `${jobName} must retain all six native jobs`); + assert.match(section, /- platform: darwin\n\s+arch: x64\n\s+runner: macos-15-intel/, `${jobName} is missing native macOS x64`); + assert.match(section, /- platform: darwin\n\s+arch: arm64\n\s+runner: macos-15/, `${jobName} is missing native macOS arm64`); + assert.match( + section, + /- name: Typecheck and test (?:unsigned|production) desktop runtime\n\s+shell: bash\n\s+run: \|\n\s+npm run desktop:typecheck\n\s+npm run desktop:test/, + `${jobName} must run the complete desktop tests without a platform condition`, + ); + assert.match(section, /Prove private-snapshot native DMG mounting is available/); + assert.match(section, /release-artifacts\.mjs probe-dmg-private-snapshot-isolation/); + assert.match(section, /probe-dmg-private-snapshot-isolation[\s\S]*--arch "\$\{\{ matrix\.arch \}\}"/); + assert.match(section, /Stage architecture(?:-verified| and signer verified) .* with native DMG mount evidence/); + assert.match(section, /release-artifacts\.mjs stage[\s\S]*--platform "\$\{\{ matrix\.platform \}\}"[\s\S]*--arch "\$\{\{ matrix\.arch \}\}"/); + assert.match(section, /Expected \$\{process\.env\.EXPECTED_PLATFORM\}-\$\{process\.env\.EXPECTED_ARCH\}/); + } + assert.equal(workflow.match(/release-artifacts\.mjs probe-dmg-private-snapshot-isolation/g)?.length, 2); + assert.ok(!releaseArchitecture.includes('probe-dmg-descriptor')); + assert.ok(!releaseArchitecture.includes("['attach', '-readonly', '-nobrowse', '-mountpoint', directory, '/dev/fd/3']")); + assert.match(releaseArtifacts, /fsConstants\.O_RDONLY \| fsConstants\.O_NOFOLLOW \| \(privateSnapshot \? 0 : fsConstants\.O_NONBLOCK\)/); + assert.match(releaseArtifacts, /mkdtemp\(join\(tmpdir\(\), 'propr-dmg-snapshot-'\)\)/); + assert.match(releaseArtifacts, /fsConstants\.O_WRONLY \| fsConstants\.O_CREAT \| fsConstants\.O_EXCL \| fsConstants\.O_NOFOLLOW/); + assert.match(releaseArtifacts, /\(pathStats\.mode & 0o777n\) !== 0o600n/); + assert.match(releaseArtifacts, /pathStats\.nlink !== 1n/); + assert.ok(!releaseArtifacts.includes('modified: stats.mtimeNs')); + assert.ok(!releaseArtifacts.includes('changed: stats.ctimeNs')); + assert.match(releaseArchitecture, /const HDIUTIL = '\/usr\/bin\/hdiutil'/); + assert.match(releaseArchitecture, /HDIUTIL, \['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath\]/); + assert.match(releaseArchitecture, /try \{\n\s+if \(mounted\) await execFile\(HDIUTIL, \['detach', directory\]\);\n\s+\} finally \{\n\s+await rm\(directory/); + assert.match(verifyDarwinImage, /const HDIUTIL = '\/usr\/bin\/hdiutil'/); + assert.match(verifyDarwinImage, /await chmod\(root, 0o500\)/); + assert.match(verifyDarwinImage, /await run\(HDIUTIL, \['verify', snapshot\.path\]\)/); + assert.match(makeDmg, /for \(let attempt = 0; attempt < 2 && !created; attempt \+= 1\)/); + assert.match(makeDmg, /\^hdiutil: create failed - Resource busy\\s\*\$/); + assert.match(makeDmg, /await rename\(temporaryOutput, outputPath\)/); + assert.match(makeDmg, /await image\.sync\(\)/); + assert.match(makeDmg, /const directory = await open\(outputDirectory, 'r'\)/); + assert.match(makeDmg, /try \{ await directory\.sync\(\); \} finally \{ await directory\.close\(\); \}/); + assert.ok(makeDmg.indexOf('await image.sync()') < makeDmg.indexOf('await directory.sync()')); + assert.match(makeDmg, /try \{ await rm\(temporaryOutput, \{ force: true \}\); \} finally \{\n\s+await rm\(stagingDirectory/); + assert.ok( + releaseArchitecture.indexOf("['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath]") + < releaseArchitecture.indexOf('inspectDmgLayout({ root: directory'), + 'native DMG bytes must be mounted read-only before layout validation', + ); + assert.ok( + releaseArchitecture.indexOf('inspectDmgLayout({ root: directory') + < releaseArchitecture.indexOf('nativeValidation: nativeDmgLayoutEvidence'), + 'native layout evidence must be produced only after the real layout validator succeeds', + ); + assert.ok( + releaseArtifacts.indexOf('const inspection = await inspectArchitecture') + < releaseArtifacts.indexOf('createNativeDmgEvidence({'), + 'staging must inspect the copied canonical DMG before binding native evidence', + ); + }); + + + test('keeps both Windows architectures and the complete machine-scope installer contract mandatory', () => { + for (const [jobName, section] of [ + ['unsigned validation', job('package', 'finalize')], + ['trusted production', job('release-package', 'release-finalize')], + ] as const) { + assert.match(section, /- platform: win32\n\s+arch: x64\n\s+runner: windows-2025/); + assert.match(section, /- platform: win32\n\s+arch: arm64\n\s+runner: windows-11-arm/); + assert.match(section, /Assert (?:signed )?Windows MVP package excludes update authority/); + assert.match(section, /Probe canonical WiX 3\.14\.1 compiler/); + assert.match( + section, + /build-windows-machine-installer\.mjs probe '\$\{\{ matrix\.arch \}\}' \$env:PROPR_DESKTOP_WIX_DIRECTORY/, + ); + assert.match(section, /Provision pinned WiX 3\.14\.1 binaries for Windows ARM64\n\s+if: matrix\.platform == 'win32' && matrix\.arch == 'arm64'/); + assert.match(section, /https:\/\/github\.com\/wixtoolset\/wix3\/releases\/download\/wix3141rtm\/wix314-binaries\.zip/); + assert.match(section, /6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31/); + assert.match(section, /Get-FileHash -LiteralPath \$archive -Algorithm SHA256/); + assert.match(section, /Invoke-WebRequest[^\n]+-MaximumRedirection 5 -TimeoutSec 120/); + assert.match(section, /\$archiveItem\.Length -le 0 -or \$archiveItem\.Length -gt 64MB/); + assert.match(section, /Expand-Archive -LiteralPath \$archive -DestinationPath \$wixDirectory/); + assert.match(section, /PROPR_DESKTOP_WIX_DIRECTORY=\$wixDirectory/); + assert.match(section, /Clean pinned Windows ARM64 WiX binaries\n\s+if: always\(\) && matrix\.platform == 'win32' && matrix\.arch == 'arm64'/); + assert.doesNotMatch(section, /choco|Chocolatey|wixVendor|electron-winstaller/); + assert.match(section, /Install and exercise (?:signed )?ordinary-user Windows application/); + assert.match(section, /Launch (?:signed )?packaged Windows application and exercise MVP desktop flows/); + assert.doesNotMatch(section, /READY|broker:build|windows-authority-build|windows-update-authority\.test|probe-packaged-windows-authority/, + `${jobName} retained a deferred Windows authority gate`); + } + assert.equal(workflow.match(/\*Machine-Setup\.msi/g)?.length, 3); + assert.equal(workflow.match(/run-installed-windows-app-harness\.ps1/g)?.length, 2); + assert.equal(workflow.match(/PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1/g)?.length, 2); + assert.doesNotMatch(forgeConfig, /extraResource|windows-authority|postPackage/); + assert.match(forgeConfig, /buildWindowsMachineInstaller/); + assert.match(forgeConfig, /wixDirectory: process\.env\.PROPR_DESKTOP_WIX_DIRECTORY/); + assert.doesNotMatch(forgeConfig, /MakerSquirrel|noMsi|Setup\.exe|full\.nupkg/); + assert.match(windowsMachineInstaller, /InstallScope="perMachine"/); + assert.match(windowsMachineInstaller, //); + assert.match( + windowsMachineInstaller, + //, + ); + assert.match( + windowsMachineInstaller, + //, + ); + assert.doesNotMatch(windowsMachineInstaller, /\bCommonProgramMenuFolder\b/); + assert.doesNotMatch( + windowsMachineInstaller, + /]*(?:\bWin64="yes")/, + ); + assert.match(windowsMachineInstaller, /INSTALLED_WIX_DIRECTORY = String\.raw`C:\\Program Files \(x86\)\\WiX Toolset v3\.14\\bin`/); + assert.match(windowsMachineInstaller, /if \(arch === 'x64'\)/); + assert.match(windowsMachineInstaller, /arch !== 'arm64'/); + assert.match(windowsMachineInstaller, /!win32\.isAbsolute\(wixDirectory\)/); + assert.match(windowsMachineInstaller, /\['-\?'\]/); + assert.match(windowsMachineInstaller, /'CANDLE'/); + assert.match(windowsMachineInstaller, /'LIGHT'/); + assert.match(windowsMachineInstaller, /'-arch', arch/); + assert.match(windowsMachineInstaller, /WIX_DIAGNOSTIC_BYTES = 4 \* 1024/); + assert.doesNotMatch(windowsMachineInstaller, /wixVendor|electron-winstaller/); + assert.match(windowsMachineInstaller, /deferred Windows update authority resource present/); + assert.doesNotMatch(windowsMachineInstaller, / { + for (const [nativeJob, aggregateJob] of [ + [job('package', 'finalize'), job('finalize', 'preflight')], + [job('release-package', 'release-finalize'), job('release-finalize', 'sign')], + ] as const) { + const make = nativeJob.search(/Make (?:signed )?Windows/); + const installed = nativeJob.indexOf('ordinary-user Windows application'); + const stage = nativeJob.indexOf('release-artifacts.mjs stage'); + const upload = nativeJob.indexOf('Upload'); + assert.ok(make >= 0 && installed > make && stage > installed && upload > stage); + assert.match(aggregateJob, /sudo apt-get install --yes cpio msitools p7zip-full rpm/); + assert.match(aggregateJob, /test -x \/usr\/bin\/msiextract/); + assert.ok(aggregateJob.indexOf('Download all') < aggregateJob.indexOf('release-artifacts.mjs finalize')); + } + assert.match(releaseArchitecture, /const MSIEXTRACT = '\/usr\/bin\/msiextract'/); + assert.match(releaseArchitecture, /KERNEL_MSIEXEC = String\.raw`\\\\\?\\GLOBALROOT\\SystemRoot\\System32\\msiexec\.exe`/); + assert.doesNotMatch(releaseArchitecture, /electron-winstaller|7z-(?:x64|arm64)\.exe/); + }); + + test('supplementary lint retains installed Windows worker lifecycle contracts', () => { + assert.doesNotMatch(installedWindowsAppTest, /(?:^|\s)-Wait(?:\s|$)/); + assert.equal(installedWindowsAppTest.match(/Start-Process/g)?.length, 1); + assert.match(installedWindowsAppTest, /\$msiTimeoutMilliseconds = 10 \* 60 \* 1000/); + assert.match(installedWindowsAppTest, /\$applicationTimeoutMilliseconds = 5 \* 60 \* 1000/); + assert.match(installedWindowsAppTest, /\$terminationTimeoutMilliseconds = 30 \* 1000/); + assert.match(installedWindowsAppTest, /\$redirectedStreamDrainTimeoutMilliseconds = 30 \* 1000/); + assert.match(installedWindowsAppTest, /\$Process\.WaitForExit\(\$TimeoutMilliseconds\)/); + assert.match(installedWindowsAppTest, /\$Process\.Kill\(\$true\)/); + assert.match( + installedWindowsAppTest, + /if \(!\$completed\) \{\n\s+Stop-SpawnedProcessTree \$Process \$Operation\n\s+throw "\$Operation timed out"/, + ); + assert.match(installedWindowsAppTest, /\$startInfo = \[Diagnostics\.ProcessStartInfo\]::new\(\)/); + assert.match(installedWindowsAppTest, /\$startInfo\.FileName = \$FilePath/); + assert.match(installedWindowsAppTest, /\$startInfo\.UseShellExecute = \$false/); + assert.match(installedWindowsAppTest, /\$startInfo\.WorkingDirectory = \$WorkingDirectory/); + assert.match(installedWindowsAppTest, /\$startInfo\.UserName = \$UserName/); + assert.match(installedWindowsAppTest, /\$startInfo\.Domain = \$Domain/); + assert.match(installedWindowsAppTest, /\$startInfo\.Password = \$Credential\.Password/); + assert.match(installedWindowsAppTest, /\$startInfo\.LoadUserProfile = \$true/); + assert.match(installedWindowsAppTest, /\$startInfo\.RedirectStandardOutput = \$true/); + assert.match(installedWindowsAppTest, /\$startInfo\.RedirectStandardError = \$true/); + assert.match(installedWindowsAppTest, /foreach \(\$argument in \$Arguments\) \{\n\s+\$startInfo\.ArgumentList\.Add\(\$argument\)/); + assert.match(installedWindowsAppTest, /\$startInfo\.Environment\.Clear\(\)/); + assert.match(installedWindowsAppTest, /\$startInfo\.Environment\.Add\(\[string\]\$entry\.Key, \[string\]\$entry\.Value\)/); + assert.doesNotMatch(installedWindowsAppTest, /\$startInfo\.Arguments\s*=/); + assert.doesNotMatch(installedWindowsAppTest, /\$applicationArgumentLine|\[string\]::Join\(' ', \$arguments\)/); + assert.match(installedWindowsAppTest, /"--user-data-dir=\$smokeUserDataDirectory"/); + assert.doesNotMatch(installedWindowsAppTest, /`"--user-data-dir=\$smokeUserDataDirectory`"/); + assert.match(installedWindowsAppTest, /-WorkingDirectory \$env:ProgramFiles/); + assert.match(installedWindowsAppTest, /-StandardOutputPath \(Join-Path \$smokeUserDataDirectory 'application\.stdout\.log'\)/); + assert.match(installedWindowsAppTest, /-StandardErrorPath \(Join-Path \$smokeUserDataDirectory 'application\.stderr\.log'\)/); + assert.equal(installedWindowsAppTest.match(/PROPR_DESKTOP_SMOKE_TEST/g)?.length, 1); + assert.doesNotMatch(installedWindowsAppTest, /Get-Content|Write-(?:Output|Verbose|Debug|Information)/); + assert.match(installedWindowsAppTest, /-AllowedExitCodes @\(0\)/); + assert.match(installedWindowsAppTest, /\$exitCode = \$Process\.ExitCode/); + assert.doesNotMatch( + installedWindowsAppTest, + /\[Environment\]::(?:Get|Set)EnvironmentVariable\(\s*'PROPR_DESKTOP_SMOKE_TEST'/, + ); + assert.doesNotMatch( + installedWindowsAppTest, + /PROPR_DESKTOP_SMOKE_TEST'[\s\S]{0,100}\[EnvironmentVariableTarget\]::(?:User|Machine)/, + ); + assert.match(installedWindowsAppTest, /\[Threading\.Tasks\.Task\]::WaitAll\(\$copyTasks, \$redirectedStreamDrainTimeoutMilliseconds\)/); + assert.match(installedWindowsAppTest, /\$Launch\.StandardOutputStream\.Dispose\(\)/); + assert.match(installedWindowsAppTest, /\$Launch\.StandardErrorStream\.Dispose\(\)/); + assert.doesNotMatch(installedWindowsAppTest, /ReadToEnd|Write-Host[^\n]*(?:StandardOutput|StandardError|Password|UserName|Domain|Arguments)/); + + assert.match(installedWindowsAppTest, /\$smokeEvidenceFileByteCap = 64 \* 1024/); + assert.match(installedWindowsAppTest, /\$smokeEvidenceFileNames = @\([\s\S]*'application\.smoke-evidence\.jsonl',[\s\S]*'application\.stdout\.log',[\s\S]*'application\.stderr\.log'[\s\S]*\)/); + assert.match(installedWindowsAppTest, /foreach \(\$fileName in \$smokeEvidenceFileNames\)/); + assert.match(installedWindowsAppTest, /\[Math\]::Min\(\[int64\]\$item\.Length, \[int64\]\$smokeEvidenceFileByteCap\)/); + assert.match(installedWindowsAppTest, /!\(\$item -is \[IO\.FileInfo\]\)/); + assert.match(installedWindowsAppTest, /\$item\.PSIsContainer/); + assert.match(installedWindowsAppTest, /\$item\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint/); + assert.match(installedWindowsAppTest, /\[IO\.FileStream\]::new\(/); + assert.doesNotMatch(installedWindowsAppTest, /New-Object IO\.FileStream\(/); + const evidenceReader = installedWindowsAppTest.slice( + installedWindowsAppTest.indexOf('function Get-SmokeEventEvidence'), + installedWindowsAppTest.indexOf("Write-Stage 'INSTALL' 'BEGIN'"), + ); + assert.doesNotMatch(evidenceReader, /Get-ChildItem[^\n]*smoke|ReadAll|ReadToEnd/); + const smokeEventAllowlist = installedWindowsAppTest.match( + /\$smokeEventCodes = \[ordered\]@\{([\s\S]*?)\n\}/, + ); + assert.ok(smokeEventAllowlist); + const expectedSmokeEvents = [ + 'desktop.smoke.authorized', + 'desktop.app.ready', + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + 'desktop.renderer.ready', + 'desktop.app.shutdown', + 'desktop.app.start_failed', + 'desktop.main_process.uncaught_exception', + 'desktop.log.write_failed', + ]; + assert.deepEqual(PACKAGED_SMOKE_EVIDENCE_EVENTS, expectedSmokeEvents); + assert.deepEqual( + [...smokeEventAllowlist[1].matchAll(/^\s+'([^']+)' = '[A-Z_]+'$/gm)].map(match => match[1]), + expectedSmokeEvents, + ); + assert.match(installedWindowsAppTest, /ConvertFrom-Json -InputObject \$line -ErrorAction Stop/); + assert.match(installedWindowsAppTest, /\$smokeEventCodes\.Contains\(\$eventName\)/); + assert.match( + installedWindowsAppTest, + /PROPR_WINDOWS_INSTALLED_SMOKE:EVIDENCE:\{0\}['"] -f \(\$summary -join ','\)/, + ); + assert.doesNotMatch(installedWindowsAppTest, /Write-Host[^\n]*(?:\$line|\$text|\$record|\$filePath|\$eventProperty)/); + assert.match(installedWindowsAppTest, /\$requiredSmokeEvents = @\([\s\S]*desktop\.smoke\.authorized[\s\S]*desktop\.app\.ready[\s\S]*desktop\.renderer\.mvp_flows\.ready[\s\S]*desktop\.renderer\.layout\.ready[\s\S]*desktop\.native\.reduced_window\.ready[\s\S]*desktop\.renderer\.ready[\s\S]*desktop\.app\.shutdown/); + assert.match(installedWindowsAppTest, /Get-SmokeEventEvidence \$smokeUserDataDirectory \$testUserSid/); + assert.match(installedWindowsAppTest, /if \(\$null -ne \$waitFailure\) \{ throw \$waitFailure \}/); + assert.match(installedWindowsAppTest, /SMOKE_REQUIRED_EVENTS_MISSING/); + assert.ok( + installedWindowsAppTest.indexOf('Wait-BoundedProcess `', installedWindowsAppTest.indexOf("Write-Stage 'APP_EXIT' 'BEGIN'")) + < installedWindowsAppTest.indexOf('Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid'), + ); + const applicationExitSection = installedWindowsAppTest.slice( + installedWindowsAppTest.indexOf("Write-Stage 'APP_EXIT' 'BEGIN'"), + installedWindowsAppTest.indexOf("Write-Stage 'UNINSTALL' 'BEGIN'"), + ); + assert.match( + applicationExitSection, + /catch \{\n\s+\$waitFailure = \$_\n\s+\} finally \{\n\s+try \{[\s\S]*?Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*?\} finally \{\n\s+\$applicationLaunch\.Process\.Dispose\(\)\n\s+\$applicationLaunch = \$null/, + ); + assert.ok( + applicationExitSection.indexOf('Wait-BoundedProcess `') + < applicationExitSection.indexOf('Close-RedirectedApplicationStreams $applicationLaunch'), + 'redirected streams must drain only after the bounded process wait completes or fails', + ); + assert.ok( + applicationExitSection.indexOf('$applicationLaunch.Process.Dispose()') + < applicationExitSection.indexOf('Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid'), + 'the application process must release redirected-stream handles before evidence inspection', + ); + assert.match( + applicationExitSection, + /\} finally \{\n\s+if \(\$null -ne \$applicationLaunch\) \{[\s\S]*Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*\$applicationLaunch\.Process\.Dispose\(\)/, + ); + + for (const stage of [ + 'INSTALL', + 'VALIDATION', + 'USER_SETUP', + 'APP_LAUNCH', + 'APP_EXIT', + 'UNINSTALL', + 'CLEANUP', + ]) { + assert.match(installedWindowsAppTest, new RegExp(`Write-Stage '${stage}' 'BEGIN'`)); + assert.match(installedWindowsAppTest, new RegExp(`Write-Stage '${stage}' 'COMPLETE'`)); + assert.match(installedWindowsAppTest, new RegExp(`Write-Stage '${stage}' 'FAILED'`)); + } + + assert.match( + installedWindowsAppTest, + /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode[\s\S]*Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/, + ); + assert.match(installedWindowsAppTest, /Get-CimInstance -ClassName Win32_UserProfile/); + assert.match(installedWindowsAppTest, /Remove-LocalUser -Name \$testUser -ErrorAction Stop/); + assert.match( + installedWindowsAppTest, + /Get-ChildItem -LiteralPath \$installRoot -Force -ErrorAction Stop[\s\S]*Remove-Item -LiteralPath \$installRoot -Force -ErrorAction Stop/, + ); + + for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { + assert.match(section, /- platform: win32\n\s+arch: x64\n/); + assert.match(section, /- platform: win32\n\s+arch: arm64\n/); + assert.equal(section.match(/run-installed-windows-app-harness\.ps1/g)?.length, 1); + assert.equal(section.match(/test-installed-windows-app-supervisor\.ps1/g)?.length, 1); + assert.equal(section.match(/run-installed-windows-app-workflow-cleanup\.ps1/g)?.length, 1); + assert.match(section, /if: always\(\) && matrix\.platform == 'win32'/); + assert.match(section, /-OwnershipManifest \$env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST/); + assert.match(section, /-ExpectedRunId \$env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID/); + } + }); + + test('runs executable supervisor acceptance on both Windows architectures and keeps supplementary contracts', () => { + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-BootstrapTimeout/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-OperationDeadlineAndTreeTermination/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-NegativeWorkerExitFinalization/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-FailClosedMarkers/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Start-ExternallyInterruptibleSupervisor/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Invoke-WorkflowCleanupController/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingAppPathsAuthority/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-ProcessTreeGone/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /WindowsIdentity\]::GetCurrent\(\)/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-Acl -LiteralPath \$canonicalLocalPath/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /FileAttributes\]::ReparsePoint/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-RunnerProfileUnchanged/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /CreateProfile|DeleteProfile|userenv\.dll/, + ); + assert.match(installedWindowsAppSupervisorFixture, /Start-FixtureDescendant/); + + assert.match(installedWindowsAppSupervisor, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000/); + assert.match(installedWindowsAppSupervisor, /AssignProcessToJobObject\(handle, processHandle\)/); + assert.match(installedWindowsAppSupervisor, /TerminateJobObject\(handle, exitCode\)/); + assert.match(installedWindowsAppSupervisor, /\$job\.AddProcess\(\$worker\.Handle\)/); + assert.match(installedWindowsAppSupervisor, /\[void\]\$ownershipReadyEvent\.Set\(\)/); + assert.ok( + installedWindowsAppSupervisor.indexOf('$job.AddProcess($worker.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$ownershipReadyEvent.Set()'), + ); + assert.match(installedWindowsAppTest, /\$ownershipHandshakeTimeoutMilliseconds = 5 \* 1000/); + assert.match(installedWindowsAppTest, /\$ownershipReady\.WaitOne\(\$ownershipHandshakeTimeoutMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$workerStarted = \$true\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); + assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); + assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); + assert.match( + installedWindowsAppSupervisor, + /\$job\.TerminateAndWait\(\$TerminationExitCode, \$WatchdogTerminationMilliseconds\)/, + ); + assert.match(installedWindowsAppSupervisor, /\$workerTreeTerminated = Stop-OwnedWorker 125/); + assert.doesNotMatch(installedWindowsAppSupervisor, /Stop-OwnedWorker \(\[uint32\]\$exitCode\)/); + assert.match(installedWindowsAppSupervisorFixture, /'NEGATIVE_EXIT'[\s\S]*exit -1/); + assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$WatchdogTerminationMilliseconds\)/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$workerTreeTerminated -and \$postTerminationCleanupAuthorized\) \{[\s\S]*Invoke-PostTerminationCleanup/, + ); + assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); + assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); + assert.match(installedWindowsAppCleanup, /Promote-UncapturedOwnedProfiles/); + assert.match( + installedWindowsAppCleanup, + /\$matchingRecords = @\(\)[\s\S]*Resolve-ValidatedOwnedProfilePath[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance/, + ); + for (const script of [installedWindowsAppTest, installedWindowsAppCleanup]) { + assert.match(script, /Resolve-SystemProfilesDirectory/); + assert.match(script, /-Name 'ProfilesDirectory' -ErrorAction Stop/); + assert.match(script, /Resolve-CanonicalNonReparseDirectory/); + assert.match(script, /FileAttributes\]::ReparsePoint/); + assert.match(script, /Split-Path -Parent \$canonicalLocalPath/); + assert.match(script, /Split-Path -Leaf \$canonicalLocalPath/); + assert.match(script, /profile local path is not the exact owned direct child of ProfilesDirectory/); + assert.match( + script, + /Resolve-ValidatedOwnedProfilePath[\s\S]*profile ownership changed immediately before deletion[\s\S]*Remove-CimInstance/, + ); + } + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /mismatched durable profile path did not fail closed[\s\S]*mismatched profile path discarded ACTIVE recovery authority/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /alternate ProfilesDirectory leaf did not fail closed[\s\S]*alternate ProfilesDirectory leaf discarded ACTIVE recovery authority/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); + assert.match(installedWindowsAppCleanup, /APP_PATH/); + assert.match(installedWindowsAppCleanup, /HKEY_CURRENT_USER\\Software\\ProPR\\Desktop/); + assert.match(installedWindowsAppCleanup, /Restore-OwnedRegistryValue/); + assert.match(installedWindowsAppCleanup, /Write-EmptyOwnershipReceipt/); + assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileIdentity/); + assert.match(installedWindowsAppCleanup, /Get-DirectoryIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Assert-MsiManagedFileSystemAuthority/); + assert.match( + installedWindowsAppCleanup, + /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, + ); + assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); + assert.doesNotMatch(installedWindowsAppCleanup, /allowProvisionalMsiUninstall/); + assert.match( + installedWindowsAppCleanup, + /\$allowAuthenticatedMsiUninstall[\s\S]*MsiTransactionState -ceq 'COMMITTED'[\s\S]*Start-Process msiexec\.exe/, + ); + assert.match( + installedWindowsAppCleanup, + /provisional registry evidence cannot authorize manual cleanup/, + ); + assert.match( + installedWindowsAppTest, + /MsiTransactionState = 'PENDING'[\s\S]*if \(!\$script:msiInstallCompleted\)[\s\S]*Get-DirectoryIdentity \$installRoot/, + ); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'ROLLED_BACK_CLEAN'/); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'COMMITTED'/); + assert.match(installedWindowsAppTest, /Assert-ExactCleanMsiBaselineAfterRollback/); + assert.match( + installedWindowsAppTest, + /Assert-MsiProductIsUnregistered \(\[string\]\$ownershipState\.InstallerProductCode\)/, + ); + assert.match(installedWindowsAppCleanup, /Assert-MsiProductIsUnregistered/); + assert.match(installedWindowsAppSupervisor, /Wait-MsiCriticalTransactionReceipt/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_MSI/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_OWNERSHIP_CAPTURE/); + assert.match( + installedWindowsAppTest, + /Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\propr-desktop\.exe/, + ); + assert.match(installedWindowsAppTest, /APP_PATH_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_FALLBACK/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_FALLBACK/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-HkcuInstalledValueOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_NORMAL_SUCCESS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /typed authenticated empty-state receipt/); + assert.match( + installedWindowsAppTest, + /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppTest, + /Assert-MsiManagedFileSystemAuthority[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match(installedWindowsAppSupervisor, /Get-InstallerAuthority \$Installer/); + assert.ok( + installedWindowsAppSupervisor.indexOf('Get-InstallerAuthority $Installer') + < installedWindowsAppSupervisor.indexOf('if (!$worker.Start())'), + 'installer authority must be captured before the worker starts', + ); + for (const field of [ + 'InstallerEntryIdentity', 'InstallerSha256', 'InstallerProductCode', + ]) { + assert.match(installedWindowsAppSupervisor, new RegExp(field)); + assert.match(installedWindowsAppTest, new RegExp(field)); + assert.match(installedWindowsAppCleanup, new RegExp(field)); + } + assert.match(installedWindowsAppSupervisor, /SchemaVersion = 3/); + assert.match(installedWindowsAppTest, /SchemaVersion = 3/); + assert.match(installedWindowsAppCleanup, /SchemaVersion -ne 3/); + assert.match( + installedWindowsAppCleanup, + /\[IO\.FileShare\]'ReadWrite, Delete'[\s\S]*ReadHandle\(\s*\$manifestStream\.SafeFileHandle,/, + ); + assert.match( + installedWindowsAppCleanup, + /ReadEntry\(\$manifestPath, \$false\) -cne\s+\$manifestEntryIdentity/, + ); + assert.match( + installedWindowsAppCleanup, + /HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',[\s\S]*'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',[\s\S]*'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT',[\s\S]*'INITIAL_ACTIVE_MATCH',[\s\S]*'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE'/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.Fixture\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.BaselineClean\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.InstallAttempted\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\]/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId\.PSObject\.BaseObject[\s\S]*GetType\(\) -ne \[string\][\s\S]*\$manifest\.InstallerEntryIdentity\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerSha256\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerProductCode\.PSObject\.BaseObject/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId = \[string\]\$runIdBaseObject[\s\S]*\$manifest\.InstallerProductCode = \[string\]\$installerProductCodeBaseObject/, + ); + assert.match( + installedWindowsAppCleanup, + /if \(\$PSVersionTable\.PSEdition -ceq 'Core'\) \{[\s\S]*\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)[\s\S]*\} else \{[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)/, + ); + assert.match( + installedWindowsAppCleanup, + /class ProPRAtomicFile[\s\S]*String\.Equals\(temporaryDirectory, destinationDirectory,[\s\S]*StringComparison\.OrdinalIgnoreCase\)[\s\S]*MoveFileExW\(temporaryFullPath, destinationFullPath,[\s\S]*MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH\)[\s\S]*Marshal\.GetLastWin32Error\(\)[\s\S]*new Win32Exception\(error/, + ); + assert.doesNotMatch(installedWindowsAppCleanup, /\[IO\.File\]::Replace\(/); + assert.match( + installedWindowsAppCleanup, + /\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)/, + ); + assert.match( + installedWindowsAppCleanup, + /\$replacementCompleted = \$false[\s\S]*\$replacementCompleted = \$true\n\s+\} finally \{\n\s+if \(!\$replacementCompleted\) \{ \[IO\.File\]::Delete\(\$temporaryPath\) \}/, + ); + assert.match( + installedWindowsAppCleanup, + /\$emptyReceipt = \$Manifest\.PSObject\.Copy\(\)[\s\S]*\$emptyReceipt\.State = 'EMPTY'[\s\S]*Write-DurableOwnershipManifest \$Path \$emptyReceipt/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\)[\s\S]*-FixtureValidationDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\) \{[\s\S]*RedirectStandardOutput = \$true[\s\S]*RedirectStandardError = \$true/, + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup diagnostic child must enter its Job Object before ownership release', + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()') + < installedWindowsAppSupervisor.indexOf('$cleanupDiagnosticDrain.Start($cleanupProcess)'), + 'cleanup diagnostic ownership must be released before redirected stream drains begin', + ); + assert.match(installedWindowsAppSupervisor, /class ProPRCleanupDiagnosticDrain/); + assert.match(installedWindowsAppSupervisor, /StandardOutputByteLimit = 96/); + assert.match(installedWindowsAppSupervisor, /StandardOutputLineLimit = 1/); + assert.match(installedWindowsAppSupervisor, /StandardErrorByteLimit = 0/); + assert.match(installedWindowsAppSupervisor, /StandardErrorLineLimit = 0/); + assert.match( + installedWindowsAppSupervisor, + /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|[\s\S]*EMPTY_RECEIPT_WRITE\)\\r\?\\n\\z/, + ); + assert.match(installedWindowsAppSupervisor, /\$cleanupHostPath = \$hostPath/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureWindowsPowerShellCleanup\)[\s\S]*System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/, + ); + assert.match( + installedWindowsAppSupervisor, + /function Get-CanonicalManifestIdentifiers[\s\S]*ToLowerInvariant\(\)[\s\S]*\[Guid\]::TryParseExact\([\s\S]*ToString\('B'\)\.ToUpperInvariant\(\)/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisor, + /InstallerEntryIdentity = \[string\]\$InstallerAuthority\.EntryIdentity[\s\S]*InstallerProductCode = \[string\]\$InstallerAuthority\.ProductCode/, + 'the 3af4800 capture/display representation must not be persisted as the identifier wire format', + ); + assert.match( + installedWindowsAppSupervisor, + /\$roundTrip = ConvertFrom-Json[\s\S]*\$roundTrip\.RunId -cne \$identifiers\.RunId[\s\S]*\$roundTrip\.InstallerProductCode -cne[\s\S]*\$identifiers\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\(\s*'CLEANUP_VALIDATION_PHASE:' \+ \$Phase/, + ); + assert.doesNotMatch( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\([\s\S]{0,120}PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_DECODE\|JSON_PARSE\|EXACT_KEY_SET\|[\s\S]*BOOLEAN_TYPES\|TRANSACTION_ENUM\|SCHEMA_TYPE_STATE\|RUN_ID_FORMAT\|[\s\S]*INSTALLER_ENTRY_ID_FORMAT\|INSTALLER_SHA256_FORMAT\|INSTALLER_PRODUCT_CODE_FORMAT\|[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|EMPTY_RECEIPT_WRITE/, + ); + assert.match( + installedWindowsAppSupervisor, + /\$cleanupProcess\.ExitCode -in @\(20,21\)/, + ); + assert.match( + installedWindowsAppCleanup, + /\$cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK'\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$manifestValidated = \$true\n\s+\$cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE'\n\s+Write-EmptyOwnershipReceipt/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /separate scenario runs the same supervisor-written initial ACTIVE[\s\S]*Windows PowerShell 5\.1 cleanup reader\/finalizer/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-WindowsPowerShellCleanupCompatibility/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /NO_MARKER_WINDOWS_POWERSHELL/); + assert.doesNotMatch( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe[\s\S]{0,180}`"\$resolvedInstaller`"/, + ); + assert.match( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe -ArgumentList @\(\n\s+'\/x', \[string\]\$manifest\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /same-path installer replacement did not fail closed/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /ACTIVE recovery authority/); + assert.match(installedWindowsAppTest, /TreeIdentity = \$script:installRootOwnedTreeIdentity/); + assert.match(installedWindowsAppTest, /EntryIdentity = \$script:shortcutOwnedEntryIdentity/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); + assert.match(installedWindowsAppWorkflowCleanup, /QueryInformationJobObject/); + assert.match(installedWindowsAppWorkflowCleanup, /WaitForNoActiveProcesses/); + assert.match(installedWindowsAppWorkflowCleanup, /TerminateAndWait/); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('$outputDrain.Start($cleanupProcess)'), + 'cleanup root must enter the Job Object before redirected output drains begin', + ); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup root must enter the Job Object before worker ownership is released', + ); + assert.ok( + installedWindowsAppCleanup.indexOf('$ownershipReady.WaitOne(5000)') + < installedWindowsAppCleanup.indexOf("Add-Type -TypeDefinition @'"), + 'cleanup worker ownership handshake must precede cold type loading', + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /early-initialization child cleanup/); + assert.match(installedWindowsAppCleanup, /workflow-cleanup-early-processes\.json/); + assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupOutputDrain/); + assert.match(installedWindowsAppWorkflowCleanup, /StreamReader reader/); + assert.match(installedWindowsAppWorkflowCleanup, /reader\.ReadAsync/); + assert.match(installedWindowsAppWorkflowCleanup, /STREAM_DRAIN_(?:TIMEOUT|FAILURE)/); + assert.match(installedWindowsAppWorkflowCleanup, /CHILD_STDERR/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerPhase/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); + assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Console\]::SetError|\btrap\b|controllerBody/); + assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::Out\.WriteLine/); + assert.equal(installedWindowsAppWorkflowCleanup.match(/\[Console\]::Out\.WriteLine/g)?.length, 2); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); + assert.match( + installedWindowsAppWorkflowCleanup, + /Add-Type -TypeDefinition @'[\s\S]*'@\n\ntry \{\n\$controllerPhase = 'PARAMETER_VALIDATION'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\n\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, + ); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /\$invokeController|StartupFailureClass/); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /run-installed-windows-app-workflow-cleanup-body\.ps1/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /\[object\]\$OwnershipManifest[\s\S]*\[object\]\$Installer[\s\S]*\[object\]\$ExpectedRunId/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); + assert.match(installedWindowsAppWorkflowCleanupWrapper, /Write-StartupFailure \$_/); + assert.equal( + installedWindowsAppWorkflowCleanupWrapper.match(/\[Console\]::Out\.WriteLine/g)?.length, + 2, + ); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanupWrapper, + /Console\]::SetError|Write-(?:Error|Host)|\btrap\b/, + ); + assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanup, + /add_(?:Output|Error)DataReceived|Begin(?:Output|Error)ReadLine/, + ); + assert.match( + installedWindowsAppWorkflowCleanup, + /if \(\$fixedResult -ceq 'COMPLETE' -and \$cleanupTreeZeroVerified -and/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixedCleanupResult -eq \$true -and !\$workflowManagedManifest\)/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_REPLACED_THEN_DEADLINE/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_SHORTCUT_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement executable was removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement shortcut was removed or changed/); + assert.match( + installedWindowsAppSupervisorFixture, + /function Initialize-FixtureDirectoryIdentity \{[\s\S]*?Add-Type -TypeDefinition/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorFixture.slice( + 0, + installedWindowsAppSupervisorFixture.indexOf('function Initialize-FixtureDirectoryIdentity'), + ), + /Add-Type/, + ); + assert.match( + installedWindowsAppSupervisorFixture, + /'OWNED_RESOURCES_THEN_DEADLINE' \{[\s\S]*Write-FixtureMarker[\s\S]*New-OwnedFixtureResources/, + ); + const controllerStatusParser = installedWindowsAppSupervisorBehaviorTest.indexOf( + '$statusMatch = Get-WorkflowCleanupControllerStatusMatch', + ); + assert.notEqual(controllerStatusParser, -1); + assert.ok( + controllerStatusParser + < installedWindowsAppSupervisorBehaviorTest.indexOf('if ($errorOutput.Length -ne 0)'), + 'controller fixed stdout must be parsed before bounded stderr classification', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WORKFLOW_CLEANUP_FIXTURE:\{0\}:STATUS:\{1\}:EXIT_CODE:\{2\}/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedControllerStartupDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /STARTUP_CLASS:\{0\}:PROCESS_EXIT:\{1\}:LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /in-place foreign child was removed or changed/); + for (const checkpoint of [ + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + ]) { + assert.match(installedWindowsAppSupervisorBehaviorTest, new RegExp(checkpoint)); + assert.match(installedWindowsAppSupervisorFixture, new RegExp(checkpoint)); + } + assert.match(installedWindowsAppSupervisorBehaviorTest, /foreign-smoke-in-place/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PrimaryWorkerFallbackForeignDescendants/); + assert.match(installedWindowsAppSupervisorFixture, /PRIMARY_FALLBACK_FOREIGN_DESCENDANTS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary install fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary shortcut fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedSupervisorMarkerDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /SUPERVISOR_EXIT:\{0\}:BOOTSTRAP_TIMED_OUT:\{1\}:LAST_VALID_NONE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /POST_TERMINATION_CLEANUP:\{3\}:SUBPHASE:\{4\}:CLEANUP_CHILD_EXIT:\{5\}/, + ); + const laterNativeDiagnostics = installedWindowsAppSupervisorBehaviorTest.slice( + installedWindowsAppSupervisorBehaviorTest.indexOf( + 'function Get-SanitizedCriticalCancellationDiagnostic', + ), + installedWindowsAppSupervisorBehaviorTest.indexOf('function Assert-OwnedResourcesGone'), + ); + assert.match(laterNativeDiagnostics, /\$outputByteLimit = 4096/); + assert.match(laterNativeDiagnostics, /\$outputLineLimit = 32/); + assert.match(laterNativeDiagnostics, /\$outputLineByteLimit = 192/); + assert.match( + laterNativeDiagnostics, + /MSI_TRANSACTION:\{1\}:' \+\s*'POST_TERMINATION_CLEANUP:\{2\}:AUTHORITY_STATE:\{3\}/, + ); + assert.match( + laterNativeDiagnostics, + /'GRACE','ROLLED_BACK_CLEAN'|GRACE\|COMMITTED\|ROLLED_BACK_CLEAN\|UNPROVEN/, + ); + assert.match(laterNativeDiagnostics, /'PROVISIONAL'[\s\S]*'NONPROVISIONAL'/); + assert.match( + laterNativeDiagnostics, + /EXIT_CODE:\{0\}:RESULT:\{1\}:CONTROLLER_STATUS:\{2\}:' \+\s*'REPORTED_EXIT_CODE:\{3\}\{4\}/, + ); + assert.match(laterNativeDiagnostics, /ASCII\.GetByteCount\(\$diagnostic\) -gt 256/); + assert.match(laterNativeDiagnostics, /if \(\$controllerStatus -ceq 'STARTUP_FAILURE'\)/); + assert.match( + laterNativeDiagnostics, + /\$startupClass -cnotin @\('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER'\)/, + ); + assert.match(laterNativeDiagnostics, /\$startupProcessExit = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\$startupLine = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\^\[1-9\]\[0-9\]\{0,5\}\$/); + assert.match(laterNativeDiagnostics, /\$parsedStartupLine -le 999999/); + assert.match( + laterNativeDiagnostics, + /STARTUP_CLASS:\{0\}:STARTUP_PROCESS_EXIT:\{1\}:' \+\s*'STARTUP_LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-WorkflowCleanupControllerStatusMatch[\s\S]*workflow cleanup parser accepted malformed startup metadata/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /valid bounded startup metadata was not preserved[\s\S]*invalid startup metadata did not fail closed to fixed sentinels[\s\S]*non-startup cleanup diagnostic included startup-only metadata/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /did not publish durable nonprovisional authority:\$duringCaptureDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /standalone cleanup did not retry to exact success after authority restoration:\$replacementRetryDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'WORKER_TREE_TERMINATION:\{0\}'\) -f/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'CLEANUP_CHILD_EXIT:\{0\}'\) -f/, + ); + assert.match(installedWindowsAppCleanup, /\$initialActiveFixtureManifest/); + assert.match( + installedWindowsAppCleanup, + /Write-EmptyOwnershipReceipt \$manifestPath \$manifest/, + ); + const primaryFallbackFixture = installedWindowsAppSupervisorFixture.slice( + installedWindowsAppSupervisorFixture.indexOf('function Test-PrimaryFallbackForeignDescendants'), + installedWindowsAppSupervisorFixture.indexOf('function Start-FixtureDescendant'), + ); + assert.doesNotMatch(primaryFallbackFixture, /Initialize-FixtureDirectoryIdentity|Add-Type/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /InjectTerminationFailure/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /termination failure discarded authenticated recovery authority/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-ProvisionalUserMarkerOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /provisional username authorized replacement-account deletion/); + assert.match(installedWindowsAppTest, /-Description \$userOwnershipMarker/); + assert.match(installedWindowsAppCleanup, /provisional local-user ownership marker does not match/); + assert.doesNotMatch(installedWindowsAppCleanup, /\$skipMsiUninstall/); + const ownedDirectoryCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + ); + assert.doesNotMatch(ownedDirectoryCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(ownedDirectoryCleanup, /owned directory contains an unexpected descendant/); + assert.match(ownedDirectoryCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match(installedWindowsAppCleanup, /Resolve-SmokeDirectoryAuthority/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedSmokeDirectory/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemEntryIdentity/); + const ownedFileCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedRegistryKey'), + ); + assert.match( + ownedFileCleanup, + /Record\.EntryIdentity[\s\S]*Get-FileSystemEntryIdentity \$path \$false/, + ); + assert.ok( + ownedFileCleanup.indexOf('Get-FileSystemEntryIdentity $path $false') + < ownedFileCleanup.indexOf('Remove-Item -LiteralPath $path'), + 'owned file entry identity must be checked immediately before deletion', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /LINE_COUNT:\{0\}:STDERR_COUNT:\{1\}/); + assert.match(installedWindowsAppCleanup, /smoke user-data object owner is not authorized/); + assert.match(installedWindowsAppCleanup, /smoke user-data object ACL is not authorized/); + assert.match(installedWindowsAppCleanup, /entries\.Count -ge 50000/); + const smokeCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedSmokeDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + ); + assert.doesNotMatch(smokeCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(smokeCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match( + installedWindowsAppTest, + /Write-DurableOwnershipToken[\s\S]*Promote-SmokeOwnershipRecord[\s\S]*SHORTCUT_PRESENT_PROBE/, + ); + assert.match( + installedWindowsAppTest, + /CreatorSid = \[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\.Value/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /replacement install tree was removed or changed/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /timed-out workflow cleanup discarded authenticated recovery authority[\s\S]*failed workflow cleanup discarded authenticated recovery authority[\s\S]*retry to fixed cleanup success/, + ); + const fixedResultWrite = installedWindowsAppWorkflowCleanup.indexOf( + 'Write-FixedResult $fixedResult', + ); + assert.ok( + fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf('$resource.Dispose()') + && fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf( + 'if ($fixedResult -ceq \'COMPLETE\' -and $validatedManifestPath)', + ), + 'fixed controller evidence must be emitted after bounded finalization', + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /workflowCleanup\.(?:Error|StandardError)|failedCleanup\.(?:Error|StandardError)/, + ); + for (const result of ['COMPLETE', 'FAILED', 'TIMED_OUT']) { + assert.match( + installedWindowsAppWorkflowCleanup, + new RegExp(`PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:\\$Result|["']${result}["']`), + ); + } + assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); + assert.match( + installedWindowsAppSupervisor, + /foreach \(\$resource in @\(\$job, \$worker, \$ownershipReadyEvent, \$cancellationEvent\)\)/, + ); + assert.match(installedWindowsAppSupervisor, /try \{ \$resource\.Dispose\(\) \} catch/); + + assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); + assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 4); + assert.match( + installedWindowsAppTest, + /\$record = '\{0\}\|\{1\}\|\{2\}\|\{3\}' -f \$deadline, \$Stage, \$Substage, \$Status/, + ); + assert.match( + installedWindowsAppSupervisor, + /\(\?BEGIN\|COMPLETE\|FAILED\)/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppTest, + /PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:\{0\}:\{1\}:\{2\}' -f `[\s\S]{0,100}\[Console\]::Out\.Flush\(\)/, + ); + + const markerWriter = installedWindowsAppTest.match( + /function Write-WatchdogMarker\(([\s\S]*?)\n\}/, + ); + assert.ok(markerWriter); + const operationAllowlist = markerWriter[1].match( + /\[ValidateSet\(\n([\s\S]*?)\n\s+\)\]\[string\]\$Substage/, + ); + assert.ok(operationAllowlist); + const operations = [...operationAllowlist[1].matchAll(/'([A-Z_]+)'/g)] + .map(match => match[1]); + assert.deepEqual(operations, [ + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK', + ]); + for (const operation of operations) { + assert.ok( + installedWindowsAppTest.match(new RegExp(`'${operation}'`, 'g'))!.length >= 2, + `${operation} must be allowlisted and reached by a bounded marker path`, + ); + } + assert.match( + installedWindowsAppTest, + /Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'BEGIN'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'COMPLETE'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'FAILED'/, + ); + + const diagnosticSources = `${installedWindowsAppSupervisor}\n${installedWindowsAppTest}`; + assert.doesNotMatch( + diagnosticSources, + /Write-(?:Host|Warning|Error|Verbose|Debug|Information)[^\n]*(?:\$password|\$credential|\$Installer|\$installerPath|\$testUser|\$UserName|\$Domain|\$Arguments|\$record|\$bytes)/i, + ); + }); + + test('supplementary lint retains fail-closed installed-app cleanup guards', () => { + assert.match( + installedWindowsAppTest, + /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$appPathsExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, + ); + assert.match(installedWindowsAppTest, /\$script:testUserCreatedByRun = \$true/); + assert.match( + installedWindowsAppTest, + /if \(\$testUserCreatedByRun -and \$null -ne \$testUserSid\)[\s\S]*!\$ownedUser\.SID\.Equals\(\$testUserSid\)[\s\S]*Remove-LocalUser/, + ); + assert.match( + installedWindowsAppTest, + /\$matchingRecords = @\(\)[\s\S]*foreach \(\$record in \$ownedProfileRecords\)[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Get-ChildItem -LiteralPath \$installRoot -Force[\s\S]*Remove-Item -LiteralPath \$installRoot -Force/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$protocolCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$protocolRegistryPath[\s\S]*Remove-Item -LiteralPath \$protocolRegistryPath -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$appPathsCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*Remove-Item -LiteralPath \$appPathsRegistryPath -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$createdByRun\) \{[\s\S]*Get-ChildItem -LiteralPath \$path -Force[\s\S]*Remove-Item -LiteralPath \$path -Force/, + ); + }); + + test('uses bounded network logon impersonation with secure native credential cleanup', () => { + const nativeLogon = [...installedWindowsAppTest.matchAll( + /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/g, + )].find((match) => match[1].includes('public static class ProPRWindowsLogon')); + assert.ok(nativeLogon); + assert.match(nativeLogon[1], /using Microsoft\.Win32\.SafeHandles;/); + assert.match(nativeLogon[1], /public const int LOGON32_LOGON_NETWORK = 3;/); + assert.match(nativeLogon[1], /public const int LOGON32_PROVIDER_DEFAULT = 0;/); + assert.match( + nativeLogon[1], + /\[DllImport\("advapi32\.dll",[\s\S]*EntryPoint = "LogonUserW"\)\]/, + ); + assert.match(nativeLogon[1], /\[return: MarshalAs\(UnmanagedType\.Bool\)\]/); + assert.match( + nativeLogon[1], + /public static extern bool LogonUserW\([\s\S]*IntPtr password,[\s\S]*out SafeAccessTokenHandle token\);/, + ); + + const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); + const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); + assert.ok(probeStart >= 0 && probeEnd > probeStart); + const shortcutProbe = installedWindowsAppTest.slice(probeStart, probeEnd); + assert.match( + shortcutProbe, + /\[Runtime\.InteropServices\.Marshal\]::SecureStringToGlobalAllocUnicode\(\n\s+\$Credential\.Password\n\s+\)/, + ); + assert.match( + shortcutProbe, + /\[ProPRWindowsLogon\]::LogonUserW\([\s\S]*\[ProPRWindowsLogon\]::LOGON32_LOGON_NETWORK,[\s\S]*\[ProPRWindowsLogon\]::LOGON32_PROVIDER_DEFAULT,[\s\S]*\[ref\]\$token/, + ); + assert.match( + shortcutProbe, + /\[Microsoft\.Win32\.SafeHandles\.SafeAccessTokenHandle\]\$token = \$null/, + ); + const finallyStart = shortcutProbe.indexOf('} finally {'); + const zeroFree = shortcutProbe.indexOf( + '[Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($passwordBuffer)', + ); + const tokenDispose = shortcutProbe.indexOf('$token.Dispose()'); + assert.ok(finallyStart >= 0 && zeroFree > finallyStart && tokenDispose > zeroFree); + assert.match(shortcutProbe, /if \(\$passwordBuffer -ne \[IntPtr\]::Zero\)/); + assert.match(shortcutProbe, /if \(\$null -ne \$token\)/); + }); + + test('requires the exact ordinary-user SID before bounded presence and absence checks', () => { + const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); + const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); + assert.ok(probeStart >= 0 && probeEnd > probeStart); + const shortcutProbe = installedWindowsAppTest.slice(probeStart, probeEnd); + const impersonated = shortcutProbe.match( + /\[Security\.Principal\.WindowsIdentity\]::RunImpersonated\(\$token, \[Action\]\{([\s\S]*?)\n\s+\}\)/, + ); + assert.ok(impersonated); + const action = impersonated[1]; + const identityCheck = action.indexOf( + 'if ($null -eq $identity.User -or !$identity.User.Equals($UserSid))', + ); + const presenceCheck = action.indexOf( + 'Test-Path -LiteralPath $ShortcutPath -ErrorAction Stop', + ); + assert.ok(identityCheck >= 0 && presenceCheck > identityCheck); + assert.match(action, /\$identity = \[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)/); + assert.match(action, /\[string\]::IsNullOrWhiteSpace\(\$ShortcutPath\)/); + assert.match(action, /!\[IO\.Path\]::IsPathRooted\(\$ShortcutPath\)/); + assert.match(action, /if \(!\$ExpectedPresent -and !\$present\) \{ return \}/); + assert.match(action, /if \(\$present -ne \$ExpectedPresent\)/); + assert.match(action, /Get-Item -LiteralPath \$ShortcutPath -Force -ErrorAction Stop/); + assert.match(action, /!\(\$item -is \[IO\.FileInfo\]\)/); + assert.match(action, /\$item\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint/); + assert.match(action, /\$item\.Length -le 0 -or \$item\.Length -gt \$shortcutFileByteCap/); + assert.match(action, /\[IO\.File\]::Open\([\s\S]*\[IO\.FileAccess\]::Read/); + assert.match(action, /\$stream\.Length -le 0 -or \$stream\.Length -gt \$shortcutFileByteCap/); + assert.match(action, /\$stream\.ReadByte\(\) -lt 0/); + assert.match(action, /if \(\$null -ne \$stream\) \{ \$stream\.Dispose\(\) \}/); + assert.match(action, /if \(\$null -ne \$identity\) \{ \$identity\.Dispose\(\) \}/); + + const shortcutCalls = [...installedWindowsAppTest.matchAll( + /Test-StartMenuShortcutAsOrdinaryUser `([\s\S]*?)\n\s+-ExpectedPresent \$(true|false)/g, + )]; + assert.deepEqual(shortcutCalls.map(call => call[2]), ['true', 'false']); + for (const call of shortcutCalls) { + assert.match(call[1], /-UserSid \$testUserSid `/); + assert.match(call[1], /-ShortcutPath \$startMenuShortcut `/); + } + }); + + test('keeps shortcut proof output fixed and redacted and rejects the legacy process proof', () => { + const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); + const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); + assert.ok(probeStart >= 0 && probeEnd > probeStart); + const shortcutProbe = installedWindowsAppTest.slice(probeStart, probeEnd); + assert.equal( + shortcutProbe.match(/PROPR_WINDOWS_INSTALLED_SMOKE:SHORTCUT_PROBE/g)?.length, + 2, + ); + assert.match( + shortcutProbe, + /Write-Host \('PROPR_WINDOWS_INSTALLED_SMOKE:SHORTCUT_PROBE:\{0\}:SUCCESS' -f \$expectation\)/, + ); + assert.match( + shortcutProbe, + /Write-Host \('PROPR_WINDOWS_INSTALLED_SMOKE:SHORTCUT_PROBE:\{0\}:\{1\}' -f \$expectation, \$failureCategory\)/, + ); + const categories = [...shortcutProbe.matchAll( + /\$failureCategory = '(LOGON_FAILED|ACCESS_CHECK_FAILED|CLEANUP_FAILED)'/g, + )].map(match => match[1]); + assert.deepEqual([...new Set(categories)].sort(), [ + 'ACCESS_CHECK_FAILED', + 'CLEANUP_FAILED', + 'LOGON_FAILED', + ]); + assert.doesNotMatch( + shortcutProbe, + /(?:Write-Host|throw)[^\n]*(?:\$ShortcutPath|\$UserName|\$Domain|\$UserSid|\$Credential|\.Exception|\.Message|NativeErrorCode)/, + ); + assert.doesNotMatch( + shortcutProbe, + /ProcessStartInfo|\$process\.Start\(|SPAWN_FAILED|EncodedCommand|probeChildEnvironment|PROPR_DESKTOP_START_MENU_SHORTCUT|shortcutProbeExitCategories|StandardOutput|StandardError/, + ); + assert.doesNotMatch(installedWindowsAppTest, /\$shortcutProbeExitCategories|\$probeTemplate/); + }); + + test('emits fixed uninstall and cleanup substages without masking the primary failure', () => { + const writerStart = installedWindowsAppTest.indexOf('function Write-CleanupSubstage('); + const writerEnd = installedWindowsAppTest.indexOf('function Stop-SpawnedProcessTree(', writerStart); + assert.ok(writerStart >= 0 && writerEnd > writerStart); + const writer = installedWindowsAppTest.slice(writerStart, writerEnd); + const substageAllowlist = writer.match(/\[ValidateSet\(\n([\s\S]*?)\n\s+\)\]\[string\]\$Substage/); + assert.ok(substageAllowlist); + const substages = [...substageAllowlist[1].matchAll(/'([A-Z_]+)'/g)].map(match => match[1]); + assert.deepEqual(substages, [ + 'MSI_UNINSTALL', + 'INSTALL_TREE', + 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', + 'SHORTCUT_FILE', + 'SHORTCUT_FOLDER', + 'ORDINARY_USER_ABSENCE_PROBE', + 'SMOKE_DATA', + 'PROFILE', + 'USER', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK', + 'FINAL_AGGREGATION', + ]); + assert.match(writer, /\[ValidateSet\('BEGIN','COMPLETE','FAILED','SKIPPED'\)\]\[string\]\$Status/); + assert.match( + writer, + /PROPR_WINDOWS_INSTALLED_SMOKE:\{0\}:\{1\}:\{2\}' -f \$Scope, \$Substage, \$Status/, + ); + + const cleanupCalls = [...installedWindowsAppTest.matchAll( + /^\s+Write-CleanupSubstage '([A-Z_]+)' '([A-Z_]+)' '([A-Z_]+)'$/gm, + )]; + assert.ok(cleanupCalls.length > 0); + assert.equal( + installedWindowsAppTest.match(/^\s+Write-CleanupSubstage /gm)?.length, + cleanupCalls.length, + 'every cleanup diagnostic call must use fixed literal allowlisted fields', + ); + for (const [, scope, substage, status] of cleanupCalls) { + assert.ok(['UNINSTALL', 'CLEANUP'].includes(scope)); + assert.ok(substages.includes(substage)); + assert.ok(['BEGIN', 'COMPLETE', 'FAILED', 'SKIPPED'].includes(status)); + } + for (const substage of [ + 'MSI_UNINSTALL', + 'INSTALL_TREE', + 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', + 'SHORTCUT_FILE', + 'SHORTCUT_FOLDER', + ]) { + for (const status of ['BEGIN', 'COMPLETE', 'FAILED']) { + assert.ok(cleanupCalls.some(match => match[1] === 'UNINSTALL' && match[2] === substage && match[3] === status)); + } + } + assert.ok(cleanupCalls.some(match => ( + match[1] === 'UNINSTALL' + && match[2] === 'ORDINARY_USER_ABSENCE_PROBE' + && match[3] === 'SKIPPED' + ))); + for (const substage of [ + 'SMOKE_DATA', + 'PROFILE', + 'USER', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK', + 'FINAL_AGGREGATION', + ]) { + for (const status of ['BEGIN', 'COMPLETE', 'FAILED']) { + assert.ok(cleanupCalls.some(match => match[1] === 'CLEANUP' && match[2] === substage && match[3] === status)); + } + } + assert.match( + installedWindowsAppTest, + /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$cleanupFailed\)[\s\S]*?if \(\$null -eq \$primaryFailure\) \{\n\s+throw 'installed Windows cleanup did not complete'/, + ); + }); + + test('keeps the canonical common shortcut and exact-identity cleanup', () => { + const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); + const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); + assert.ok(probeStart >= 0 && probeEnd > probeStart); + const shortcutProbe = installedWindowsAppTest.slice(probeStart, probeEnd); + assert.match(shortcutProbe, /\[string\]\$ShortcutPath/); + assert.match(shortcutProbe, /Test-Path -LiteralPath \$ShortcutPath -ErrorAction Stop/); + assert.equal(installedWindowsAppTest.match(/-ShortcutPath \$startMenuShortcut/g)?.length, 2); + + const installStart = installedWindowsAppTest.indexOf("Write-Stage 'INSTALL' 'BEGIN'"); + assert.ok( + installedWindowsAppTest.indexOf( + '$startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut', + ) < installStart, + ); + assert.ok( + installedWindowsAppTest.indexOf( + '$startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder', + ) < installStart, + ); + assert.match( + installedWindowsAppTest, + /\$script:startMenuShortcutCreatedByRun =\n\s+!\$startMenuShortcutExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcut\)/, + ); + assert.match( + installedWindowsAppTest, + /\$script:startMenuShortcutFolderCreatedByRun =\n\s+!\$startMenuShortcutFolderExistedBeforeInstall -and[\s\S]{0,40}\(Test-Path -LiteralPath \$startMenuShortcutFolder\)/, + ); + + const cleanupStart = installedWindowsAppTest.indexOf("Write-Stage 'CLEANUP' 'BEGIN'"); + assert.ok(cleanupStart >= 0); + const cleanup = installedWindowsAppTest.slice(cleanupStart); + assert.match( + cleanup, + /if \(\$startMenuShortcutCreatedByRun -and \(Test-Path -LiteralPath \$startMenuShortcut\)\)[\s\S]*Get-FileIdentity \$startMenuShortcut[\s\S]*Remove-Item -LiteralPath \$startMenuShortcut -Force -ErrorAction Stop/, + ); + assert.match( + cleanup, + /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Get-ChildItem -LiteralPath \$startMenuShortcutFolder -Force[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, + ); + const installFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN'"), + ); + const shortcutFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'FINAL_AGGREGATION' 'BEGIN'"), + ); + assert.doesNotMatch(installFallback, /Remove-Item[^\n]*-Recurse/); + assert.doesNotMatch(shortcutFallback, /Remove-Item[^\n]*-Recurse/); + assert.doesNotMatch( + installedWindowsAppTest, + /Remove-Item[^\n]*\$commonPrograms[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*\$commonPrograms/, + ); + assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu shortcut behind/); + assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu folder behind/); + }); + + + test('replaces a hostile privileged parent environment with the exact smoke child allowlist', () => { + const allowlist = installedWindowsAppTest.match( + /\$childEnvironment = \[ordered\]@\{([\s\S]*?)\n\s+\}/, + ); + assert.ok(allowlist); + const entries = [...allowlist[1].matchAll(/^\s+'([^']+)' = ('[^']*'|\$[A-Za-z][A-Za-z0-9]*)$/gm)] + .map(([, key, expression]) => ({ key, expression })); + assert.deepEqual(entries.map(({ key }) => key), [ + 'APPDATA', + 'LOCALAPPDATA', + 'PROPR_DESKTOP_SMOKE_TEST', + 'SystemRoot', + 'TEMP', + 'TMP', + 'USERPROFILE', + ]); + + const hostileNames = [ + 'BUILD_PASSWORD', + 'CI_TOKEN', + 'DEPLOY_SECRET', + 'SSH_PRIVATE_KEY', + 'CSC_LINK', + 'CSC_KEY_PASSWORD', + 'WIN_CSC_LINK', + 'WIN_CSC_KEY_PASSWORD', + 'WINDOWS_CERTIFICATE_FILE', + 'WINDOWS_CERTIFICATE_PASSWORD', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'AZURE_CLIENT_ID', + 'AZURE_CLIENT_SECRET', + 'AZURE_TENANT_ID', + 'PROPR_DESKTOP_UPDATE_PRIVATE_KEY', + 'PROPR_DESKTOP_SIGNING_SECRET', + 'PROPR_DESKTOP_UNRELATED', + 'PATH', + ]; + const seededValues = new Set(); + const childEnvironment = new Map(); + for (const name of [...hostileNames, ...entries.map(({ key }) => key)]) { + const value = `hostile-parent-value:${name}`; + seededValues.add(value); + childEnvironment.set(name, value); + } + + const launch = installedWindowsAppTest.match( + /\$startInfo = \[Diagnostics\.ProcessStartInfo\]::new\(\)([\s\S]*?)if \(!\$process\.Start\(\)\)/, + ); + assert.ok(launch); + const clear = launch[0].indexOf('$startInfo.Environment.Clear()'); + const add = launch[0].indexOf('$startInfo.Environment.Add([string]$entry.Key, [string]$entry.Value)'); + const start = launch[0].indexOf('if (!$process.Start())'); + assert.ok(clear > 0 && clear < add && add < start); + assert.equal(launch[0].match(/\$startInfo\.Environment/g)?.length, 2); + assert.doesNotMatch(launch[0], /GetEnvironmentVariables|EnvironmentVariables|\.Environment\s*=|Remove\(/); + + const smokeRoot = 'C:\\private smoke root\\propr-desktop-smoke-0123456789abcdef0123456789abcdef'; + const fixedValues: Record = { + '$roamingAppDataDirectory': `${smokeRoot}\\profile\\AppData\\Roaming`, + '$localAppDataDirectory': `${smokeRoot}\\profile\\AppData\\Local`, + '$WindowsDirectory': 'C:\\Windows', + '$temporaryDirectory': `${smokeRoot}\\temp`, + '$profileDirectory': `${smokeRoot}\\profile`, + }; + childEnvironment.clear(); + for (const { key, expression } of entries) { + const value = expression.startsWith("'") + ? expression.slice(1, -1) + : fixedValues[expression]; + assert.ok(value, `unexpected child environment expression ${expression}`); + childEnvironment.set(key, value); + } + + assert.deepEqual([...childEnvironment.keys()], entries.map(({ key }) => key)); + assert.equal(childEnvironment.get('PROPR_DESKTOP_SMOKE_TEST'), '1'); + assert.equal(childEnvironment.get('SystemRoot'), 'C:\\Windows'); + for (const name of ['APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'USERPROFILE']) { + assert.ok(childEnvironment.get(name)?.startsWith(`${smokeRoot}\\`)); + } + for (const hostileName of hostileNames) assert.ok(!childEnvironment.has(hostileName)); + for (const value of childEnvironment.values()) assert.ok(!seededValues.has(value)); + + assert.match(installedWindowsAppTest, /\[Environment\]::GetFolderPath\(\[Environment\+SpecialFolder\]::Windows\)/); + assert.doesNotMatch(allowlist[0], /\$env:|PATH/); + assert.doesNotMatch(installedWindowsAppTest, /Write-Host[^\n]*(?:childEnvironment|Environment|Password|UserName|Domain)/); + assert.match(installedWindowsAppTest, /alternate-credential child profile ACL is not inherited from the smoke directory/); + }); + + test('keeps spaced and unspaced smoke argv values as distinct ArgumentList entries', () => { + const launch = installedWindowsAppTest.match( + /function Start-AlternateCredentialApplication\([\s\S]*?\n\}/, + ); + assert.ok(launch); + assert.match( + launch[0], + /foreach \(\$argument in \$Arguments\) \{\n\s+\$startInfo\.ArgumentList\.Add\(\$argument\)\n\s+\}/, + ); + assert.doesNotMatch(launch[0], /\.Arguments\s*=|Join\(|-join|CommandLine|cmd\.exe|powershell\.exe/); + + for (const argumentValues of [ + ['--propr-smoke-test', '--user-data-dir=C:\\smoke root\\profile'], + ['--propr-smoke-test', '--user-data-dir=C:\\smoke-root\\profile'], + ]) { + const argumentList: string[] = []; + for (const argument of argumentValues) argumentList.push(argument); + assert.deepEqual(argumentList, argumentValues); + } + }); + + test('opens installed Windows smoke evidence with a bounded, redacted reader', () => { + const inspectionPhase = installedWindowsAppTest.match( + /enum SmokeEvidenceInspectionPhase \{([\s\S]*?)\n\}/, + ); + assert.ok(inspectionPhase); + assert.deepEqual( + [...inspectionPhase[1].matchAll(/^\s+([A-Z_]+)$/gm)].map(match => match[1]), + ['DIRECTORY', 'ACL', 'FILE_METADATA', 'FILE_OPEN', 'FILE_READ', 'EVENT_PARSE', 'SUMMARY'], + ); + + const evidenceReader = installedWindowsAppTest.match( + /function Get-SmokeEventEvidence\([\s\S]*?\n\}\n\ntry \{/, + ); + assert.ok(evidenceReader); + const reader = evidenceReader[0]; + assert.match(reader, /foreach \(\$fileName in \$smokeEvidenceFileNames\)/); + assert.doesNotMatch(reader, /Get-ChildItem|Get-Content|ReadAll|ReadToEnd/); + assert.match(reader, /Get-Item -LiteralPath \$filePath -Force -ErrorAction Stop/); + assert.match(reader, /!\(\$item -is \[IO\.FileInfo\]\)/); + assert.match(reader, /\$item\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint/); + assert.match(reader, /\[Math\]::Min\(\[int64\]\$item\.Length, \[int64\]\$smokeEvidenceFileByteCap\)/); + + assert.match(installedWindowsAppTest, /\$smokeEvidenceOpenRetryDeadlineMilliseconds = 2 \* 1000/); + assert.match(installedWindowsAppTest, /\$smokeEvidenceOpenRetryDelayMilliseconds = 50/); + assert.match(reader, /\$openRetryStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); + assert.match(reader, /\$openAttempt -gt 0 -and\n\s+\$openRetryStopwatch\.ElapsedMilliseconds -ge/); + assert.match(reader, /catch \[IO\.IOException\] \{/); + assert.match(reader, /\$nativeErrorCode -notin @\(32, 33\)/); + assert.match( + reader, + /\$openRetryStopwatch\.ElapsedMilliseconds -ge \$smokeEvidenceOpenRetryDeadlineMilliseconds/, + ); + assert.match( + reader, + /\$retryDelayMilliseconds = \[Math\]::Min\([\s\S]*?\$smokeEvidenceOpenRetryDelayMilliseconds,[\s\S]*?\$remainingMilliseconds/, + ); + assert.match(reader, /Start-Sleep -Milliseconds \$retryDelayMilliseconds/); + + assert.match( + reader, + /\[IO\.FileStream\]::new\([\s\S]*?\[IO\.FileMode\]::Open,[\s\S]*?\[IO\.FileAccess\]::Read,[\s\S]*?\[IO\.FileShare\]::Read,[\s\S]*?\[IO\.FileOptions\]::SequentialScan/, + ); + assert.doesNotMatch(reader, /New-Object IO\.FileStream/); + assert.match( + reader, + /\} finally \{\n\s+if \(\$null -ne \$stream\) \{ \$stream\.Dispose\(\) \}\n\s+\}/, + ); + assert.match(reader, /\[Text\.UTF8Encoding\]::new\(\$false, \$true\)/); + assert.match( + reader, + /\} finally \{\n\s+if \(\$null -ne \$stream\) \{ \$stream\.Dispose\(\) \}\n\s+\}\n\s+\$inspectionPhase = \[SmokeEvidenceInspectionPhase\]::EVENT_PARSE\n\s+if \(\$offset -eq 0\) \{ continue \}/, + ); + assert.match( + reader, + /try \{\n\s+\$record = ConvertFrom-Json -InputObject \$line -ErrorAction Stop\n\s+if \(\$null -eq \$record -or \$record -isnot \[PSCustomObject\]\) \{ continue \}/, + ); + assert.match( + reader, + /\$eventProperty = \$record\.PSObject\.Properties\['event'\]\n\s+if \(\$null -eq \$eventProperty -or \$eventProperty\.Name -cne 'event' -or\n\s+\$eventProperty\.Value -isnot \[string\]\) \{\n\s+continue\n\s+\}/, + ); + assert.match( + reader, + /\$eventName = \$eventProperty\.Value\n\s+if \(!\$smokeEventCodes\.Contains\(\$eventName\)\) \{ continue \}\n\s+\$events\[\$eventName\] = \$true\n\s+\} catch \{\n\s+continue\n\s+\}/, + ); + assert.match( + reader, + /\$fileName -ceq 'application\.smoke-evidence\.jsonl' -and\n\s+@\(\$record\.PSObject\.Properties\)\.Count -ne 1/, + ); + + assert.match( + reader, + /Write-Host \('PROPR_WINDOWS_INSTALLED_SMOKE:EVIDENCE_INSPECTION_FAILED:\{0\}' -f \$inspectionPhase\)\n\s+throw 'smoke evidence inspection failed'/, + ); + assert.match( + reader, + /\[SmokeEvidenceInspectionPhase\]\$inspectionPhase = \[SmokeEvidenceInspectionPhase\]::DIRECTORY/, + ); + assert.equal(reader.match(/EVIDENCE_INSPECTION_FAILED/g)?.length, 1); + assert.doesNotMatch( + reader, + /Write-(?:Host|Warning|Error|Verbose|Debug|Information)[^\n]*(?:\$_|\$filePath|\$fullPath|\$item|\$bytes|\$text|\$line|\$record|\$eventProperty|\$eventName|Exception|Message|Error|endpoint)/i, + ); + }); + + test('configures signed updates only for macOS and never advertises a Windows update feed', () => { + const production = job('release-package', 'release-finalize'); + assert.match(production, /Require macOS signed-update runtime configuration\n\s+if: matrix\.platform == 'darwin'/); + assert.doesNotMatch(workflow, /PROPR_DESKTOP_WINDOWS_(?:X64|ARM64)_FEED_URL/); + assert.doesNotMatch(workflow, /Require signed-update runtime configuration\n\s+if: matrix\.platform != 'linux'/); + }); +}); diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts new file mode 100644 index 000000000..37c695b31 --- /dev/null +++ b/apps/desktop/src/security.test.ts @@ -0,0 +1,337 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import { + createLatestRendererReloader, + deepLinkFromArguments, + applyDevelopmentRendererCsp, + connectApiBaseUrlFromDeepLink, + dashboardPathFromDeepLink, + isSafeExternalUrl, + isTrustedRendererUrl, + normalizeApiBaseUrl, + normalizeDesktopDashboardPath, + normalizeDeepLink, + rendererContentSecurityPolicy, + validatedDevServerUrl, +} from './security'; + +describe('desktop URL security', () => { + it('matches the shared canonical origin parity table', () => { + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + assert.equal(normalizeApiBaseUrl(input), expected, name); + } + }); + it('only accepts HTTPS and loopback HTTP API endpoints', () => { + assert.equal(normalizeApiBaseUrl('https://propr.example.com/'), 'https://propr.example.com'); + assert.equal(normalizeApiBaseUrl('http://localhost:4000/'), 'http://localhost:4000'); + assert.equal(normalizeApiBaseUrl('http://team.localhost:4000'), 'http://team.localhost:4000'); + assert.equal(normalizeApiBaseUrl('http://127.99.2.3:4000'), 'http://127.99.2.3:4000'); + assert.equal(normalizeApiBaseUrl('http://127.0.0.1:4000'), 'http://127.0.0.1:4000'); + assert.equal(normalizeApiBaseUrl('http://[::1]:4000/'), 'http://[::1]:4000'); + assert.equal(normalizeApiBaseUrl('https://propr.example.com/base'), null); + assert.equal(normalizeApiBaseUrl('http://[::1]:4000/api'), null); + assert.equal(normalizeApiBaseUrl('http://propr.example.com'), null); + assert.equal(normalizeApiBaseUrl('http://[2001:db8::1]:4000'), null); + assert.equal(normalizeApiBaseUrl('https://user:secret@propr.example.com'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev'), 'https://t-instance123.propr.dev'); + assert.equal(normalizeApiBaseUrl(' https://t-instance123.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev '), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev/'), null); + assert.equal(normalizeApiBaseUrl('HTTPS://t-instance123.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://T-instance123.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev:443'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev:8443'), null); + assert.equal(normalizeApiBaseUrl('https://t-%69nstance123.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr%2edev'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.foo.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://x.t-instance123.propr.dev'), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev.'), null); + assert.equal(normalizeApiBaseUrl(`https://example.com/${'private'.repeat(400)}`), null); + assert.equal(normalizeApiBaseUrl('https://t-instance123.propr.dev.example.com'), 'https://t-instance123.propr.dev.example.com'); + assert.equal(normalizeApiBaseUrl('file:///tmp/propr'), null); + assert.equal(normalizeApiBaseUrl('http://localhost.:4000'), null); + assert.equal(normalizeApiBaseUrl('http://127.1:4000'), null); + assert.equal(normalizeApiBaseUrl('http://0177.0.0.1:4000'), null); + assert.equal(normalizeApiBaseUrl('http://0x7f000001:4000'), null); + assert.equal(normalizeApiBaseUrl('http://[::ffff:127.0.0.1]:4000'), null); + assert.equal(normalizeApiBaseUrl('https://propr.example.com///'), 'https://propr.example.com'); + }); + + it('denies unsafe external browser schemes and credential-bearing URLs', () => { + assert.equal(isSafeExternalUrl('https://github.com/integry/propr'), true); + assert.equal(isSafeExternalUrl('http://localhost:4000/docs'), true); + assert.equal(isSafeExternalUrl('http://[::1]:4000/docs'), true); + assert.equal(isSafeExternalUrl('http://example.com'), false); + assert.equal(isSafeExternalUrl('http://[2001:db8::1]:4000/docs'), false); + assert.equal(isSafeExternalUrl('javascript:alert(1)'), false); + assert.equal(isSafeExternalUrl('file://[::1]/tmp/propr'), false); + assert.equal(isSafeExternalUrl('https://token@example.com'), false); + }); + + it('requires an exact loopback development origin', () => { + assert.equal(validatedDevServerUrl('http://localhost:5173/')?.origin, 'http://localhost:5173'); + assert.equal(validatedDevServerUrl('http://[::1]:5173/')?.origin, 'http://[::1]:5173'); + assert.equal(validatedDevServerUrl('https://localhost:5173/'), null); + assert.equal(validatedDevServerUrl('http://0.0.0.0:5173/'), null); + assert.equal(validatedDevServerUrl('http://[2001:db8::1]:5173/'), null); + assert.equal(validatedDevServerUrl('ws://[::1]:5173/'), null); + assert.equal(validatedDevServerUrl('http://localhost:5173/path'), null); + assert.equal( + isTrustedRendererUrl('http://localhost:5173/renderer.html', 'http://localhost:5173/', '/unused'), + true, + ); + assert.equal( + isTrustedRendererUrl('http://127.0.0.1:5173/renderer.html', 'http://localhost:5173/', '/unused'), + false, + ); + assert.equal( + isTrustedRendererUrl('http://127.1:5173/renderer.html', 'http://127.0.0.1:5173/', '/unused'), + false, + ); + }); + + it('retains IPC trust for hash-routed packaged renderer URLs only', () => { + const renderer = 'propr-app://renderer/renderer.html'; + assert.equal(isTrustedRendererUrl(renderer, undefined, renderer), true); + assert.equal(isTrustedRendererUrl(`${renderer}#/plans/123`, undefined, renderer), true); + assert.equal(isTrustedRendererUrl(`${renderer}?profile=123#/plans/123`, undefined, renderer), false); + assert.equal(isTrustedRendererUrl('propr-app://renderer/other.html', undefined, renderer), false); + assert.equal(isTrustedRendererUrl('propr-app://other/renderer.html#/plans/123', undefined, renderer), false); + assert.equal(isTrustedRendererUrl('https://propr.example.com', undefined, renderer), false); + }); + + it('allowlists custom protocol actions and extracts them from argv', () => { + const link = 'propr://connect?api=https%3A%2F%2Fpropr.example.com'; + assert.equal(normalizeDeepLink(link), link); + assert.equal(deepLinkFromArguments(['electron', '.', link]), link); + assert.equal(normalizeDeepLink('propr://delete-everything'), null); + assert.equal(normalizeDeepLink('https://propr.example.com'), null); + assert.equal(normalizeDeepLink('propr://user:secret@connect'), null); + }); + + it('accepts only one bounded canonical Connect API candidate', () => { + const link = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; + assert.equal(connectApiBaseUrlFromDeepLink(link), 'https://connect.propr.dev'); + assert.equal(normalizeDeepLink(link), link); + + const rejected = [ + 'propr://connect', + 'propr://connect?api=', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev&api=https%3A%2F%2Fother.example', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev&token=secret', + 'propr://connect?url=https%3A%2F%2Fconnect.propr.dev', + 'propr://user:secret@connect?api=https%3A%2F%2Fconnect.propr.dev', + 'propr://connect:443?api=https%3A%2F%2Fconnect.propr.dev', + 'propr://connect/path?api=https%3A%2F%2Fconnect.propr.dev', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev#fragment', + 'propr://connect?api=http%3A%2F%2Fconnect.propr.dev', + 'propr://connect?api=https%3A%2F%2Fuser%3Asecret%40connect.propr.dev', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev%2Fapi', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev%3Ftoken%3Dsecret', + 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev%23secret', + 'propr://connect?api=https%253A%252F%252Fconnect.propr.dev', + ]; + rejected.forEach(candidate => { + assert.equal(connectApiBaseUrlFromDeepLink(candidate), null, candidate); + assert.equal(normalizeDeepLink(candidate), null, candidate); + }); + + const oversized = `propr://connect?api=https%3A%2F%2Fexample.com&${'x'.repeat(2_048)}`; + assert.ok(oversized.length > 2_048); + assert.equal(connectApiBaseUrlFromDeepLink(oversized), null); + assert.equal(normalizeDeepLink(oversized), null); + + const expandedApi = `https://${Array(300).fill('é').join('.')}.example`; + const rawLink = `propr://connect?api=${expandedApi}`; + const expandedCanonicalLink = new URL(rawLink).href; + assert.ok(rawLink.length < 2_048); + assert.ok(expandedCanonicalLink.length > 2_048); + assert.notEqual(connectApiBaseUrlFromDeepLink(rawLink), null); + assert.equal(normalizeDeepLink(rawLink), null); + }); + + it('does not canonicalize malformed reserved Connect origins into trusted candidates', () => { + const rejectedOrigins = [ + 'https://t-instance123.propr.dev/', + 'HTTPS://t-instance123.propr.dev', + 'https://T-instance123.propr.dev', + 'https://t-instance123.propr.dev:443', + 'https://t-instance123.propr.dev:8443', + 'https://t-%69nstance123.propr.dev', + 'https://t-instance123.propr%2edev', + 'https://t-instance123.foo.propr.dev', + 'https://x.t-instance123.propr.dev', + 'https://t-instance123.propr.dev.', + ]; + rejectedOrigins.forEach(origin => { + const link = `propr://connect?api=${encodeURIComponent(origin)}`; + assert.equal(connectApiBaseUrlFromDeepLink(link), null, origin); + assert.equal(normalizeDeepLink(link), null, origin); + }); + }); + + it('accepts a normal internal dashboard route from an open deep link', () => { + const link = 'propr://open?path=%2Ftasks'; + const queryAndHashLink = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent'; + assert.equal(dashboardPathFromDeepLink(link), '/tasks'); + assert.equal(normalizeDeepLink(link), link); + assert.equal(normalizeDesktopDashboardPath('/tasks?status=open'), '/tasks?status=open'); + assert.equal(dashboardPathFromDeepLink(queryAndHashLink), '/tasks?status=open#recent'); + assert.equal(normalizeDesktopDashboardPath('/tasks?status=open#recent'), '/tasks?status=open#recent'); + }); + + it('revalidates open links after canonical serialization', () => { + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + assert.ok(rawLink.length < 2_048); + assert.ok(expandedCanonicalLink.length > 2_048); + assert.notEqual(dashboardPathFromDeepLink(rawLink), null); + assert.equal(dashboardPathFromDeepLink(expandedCanonicalLink), null); + assert.equal(normalizeDeepLink(rawLink), null); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + assert.equal(boundaryCanonicalLink.length, 2_048); + assert.equal(new URL(boundaryCanonicalLink).href, boundaryCanonicalLink); + assert.equal(dashboardPathFromDeepLink(boundaryCanonicalLink), `/tasks/${suffix}`); + assert.equal(normalizeDeepLink(boundaryCanonicalLink), boundaryCanonicalLink); + }); + + it('rejects encoded delimiters combined with encoded traversal', () => { + const rejectedPaths = [ + '/tasks%23/%2e%2e/login', + '/tasks%23/%252e%252e/login', + '/tasks%3f/%2e%2e/login', + '/tasks%3f/%252e%252e/login', + ]; + + rejectedPaths.forEach(path => { + const link = `propr://open?path=${encodeURIComponent(path)}`; + assert.equal(normalizeDesktopDashboardPath(path), null, path); + assert.equal(dashboardPathFromDeepLink(link), null, link); + assert.equal(normalizeDeepLink(link), null, link); + }); + }); + + it('rejects malformed and unsafe open deep-link paths', () => { + const rejected = [ + 'propr://open', + 'propr://open?path=', + 'propr://open?path=%2Ftasks&path=%2Fplans', + 'propr://open?path=%2Ftasks&extra=true', + 'propr://open?path=https%3A%2F%2Fevil.example%2Ftasks', + 'propr://open?path=%2F%2Fevil.example%2Ftasks', + 'propr://open?path=%2Ftasks%252F..%252Flogin', + 'propr://open?path=%2Ftasks%252F%252e%252e%252Flogin', + 'propr://open?path=%2Ftasks%250Anext', + 'propr://open?path=%2Ftasks%255Cnext', + 'propr://open?path=%2Flogin%3Fredirect_to%3D%252Ftasks', + 'propr://open?path=%2Fdesktop%2Fpairing%3Fpairing_id%3Dattacker', + 'propr://open?path=%2Ftasks%3Ftunnel%3Dt-attacker.propr.dev', + 'propr://open?path=%2Ftasks%3Fflow%3Dattacker', + ]; + rejected.forEach(link => { + assert.equal(dashboardPathFromDeepLink(link), null, link); + assert.equal(normalizeDeepLink(link), null, link); + }); + }); + + it('publishes a restrictive production policy', () => { + const policy = rendererContentSecurityPolicy(); + assert.match(policy, /default-src 'self'/); + assert.match(policy, /object-src 'none'/); + assert.match(policy, /frame-src 'none'/); + assert.doesNotMatch(policy, /unsafe-eval/); + assert.match(policy, /script-src 'self'(?:;|$)/); + assert.match(policy, /connect-src 'self' https: wss:/); + assert.doesNotMatch(policy, /(?:^|\s)http:(?:\s|;|$)/); + assert.doesNotMatch(policy, /(?:^|\s)ws:(?:\s|;|$)/); + }); + + it('scopes packaged cleartext connections to exact normalized loopback profiles', () => { + const connectSources = (candidate: string): string[] => { + const policy = rendererContentSecurityPolicy(false, [candidate]); + const directive = policy.split('; ').find(value => value.startsWith('connect-src ')); + assert.ok(directive); + return directive.slice('connect-src '.length).split(' '); + }; + + for (const origin of [ + 'http://localhost:4000', + 'http://team.localhost:5173', + 'http://127.0.0.1:3000', + 'http://127.99.2.3:49152', + 'http://[::1]:4000', + ]) { + const sources = connectSources(origin); + assert.ok(sources.includes(origin), origin); + assert.ok(sources.includes(origin.replace(/^http:/, 'ws:')), origin); + assert.ok(sources.includes('https:'), origin); + assert.ok(sources.includes('wss:'), origin); + } + }); + + it('does not admit non-loopback or deceptive cleartext CSP sources', () => { + const rejected = [ + 'http://192.168.1.20:4000', + 'http://example.test:4000', + 'http://localhost.example.test:4000', + 'http://localhost.:4000', + 'http://127.1:4000', + 'http://0177.0.0.1:4000', + 'http://0x7f000001:4000', + 'http://[::ffff:127.0.0.1]:4000', + ]; + const policy = rendererContentSecurityPolicy(false, rejected); + assert.match(policy, /connect-src 'self' https: wss:/); + assert.equal(rejected.some(candidate => policy.includes(candidate)), false); + assert.doesNotMatch(policy, /(?:^|\s)http:(?:\s|;|$)/); + assert.doesNotMatch(policy, /(?:^|\s)ws:(?:\s|;|$)/); + }); + + it('keeps remote HTTPS and WSS scheme support without adding cleartext sources', () => { + const policy = rendererContentSecurityPolicy(false, [ + 'https://propr.example.test', + 'https://t-instance123.propr.dev', + ]); + assert.match(policy, /connect-src 'self' https: wss:/); + assert.doesNotMatch(policy, /(?:^|\s)http:(?:\s|;|$)/); + assert.doesNotMatch(policy, /(?:^|\s)ws:(?:\s|;|$)/); + }); + + it('reloads only the latest current renderer across replacement and overlapping policy changes', () => { + const scheduled: Array<() => void> = []; + const reloads: string[] = []; + const renderer = (name: string) => ({ + isDestroyed: () => false, + reload: () => { reloads.push(name); }, + }); + let currentRenderer = renderer('first'); + const reloadLatest = createLatestRendererReloader( + () => currentRenderer, + callback => { scheduled.push(callback); }, + ); + + reloadLatest(); + currentRenderer = renderer('replacement'); + reloadLatest(); + currentRenderer = renderer('current'); + scheduled[0](); + scheduled[1](); + + assert.deepEqual(reloads, ['current']); + }); + + it('relaxes inline scripts only while Vite serves the development renderer', () => { + const packagedPolicy = rendererContentSecurityPolicy(); + const source = ``; + const transformed = applyDevelopmentRendererCsp(source); + + assert.match(transformed, /script-src 'self' 'unsafe-inline'/); + assert.equal(applyDevelopmentRendererCsp(source).includes(rendererContentSecurityPolicy(true)), true); + assert.match(packagedPolicy, /script-src 'self'(?:;|$)/); + }); +}); diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts new file mode 100644 index 000000000..01bfba16c --- /dev/null +++ b/apps/desktop/src/security.ts @@ -0,0 +1,260 @@ +import { + canonicalProprHttpUrlOrigin, + isProprConnectReservedHostAttempt, + MAX_PROPR_API_BASE_URL_LENGTH, + parseProprConnectEndpoint, +} from '@propr/shared'; +import { DESKTOP_PROTOCOL } from './shared/contract'; +import { + isProprLoopbackHostname, +} from '@propr/shared'; + +const DEEP_LINK_ACTIONS = new Set(['connect', 'open']); +const DESKTOP_DASHBOARD_ORIGIN = 'https://desktop.propr.invalid'; +const RESERVED_DASHBOARD_PARAMETERS = new Set([ + 'flow', + 'logged_out', + 'oauth_complete', + 'redirect_to', + 'tunnel', +]); + +const parseUrl = (value: string): URL | null => { + try { + return new URL(value); + } catch { + return null; + } +}; + +const hasCredentials = (url: URL): boolean => Boolean(url.username || url.password); + +const isSafeDashboardPathForm = (value: string): boolean => { + if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false; + if (/[\u0000-\u001F\u007F\\]/.test(value)) return false; + const pathname = value.split(/[?#]/, 1)[0]; + return !pathname.split('/').some(segment => segment === '.' || segment === '..'); +}; + +const isSafeDecodedPathScope = (value: string): boolean => { + if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false; + if (/[\u0000-\u001F\u007F\\]/.test(value)) return false; + return !value.split('/').some(segment => segment === '.' || segment === '..'); +}; + +const isAllowedDashboardUrl = (url: URL): boolean => { + if (url.origin !== DESKTOP_DASHBOARD_ORIGIN) return false; + const route = url.pathname.toLowerCase().replace(/\/+$/, '') || '/'; + if (route === '/login' || route.startsWith('/login/') || route === '/desktop/pairing') return false; + return ![...url.searchParams.keys()].some(key => RESERVED_DASHBOARD_PARAMETERS.has(key.toLowerCase())); +}; + +const fullyDecodeDashboardPath = (value: string): URL | null => { + let decoded = value; + // Keep the original path scope while decoding so encoded delimiters cannot hide traversal in a later layer. + let decodedPathScope = value.split(/[?#]/, 1)[0]; + for (let remaining = value.length + 1; remaining > 0; remaining -= 1) { + if (!isSafeDashboardPathForm(decoded) || !isSafeDecodedPathScope(decodedPathScope)) return null; + let url: URL; + try { + url = new URL(decoded, DESKTOP_DASHBOARD_ORIGIN); + } catch { + return null; + } + if (!isAllowedDashboardUrl(url)) return null; + if (!decoded.includes('%')) return url; + if (/%(?![\da-f]{2})/i.test(decoded)) return null; + try { + const next = decodeURIComponent(decoded); + if (next === decoded) return url; + decoded = next; + decodedPathScope = decodeURIComponent(decodedPathScope); + } catch { + return null; + } + } + return null; +}; + +export const normalizeDesktopDashboardPath = (value: string): string | null => { + if (!value || value.length > 2_048) return null; + const url = fullyDecodeDashboardPath(value); + if (!url) return null; + return `${url.pathname}${url.search}${url.hash}`; +}; + +export const dashboardPathFromDeepLink = (value: string): string | null => { + if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null; + const url = parseUrl(value); + if ( + !url + || url.protocol !== `${DESKTOP_PROTOCOL}:` + || url.hostname !== 'open' + || hasCredentials(url) + || url.port + || url.hash + || (url.pathname !== '' && url.pathname !== '/') + ) return null; + const entries = [...url.searchParams.entries()]; + if (entries.length !== 1 || entries[0][0] !== 'path') return null; + return normalizeDesktopDashboardPath(entries[0][1]); +}; + +export const connectApiBaseUrlFromDeepLink = (value: string): string | null => { + if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null; + const url = parseUrl(value); + if ( + !url + || url.protocol !== `${DESKTOP_PROTOCOL}:` + || url.hostname !== 'connect' + || hasCredentials(url) + || url.port + || url.hash + || (url.pathname !== '' && url.pathname !== '/') + ) return null; + const entries = [...url.searchParams.entries()]; + if (entries.length !== 1 || entries[0][0] !== 'api') return null; + return normalizeApiBaseUrl(entries[0][1]); +}; + +export const normalizeApiBaseUrl = (value: string): string | null => { + if (value.length > MAX_PROPR_API_BASE_URL_LENGTH) return null; + const candidate = value.trim(); + const url = parseUrl(candidate); + if (!url || hasCredentials(url) || url.hash || url.search) return null; + if (url.pathname.replace(/\//g, '') !== '') return null; + if (isProprConnectReservedHostAttempt(value) && !parseProprConnectEndpoint(value)) return null; + return canonicalProprHttpUrlOrigin(candidate); +}; + +export const isSafeExternalUrl = (value: string): boolean => { + const url = parseUrl(value); + if (!url || hasCredentials(url)) return false; + return canonicalProprHttpUrlOrigin(value) === url.origin; +}; + +export const validatedDevServerUrl = (value: string | undefined): URL | null => { + if (!value) return null; + const url = parseUrl(value); + if (!url || url.protocol !== 'http:' || !isProprLoopbackHostname(url.hostname) || hasCredentials(url)) return null; + if (url.pathname !== '/' || url.search || url.hash) return null; + if (canonicalProprHttpUrlOrigin(value) !== url.origin) return null; + return url; +}; + +export const isTrustedRendererUrl = ( + candidate: string, + devServerUrl: string | undefined, + packagedRendererUrl: string, +): boolean => { + const candidateUrl = parseUrl(candidate); + if (!candidateUrl) return false; + const devUrl = validatedDevServerUrl(devServerUrl); + if (devUrl) { + return !hasCredentials(candidateUrl) + && canonicalProprHttpUrlOrigin(candidate) === candidateUrl.origin + && candidateUrl.origin === devUrl.origin; + } + const packagedUrl = parseUrl(packagedRendererUrl); + if (!packagedUrl || hasCredentials(candidateUrl) || candidateUrl.search) return false; + return candidateUrl.protocol === packagedUrl.protocol + && candidateUrl.host === packagedUrl.host + && candidateUrl.pathname === packagedUrl.pathname; +}; + +export const normalizeDeepLink = (value: string): string | null => { + if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null; + const url = parseUrl(value); + if (!url || url.protocol !== `${DESKTOP_PROTOCOL}:` || hasCredentials(url)) return null; + if (!DEEP_LINK_ACTIONS.has(url.hostname) || url.port || url.hash) return null; + const dashboardPath = url.hostname === 'open' ? dashboardPathFromDeepLink(value) : null; + if (url.hostname === 'open' && dashboardPath === null) return null; + const connectApiBaseUrl = url.hostname === 'connect' ? connectApiBaseUrlFromDeepLink(value) : null; + if (url.hostname === 'connect' && connectApiBaseUrl === null) return null; + + const canonicalCandidate = url.href; + if (canonicalCandidate.length > 2_048 || /[\u0000-\u001F\u007F]/.test(canonicalCandidate)) return null; + if ( + url.hostname === 'open' + && dashboardPathFromDeepLink(canonicalCandidate) !== dashboardPath + ) return null; + if ( + url.hostname === 'connect' + && connectApiBaseUrlFromDeepLink(canonicalCandidate) !== connectApiBaseUrl + ) return null; + return canonicalCandidate; +}; + +export const deepLinkFromArguments = (argv: readonly string[]): string | null => { + for (const argument of argv) { + const normalized = normalizeDeepLink(argument); + if (normalized) return normalized; + } + return null; +}; + +const rendererConnectSources = ( + development: boolean, + apiBaseUrls: readonly string[], +): string => { + const sources = new Set(["'self'", 'https:', 'wss:']); + if (development) { + sources.add('http:'); + sources.add('ws:'); + } else { + for (const candidate of apiBaseUrls) { + const origin = normalizeApiBaseUrl(candidate); + if (!origin || !origin.startsWith('http://')) continue; + sources.add(origin); + sources.add(`ws://${origin.slice('http://'.length)}`); + } + } + return [...sources].join(' '); +}; + +export const rendererContentSecurityPolicy = ( + development = false, + apiBaseUrls: readonly string[] = [], +): string => [ + "default-src 'self'", + `script-src 'self'${development ? " 'unsafe-inline'" : ''}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + // HTTPS/WSS support remote instances and ProPR Connect. Cleartext sources + // are exact, main-validated active profile origins because CSP has no IPv4 + // CIDR syntax with which to express the normalizer's complete 127/8 range. + `connect-src ${rendererConnectSources(development, apiBaseUrls)}`, + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-src 'none'", +].join('; '); + +interface ReloadableRenderer { + isDestroyed(): boolean; + reload(): void; +} + +export const createLatestRendererReloader = ( + getCurrentRenderer: () => ReloadableRenderer | null, + schedule: (callback: () => void) => void = callback => { setTimeout(callback, 0); }, +): (() => void) => { + let generation = 0; + return () => { + const scheduledGeneration = ++generation; + schedule(() => { + if (generation !== scheduledGeneration) return; + const renderer = getCurrentRenderer(); + if (renderer && !renderer.isDestroyed()) renderer.reload(); + }); + }; +}; + +export const applyDevelopmentRendererCsp = (html: string): string => { + const packagedPolicy = rendererContentSecurityPolicy(); + if (!html.includes(packagedPolicy)) { + throw new Error('renderer.html is missing the packaged content security policy'); + } + return html.replace(packagedPolicy, rendererContentSecurityPolicy(true)); +}; diff --git a/apps/desktop/src/session-security.test.ts b/apps/desktop/src/session-security.test.ts new file mode 100644 index 000000000..efbdc9d74 --- /dev/null +++ b/apps/desktop/src/session-security.test.ts @@ -0,0 +1,349 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import type { Session, WebContents, WebFrameMain } from 'electron'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { DesktopCredentialService } from './credential-service'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { + configureDesktopSessionSecurity, + desktopNetworkPermissionAllowed, + type DesktopNetworkPermissionEvidence, + type DesktopRendererOwnershipEvidence, +} from './session-security'; + +const RENDERER_URL = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; +const ACTIVE_ORIGIN = 'http://127.0.0.2:41731'; +const TOKEN = `propr_it_${'T'.repeat(43)}`; +const IDENTITY = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; + +const discovery = { + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: IDENTITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +describe('production desktop session security', () => { + it('denies every permission except active trusted-main-frame local network access', () => { + const accepted = { + activeBindingCurrent: true, + decision: 'check' as const, + isMainFrame: true, + mainWindowPresent: true, + permission: 'loopback-network', + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityEqual: true, + requestingOriginAuthorityValid: true, + requestingUrlAuthorityEqual: true, + requestingUrlPresent: false, + requestingUrlTrusted: false, + webContentsEqualsMainWindow: false, + webContentsPresent: false, + }; + assert.equal(desktopNetworkPermissionAllowed(accepted), true); + for (const rejected of [ + { activeBindingCurrent: false }, + { isMainFrame: false }, + { mainWindowPresent: false }, + { permission: 'notifications' }, + { rendererDocumentUrlTrusted: false }, + { requestingOriginAuthorityEqual: false }, + { requestingOriginAuthorityValid: false }, + { webContentsPresent: true }, + ]) { + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, ...rejected }), false); + } + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network' }), true); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network-access' }), true); + assert.equal(desktopNetworkPermissionAllowed({ + ...accepted, + decision: 'request', + requestingUrlPresent: true, + requestingUrlTrusted: true, + webContentsEqualsMainWindow: true, + webContentsPresent: true, + }), true); + }); + + it('pins permission and concrete credential transport to the live main renderer and current origin', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-session-security-')); + const store = new ProfileStore(directory, encryption); + let connectClaimCurrent = true; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Session security test', + openPairingBrowser: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? new Response(JSON.stringify(discovery), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ username: 'octocat' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + snapshotConnectIdentityClaim: () => ({ + status: 'unclaimed', + isCurrent: () => connectClaimCurrent, + beginCommit: () => () => undefined, + }), + }); + try { + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: ACTIVE_ORIGIN }); + await store.writeCredential({ + version: 2, + profileId: profile.id, + origin: ACTIVE_ORIGIN, + publicInstanceIdentity: IDENTITY, + token: TOKEN, + }); + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + + type PermissionCheck = ( + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => boolean; + type PermissionRequest = ( + webContents: WebContents, + permission: string, + callback: (allowed: boolean) => void, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => void; + type BeforeSendHeaders = ( + details: { + url: string; + method: string; + resourceType: string; + requestHeaders: Record; + webContentsId: number; + webContents?: WebContents; + frame?: WebFrameMain | null; + }, + callback: (decision: Record) => void, + ) => void; + let permissionCheck: PermissionCheck = () => false; + let permissionRequest: PermissionRequest = () => undefined; + let beforeSendHeaders: BeforeSendHeaders = () => undefined; + const evidence: DesktopNetworkPermissionEvidence[] = []; + const ownershipEvidence: DesktopRendererOwnershipEvidence[] = []; + const desktopSession = { + setPermissionCheckHandler: (handler: PermissionCheck | null) => { + if (handler) permissionCheck = handler; + }, + setPermissionRequestHandler: (handler: PermissionRequest | null) => { + if (handler) permissionRequest = handler; + }, + webRequest: { + onBeforeSendHeaders: (handler: BeforeSendHeaders | null) => { + if (handler) beforeSendHeaders = handler; + }, + onHeadersReceived: () => undefined, + }, + } as unknown as Session; + let destroyed = false; + let rendererUrl = RENDERER_URL; + const mainFrame = { + detached: false, + parent: null, + url: RENDERER_URL, + } as unknown as WebFrameMain; + const mainRenderer = { + id: 41, + getURL: () => rendererUrl, + isDestroyed: () => destroyed, + mainFrame, + } as unknown as WebContents; + const foreignRenderer = { + id: 42, + getURL: () => RENDERER_URL, + isDestroyed: () => false, + } as unknown as WebContents; + configureDesktopSessionSecurity({ + contentSecurityPolicy: () => "default-src 'self'", + credentials: service, + desktopSession, + getMainRenderer: () => mainRenderer, + isTrustedRendererUrl: value => value === RENDERER_URL, + reportNetworkPermissionDecision: record => evidence.push(record), + reportRendererOwnershipDecision: record => ownershipEvidence.push(record), + }); + + const check = ( + webContents: WebContents | null = null, + origin = DESKTOP_RENDERER_ORIGIN, + details: { requestingUrl?: string; isMainFrame: boolean } = { isMainFrame: true }, + ) => permissionCheck(webContents, 'loopback-network', origin, details); + assert.equal(check(), false); + const activated = await service.activate(ready.activationTicket); + assert.equal(check(), true); + assert.equal(check(foreignRenderer, DESKTOP_RENDERER_ORIGIN, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }), false); + assert.equal(check(null, 'https://attacker.example.test'), false); + assert.equal(check(null, DESKTOP_RENDERER_ORIGIN, { isMainFrame: false }), false); + rendererUrl = 'https://attacker.example.test/renderer.html'; + assert.equal(check(), false); + rendererUrl = RENDERER_URL; + destroyed = true; + assert.equal(check(), false); + destroyed = false; + + let requested = false; + permissionRequest(mainRenderer, 'local-network-access', value => { requested = value; }, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }); + assert.equal(requested, true); + permissionRequest(foreignRenderer, 'local-network-access', value => { requested = value; }, { + requestingUrl: RENDERER_URL, + isMainFrame: true, + }); + assert.equal(requested, false); + + const intercepted = async ( + url: string, + headers: Record, + webContentsId = mainRenderer.id, + resourceType = 'xhr', + frame: WebFrameMain | null = mainFrame, + omitFrame = false, + ) => await new Promise>(resolve => beforeSendHeaders({ + url, + method: 'GET', + resourceType, + requestHeaders: headers, + webContentsId, + ...(!omitFrame ? { frame } : {}), + }, resolve)); + const scopeHeaders = { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope, + }; + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { + requestHeaders: { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: `Bearer ${TOKEN}`, + }, + }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', mainFrame, true, + ), { cancel: true }); + assert.equal(ownershipEvidence.at(-1)?.frameOmitted, true); + assert.equal(ownershipEvidence.at(-1)?.rendererOwned, false); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'other', mainFrame, true, + ), { cancel: true }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', null, + ), { cancel: true }); + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`, + scopeHeaders, + mainRenderer.id, + 'mainFrame', + ), { cancel: true }); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, foreignRenderer.id), { + cancel: true, + }); + const childFrame = { + detached: false, + parent: mainFrame, + url: RENDERER_URL, + } as unknown as WebFrameMain; + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', childFrame, + ), { cancel: true }); + const foreignDocument = { + detached: false, + parent: null, + url: 'https://attacker.example.test/renderer.html', + } as unknown as WebFrameMain; + assert.deepEqual(await intercepted( + `${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders, mainRenderer.id, 'xhr', foreignDocument, + ), { cancel: true }); + for (const target of [ + 'http://127.0.0.1:41731/api/side-effect', + 'http://127.0.0.3:41731/api/side-effect', + 'https://192.168.1.10/api/side-effect', + ]) { + assert.deepEqual(await intercepted(target, { + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + }), { cancel: true }, target); + } + connectClaimCurrent = false; + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true }); + connectClaimCurrent = true; + assert.equal(check(), true); + destroyed = true; + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/auth/user`, scopeHeaders), { cancel: true }); + destroyed = false; + + assert.deepEqual(await service.discardActivation(activated), { discarded: true }); + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, { + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=must-not-cross', + }), { cancel: true }); + + const revokedReady = await service.probe(profile); + assert.equal(revokedReady.status, 'ready'); + if (revokedReady.status !== 'ready') return; + const revoked = await service.activate(revokedReady.activationTicket); + assert.deepEqual(await service.invalidate({ + profileId: profile.id, + transportScope: revoked.transportScope, + code: 'INSTANCE_TOKEN_REVOKED', + }), { invalidated: true }); + assert.equal(check(), false); + assert.deepEqual(await intercepted(`${ACTIVE_ORIGIN}/api/side-effect`, {}), { cancel: true }); + + assert.ok(evidence.length >= 10); + assert.doesNotMatch( + JSON.stringify(evidence), + /attacker|renderer\.html|127\.0\.0\.2|192\.168|propr_it_/u, + ); + assert.ok(evidence.every(record => Object.keys(record).sort().join(',') === [ + 'activeBindingCurrent', 'allowed', 'decision', 'isMainFrame', 'mainWindowPresent', + 'permissionCategory', 'rendererDocumentUrlTrusted', 'requestingOriginAuthorityEqual', + 'requestingOriginAuthorityValid', 'requestingUrlPresent', 'requestingUrlTrusted', + 'schemaVersion', 'webContentsEqualsMainWindow', 'webContentsPresent', + ].sort().join(','))); + } finally { + await service.dispose(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/session-security.ts b/apps/desktop/src/session-security.ts new file mode 100644 index 000000000..a13e6a0e6 --- /dev/null +++ b/apps/desktop/src/session-security.ts @@ -0,0 +1,292 @@ +import type { Session, WebContents } from 'electron'; +import type { DesktopCredentialService } from './credential-service'; + +const DESKTOP_NETWORK_PERMISSIONS = new Set([ + // Chromium split the original permission into address-space-specific + // permissions. Keep the original spelling for older supported runtimes. + 'local-network-access', + 'local-network', + 'loopback-network', +]); + +export type DesktopNetworkPermissionCategory = + | 'local-network-access' + | 'local-network' + | 'loopback-network'; + +export interface DesktopNetworkPermissionEvidence { + schemaVersion: 1; + permissionCategory: DesktopNetworkPermissionCategory; + decision: 'check' | 'request'; + allowed: boolean; + activeBindingCurrent: boolean; + webContentsPresent: boolean; + webContentsEqualsMainWindow: boolean; + mainWindowPresent: boolean; + isMainFrame: boolean; + requestingUrlPresent: boolean; + requestingUrlTrusted: boolean; + rendererDocumentUrlTrusted: boolean; + requestingOriginAuthorityValid: boolean; + requestingOriginAuthorityEqual: boolean; +} + +export interface DesktopRendererOwnershipEvidence { + schemaVersion: 1; + resourceCategory: 'xhr' | 'webSocket' | 'other'; + mainRendererPresent: boolean; + mainRendererLive: boolean; + webContentsIdMatches: boolean; + webContentsAbsentOrMatches: boolean; + mainFrameLive: boolean; + rendererDocumentTrusted: boolean; + rendererDocumentAuthorityEqual: boolean; + frameOmitted: boolean; + framePresent: boolean; + frameMatchesMainFrame: boolean; + frameExplicitlyForeign: boolean; + rendererOwned: boolean; +} + +const rendererAuthority = (value: string): string | null => { + try { + const url = new URL(value); + if (!url.protocol || !url.hostname || url.username || url.password) return null; + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +}; + +export interface DesktopNetworkPermissionContext extends Omit { + permission: string; + requestingUrlAuthorityEqual: boolean; +} + +/** Local Network Access is available only to the live trusted main frame with a current binding. */ +export const desktopNetworkPermissionAllowed = ({ + activeBindingCurrent, + decision, + isMainFrame, + mainWindowPresent, + permission, + rendererDocumentUrlTrusted, + requestingOriginAuthorityEqual, + requestingOriginAuthorityValid, + requestingUrlAuthorityEqual, + requestingUrlPresent, + requestingUrlTrusted, + webContentsEqualsMainWindow, + webContentsPresent, +}: DesktopNetworkPermissionContext): boolean => DESKTOP_NETWORK_PERMISSIONS.has(permission) + && activeBindingCurrent + && mainWindowPresent + && isMainFrame + && rendererDocumentUrlTrusted + && (!requestingUrlPresent || (requestingUrlTrusted && requestingUrlAuthorityEqual)) + && requestingOriginAuthorityValid + && requestingOriginAuthorityEqual + && (decision === 'check' + ? !webContentsPresent || webContentsEqualsMainWindow + : webContentsPresent && webContentsEqualsMainWindow && requestingUrlPresent); + +interface ConfigureDesktopSessionSecurityOptions { + contentSecurityPolicy(): string; + credentials: DesktopCredentialService; + desktopSession: Session; + enableRendererNetworkBoundary?: boolean; + getMainRenderer(): WebContents | null; + isTrustedRendererUrl(value: string): boolean; + reportNetworkPermissionDecision?(evidence: DesktopNetworkPermissionEvidence): void; + reportRendererOwnershipDecision?(evidence: DesktopRendererOwnershipEvidence): void; +} + +/** Install the production permission, concrete-request, and response boundary on one session. */ +export const configureDesktopSessionSecurity = ({ + contentSecurityPolicy, + credentials, + desktopSession, + enableRendererNetworkBoundary = true, + getMainRenderer, + isTrustedRendererUrl, + reportNetworkPermissionDecision = () => undefined, + reportRendererOwnershipDecision = () => undefined, +}: ConfigureDesktopSessionSecurityOptions): { + close(): void; + dispose(): void; +} => { + const allowNetworkPermission = ( + decision: 'check' | 'request', + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + isMainFrame: boolean, + requestingUrl?: string, + ): boolean => { + const candidate = getMainRenderer(); + const mainRenderer = candidate !== null && !candidate.isDestroyed() ? candidate : null; + const rendererDocumentUrl = mainRenderer?.getURL() ?? ''; + const rendererDocumentAuthority = rendererAuthority(rendererDocumentUrl); + const requestingUrlPresent = typeof requestingUrl === 'string' && requestingUrl.length > 0; + const requestingUrlAuthority = requestingUrlPresent ? rendererAuthority(requestingUrl) : null; + const requestingOriginAuthority = rendererAuthority(requestingOrigin); + const context: DesktopNetworkPermissionContext = { + activeBindingCurrent: credentials.hasActiveRendererBinding(), + decision, + isMainFrame: isMainFrame === true, + mainWindowPresent: mainRenderer !== null, + permission, + rendererDocumentUrlTrusted: mainRenderer !== null && isTrustedRendererUrl(rendererDocumentUrl), + requestingOriginAuthorityEqual: rendererDocumentAuthority !== null + && requestingOriginAuthority === rendererDocumentAuthority, + requestingOriginAuthorityValid: requestingOriginAuthority !== null + && requestingOrigin === requestingOriginAuthority, + requestingUrlAuthorityEqual: !requestingUrlPresent || (rendererDocumentAuthority !== null + && requestingUrlAuthority === rendererDocumentAuthority), + requestingUrlPresent, + requestingUrlTrusted: requestingUrlPresent && isTrustedRendererUrl(requestingUrl), + webContentsEqualsMainWindow: webContents !== null && webContents === mainRenderer, + webContentsPresent: webContents !== null, + }; + const allowed = desktopNetworkPermissionAllowed(context); + if (DESKTOP_NETWORK_PERMISSIONS.has(permission)) { + try { + reportNetworkPermissionDecision({ + schemaVersion: 1, + permissionCategory: permission as DesktopNetworkPermissionCategory, + decision, + allowed, + activeBindingCurrent: context.activeBindingCurrent, + webContentsPresent: context.webContentsPresent, + webContentsEqualsMainWindow: context.webContentsEqualsMainWindow, + mainWindowPresent: context.mainWindowPresent, + isMainFrame: context.isMainFrame, + requestingUrlPresent: context.requestingUrlPresent, + requestingUrlTrusted: context.requestingUrlTrusted, + rendererDocumentUrlTrusted: context.rendererDocumentUrlTrusted, + requestingOriginAuthorityValid: context.requestingOriginAuthorityValid, + requestingOriginAuthorityEqual: context.requestingOriginAuthorityEqual, + }); + } catch { + // Fixed diagnostics cannot alter the permission decision. + } + } + return allowed; + }; + + if (enableRendererNetworkBoundary) { + desktopSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => + allowNetworkPermission( + 'check', + webContents, + String(permission), + requestingOrigin, + details.isMainFrame, + details.requestingUrl, + )); + desktopSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = 'requestingUrl' in details && typeof details.requestingUrl === 'string' + ? details.requestingUrl + : undefined; + callback(allowNetworkPermission( + 'request', + webContents, + String(permission), + requestingUrl ? rendererAuthority(requestingUrl) ?? '' : '', + details.isMainFrame, + requestingUrl, + )); + }); + } else { + desktopSession.setPermissionCheckHandler(() => false); + desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + } + desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { + const mainRenderer = getMainRenderer(); + const requestingFrame = details.frame; + const mainFrame = mainRenderer?.mainFrame; + const mainRendererLive = mainRenderer !== null && !mainRenderer.isDestroyed(); + const mainFrameLive = mainRendererLive + && mainFrame !== undefined + && mainFrame !== null + && !mainFrame.detached + && mainFrame.parent === null; + const rendererDocumentUrl = mainRendererLive ? mainRenderer.getURL() : ''; + const mainFrameUrl = mainFrameLive ? mainFrame.url : ''; + const rendererDocumentTrusted = mainFrameLive + && isTrustedRendererUrl(rendererDocumentUrl) + && isTrustedRendererUrl(mainFrameUrl); + const rendererDocumentAuthorityEqual = rendererDocumentTrusted + && rendererAuthority(rendererDocumentUrl) !== null + && rendererAuthority(rendererDocumentUrl) === rendererAuthority(mainFrameUrl) + && rendererDocumentUrl === mainFrameUrl; + const webContentsIdMatches = mainRendererLive && details.webContentsId === mainRenderer.id; + const webContentsAbsentOrMatches = mainRendererLive + && (details.webContents === undefined || details.webContents === mainRenderer); + const frameOmitted = requestingFrame === undefined; + const framePresent = requestingFrame !== undefined && requestingFrame !== null; + const frameMatchesMainFrame = framePresent + && mainFrame !== undefined + && mainFrame !== null + && requestingFrame === mainFrame + && !requestingFrame.detached + && isTrustedRendererUrl(requestingFrame.url); + const resourceCategory = details.resourceType === 'xhr' + ? 'xhr' + : details.resourceType === 'webSocket' + ? 'webSocket' + : 'other'; + const rendererOwned = mainRendererLive + && webContentsIdMatches + && webContentsAbsentOrMatches + && frameMatchesMainFrame; + if (details.webContentsId !== undefined) { + try { + reportRendererOwnershipDecision({ + schemaVersion: 1, + resourceCategory, + mainRendererPresent: mainRenderer !== null, + mainRendererLive, + webContentsIdMatches, + webContentsAbsentOrMatches, + mainFrameLive, + rendererDocumentTrusted, + rendererDocumentAuthorityEqual, + frameOmitted, + framePresent, + frameMatchesMainFrame, + frameExplicitlyForeign: framePresent && !frameMatchesMainFrame, + rendererOwned, + }); + } catch { + // Fixed diagnostics cannot alter the renderer ownership decision. + } + } + void credentials.prepareRequestAsync(details.url, details.requestHeaders, { + method: details.method, + ...(enableRendererNetworkBoundary ? { rendererOwned } : {}), + resourceType: details.resourceType, + }).then(callback, () => callback({ cancel: true })); + }); + desktopSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}), + 'Content-Security-Policy': [contentSecurityPolicy()], + }, + }); + }); + return { + close() { + desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true })); + desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true })); + }, + dispose() { + desktopSession.setPermissionCheckHandler(null); + desktopSession.setPermissionRequestHandler(null); + desktopSession.webRequest.onBeforeSendHeaders(null); + desktopSession.webRequest.onHeadersReceived(null); + }, + }; +}; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts new file mode 100644 index 000000000..2fe16b87e --- /dev/null +++ b/apps/desktop/src/shared/contract.ts @@ -0,0 +1,158 @@ +export const DESKTOP_PROTOCOL = 'propr'; + +export const IPC_CHANNELS = Object.freeze({ + appMetadata: 'desktop:app-metadata', + authLogout: 'desktop:auth-logout', + openExternal: 'desktop:open-external', + storageSecurity: 'desktop:storage-security', + profilesList: 'desktop:profiles-list', + profilesSave: 'desktop:profiles-save', + profilesRemove: 'desktop:profiles-remove', + profilesSetActive: 'desktop:profiles-set-active', + authenticationPair: 'desktop:authentication-pair', + authenticationCancel: 'desktop:authentication-cancel', + connectionProbe: 'desktop:connection-probe', + connectionActivate: 'desktop:connection-activate', + connectionDiscard: 'desktop:connection-discard', + connectionInvalidate: 'desktop:connection-invalidate', + connectDiscover: 'desktop:connect-discover', + connectRediscover: 'desktop:connect-rediscover', + lifecycleStatus: 'desktop:lifecycle-status', + lifecycleStart: 'desktop:lifecycle-start', + lifecycleStop: 'desktop:lifecycle-stop', + lifecycleRestart: 'desktop:lifecycle-restart', + deepLink: 'desktop:deep-link', + acceptanceJourneyStage: 'desktop:acceptance-journey-stage', +} as const); + +export type DesktopAcceptanceJourneyStage = + | 'AUTHENTICATION_REQUIRED' + | 'CREDENTIAL_COMMITTED' + | 'AUTHENTICATED_REPROBE_READY' + | 'ACTIVATION_COMMITTED' + | 'ACTIVATION_PUBLISHED' + | 'REACT_CONNECTED'; + +export type DesktopPlatform = 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' + | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'; + +export interface DesktopAppMetadata { + name: string; + version: string; + platform: DesktopPlatform; + arch: string; + packaged: boolean; +} + +export interface DesktopProfile { + id: string; + label: string; + apiBaseUrl: string; + createdAt: string; + updatedAt: string; +} + +export interface DesktopProfileInput { + id?: string; + label: string; + apiBaseUrl: string; +} + +/** Secret-free candidate projected by the trusted main-process discovery service. */ +export interface DesktopDiscoveryCandidate { + id: string; + label: string; + apiBaseUrl: string; +} + +export interface DesktopProfileList { + profiles: DesktopProfile[]; + activeProfileId: string | null; +} + +export type StorageSecurity = { + available: true; + backend: string; +} | { + available: false; + backend: string; + reason: 'os-encryption-unavailable' | 'insecure-basic-text-backend'; +}; + +export type DesktopConnectionResult = + | { status: 'ready'; version?: string; authentication?: string; activationTicket: string } + | { status: 'authentication-required'; message?: string; version?: string; authentication?: string } + | { status: 'incompatible'; message: string; version?: string } + | { status: 'offline'; message: string }; + +export interface DesktopConnectionScope { + profileId: string; + transportScope: string; +} + +export interface DesktopActivatedConnection extends DesktopConnectionScope { + status: 'ready'; + identityEpoch: string; +} + +export interface DesktopAccessInvalidation extends DesktopConnectionScope { + code: string; +} + +export type LocalLifecycleState = 'disconnected' | 'starting' | 'connected' | 'stopping' | 'error'; + +export interface LocalLifecycleStatus { + state: LocalLifecycleState; + detail?: string; +} + +export type LocalLifecycleOperationResult = + | { ok: true; status: LocalLifecycleStatus } + | { ok: false; code: 'not-implemented'; status: LocalLifecycleStatus }; + +export interface DesktopBridge { + app: { + getMetadata(): Promise; + onDeepLink(listener: (url: string) => void): () => void; + }; + auth: { + logout(apiBaseUrl: string): Promise; + }; + external: { + open(url: string): Promise; + }; + storage: { + security(): Promise; + }; + profiles: { + list(): Promise; + save(profile: DesktopProfileInput): Promise; + remove(profileId: string): Promise; + setActive(profileId: string | null): Promise; + }; + authentication: { + pair(profile: DesktopProfileInput): Promise<{ paired: true }>; + cancel(profileId: string): Promise; + }; + connection: { + probe(profile: DesktopProfileInput): Promise; + activate(activationTicket: string): Promise; + discard(value: DesktopConnectionScope): Promise<{ discarded: boolean }>; + invalidate(value: DesktopAccessInvalidation): Promise<{ invalidated: boolean }>; + }; + discovery: { + supported: boolean; + discover(): Promise; + rediscover(profileId: string): Promise; + }; + lifecycle: { + status(): Promise; + start(): Promise; + stop(): Promise; + restart(): Promise; + }; + /** @internal Present only in an authorized packaged Connect acceptance process. */ + acceptance?: { + reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise; + }; +} diff --git a/apps/desktop/src/shutdown.ts b/apps/desktop/src/shutdown.ts new file mode 100644 index 000000000..7ca53e081 --- /dev/null +++ b/apps/desktop/src/shutdown.ts @@ -0,0 +1,117 @@ +import type { RegisteredIpcHandlers } from './ipc'; + +interface ShutdownEvent { + preventDefault(): void; +} + +interface DestructibleWindow { + isDestroyed(): boolean; + destroy(): void; +} + +interface ShutdownOptions { + credentials: { dispose(): Promise }; + lifecycle: { shutdown(): Promise }; + ipc: RegisteredIpcHandlers; + profiles: { close(): Promise }; + sessionSecurity: { close(): void; dispose(): void }; + disposeRendererProtocol(): void; + getWindow(): DestructibleWindow | null; + quit(): void; + onStarted(): void; + log(level: 'info' | 'error', event: string, fields?: Record): void; +} + +interface ShutdownCoordinatorOptions { + drainTimeoutMs?: number; +} + +export interface DesktopShutdownCoordinator { + beforeQuit(event: ShutdownEvent): void; + readonly started: boolean; + awaitFinished(): Promise; +} + +/** + * The single production shutdown order used by Electron and lifecycle tests. + * Admission closes synchronously; admitted service/IPC work drains before the + * profile store, session hooks, handlers, and renderer window are destroyed. + */ +export const createDesktopShutdownCoordinator = ( + options: ShutdownOptions, + coordinatorOptions: ShutdownCoordinatorOptions = {}, +): DesktopShutdownCoordinator => { + let state: 'idle' | 'draining' | 'allow-final-quit' | 'finished' = 'idle'; + let completion: Promise | null = null; + const drainTimeoutMs = coordinatorOptions.drainTimeoutMs ?? 15_000; + const step = (name: string): void => options.log('info', 'desktop.app.shutdown_step', { step: name }); + const bounded = async (promise: Promise, phase: string): Promise => { + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + promise.then(() => false, error => { + options.log('error', 'desktop.app.shutdown_failed', { phase, error }); + return false; + }), + new Promise(resolve => { + timer = setTimeout(() => resolve(true), drainTimeoutMs); + }), + ]); + if (timer) clearTimeout(timer); + if (timedOut) options.log('error', 'desktop.app.shutdown_forced', { phase, drainTimeoutMs }); + }; + + return { + beforeQuit(event) { + if (state === 'allow-final-quit') { + state = 'finished'; + return; + } + event.preventDefault(); + if (state !== 'idle') { + if (state === 'draining') options.log('info', 'desktop.app.shutdown_retry'); + return; + } + state = 'draining'; + options.onStarted(); + step('admission-closed'); + options.ipc.close(); + step('ipc-closed'); + options.sessionSecurity.close(); + step('session-closed'); + options.disposeRendererProtocol(); + step('protocol-disposed'); + step('credentials-dispose-started'); + const credentialDrain = options.credentials.dispose(); + step('authentication-cleared'); + const lifecycleDrain = options.lifecycle.shutdown(); + step('lifecycle-drain-started'); + const ipcDrain = options.ipc.awaitIdle(); + step('ipc-drain-started'); + completion = bounded(Promise.allSettled([ + credentialDrain, + lifecycleDrain, + ipcDrain, + ]).then(results => { + for (const result of results) if (result.status === 'rejected') throw result.reason; + }), 'service-drain').then(async () => { + step('service-drain-finished'); + step('profiles-close-started'); + await bounded(options.profiles.close(), 'profile-store'); + step('profiles-close-finished'); + options.sessionSecurity.dispose(); + step('session-disposed'); + options.ipc.dispose(); + step('ipc-disposed'); + const window = options.getWindow(); + if (window && !window.isDestroyed()) window.destroy(); + step('window-destroyed'); + options.log('info', 'desktop.app.shutdown'); + state = 'allow-final-quit'; + step('final-quit'); + options.quit(); + }); + }, + get started() { return state !== 'idle'; }, + awaitFinished() { return completion ?? Promise.resolve(); }, + }; +}; diff --git a/apps/desktop/src/signed-update-policy.test.ts b/apps/desktop/src/signed-update-policy.test.ts new file mode 100644 index 000000000..3417d7b5d --- /dev/null +++ b/apps/desktop/src/signed-update-policy.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { test } from 'node:test'; +import { + applySignedUpdate, + checkForSignedUpdates, + parseSignedUpdateManifest, + type SignedUpdateManifest, +} from './signed-updates'; + +const keys = generateKeyPairSync('ed25519'); +const publicKey = keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); +const config = { + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + publicKey, + signingIdentity: 'TEAM123456', + windowsSignerPins: [], +}; + +test('Windows signed-update public boundary is fixed unsupported with zero external or apply calls', async () => { + const calls = { request: 0, signer: 0, authority: 0, install: 0 }; + const common = { + config: { ...config, signingIdentity: 'CN=Configured Windows Publisher' }, + currentVersion: '1.2.3', + platform: 'win32' as const, + arch: 'x64', + cacheDirectory: 'configured-but-never-touched', + request: async (): Promise => { + calls.request += 1; + throw new Error('Windows must not request metadata or artifacts'); + }, + verifyNativeSigner: async () => { + calls.signer += 1; + throw new Error('Windows must not inspect an update artifact'); + }, + applyHeldArtifact: async () => { + calls.authority += 1; + throw new Error('Windows must not invoke windows-update-authority'); + }, + }; + + assert.equal(await checkForSignedUpdates(common), 'unsupported'); + assert.equal(await applySignedUpdate({ + ...common, + installVerifiedArtifact: async () => { calls.install += 1; }, + }), 'unsupported'); + assert.deepEqual(calls, { request: 0, signer: 0, authority: 0, install: 0 }); +}); + +test('signed macOS feeds accept only the canonical ZIP extension and matching artifact URL', () => { + const fileName = 'ProPR-Desktop-1.2.4-macos-x64.zip'; + const artifactUrl = `https://updates.example.test/darwin/x64/${fileName}`; + const manifest = { + schemaVersion: 2, + channel: 'stable', + manifestUrl: config.manifestUrl, + windowsSignerPins: [], + version: '1.2.4', + tag: 'desktop-v1.2.4', + publishedAt: '2026-08-30T00:00:00.000Z', + feeds: { + 'darwin-x64': { + target: 'darwin-x64', + version: '1.2.4', + feed: { url: 'https://updates.example.test/darwin/x64/RELEASES.json', size: 100, sha256: '1'.repeat(64) }, + artifact: { url: artifactUrl, fileName, kind: 'zip', size: 200, sha256: '2'.repeat(64) }, + signer: { + type: 'apple-team-id', + identity: 'TEAM123456', + designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + }, + }, + }, + }; + assert.equal( + parseSignedUpdateManifest(Buffer.from(JSON.stringify(manifest))).feeds['darwin-x64'].artifact.fileName, + fileName, + ); + + const invalidNames = [ + 'ProPR-Desktop-1.2.4-macos-x64-zip', + 'ProPR-Desktop-1.2.4-macos-x64.zip.zip', + 'ProPR-Desktop-1.2.4-macos-x64.ZIP', + 'ProPR-Desktop-1.2.4-macos-x64.dmg', + 'ProPR-Desktop-1.2.3-macos-x64.zip', + 'ProPR-Desktop-1.2.4-macos-arm64.zip', + ]; + for (const invalidName of invalidNames) { + const candidate = structuredClone(manifest); + candidate.feeds['darwin-x64'].artifact.fileName = invalidName; + candidate.feeds['darwin-x64'].artifact.url = `https://updates.example.test/darwin/x64/${invalidName}`; + assert.throws( + () => parseSignedUpdateManifest(Buffer.from(JSON.stringify(candidate))), + /artifact does not match its target or URL/, + invalidName, + ); + } + const wrongKind = structuredClone(manifest); + wrongKind.feeds['darwin-x64'].artifact.kind = 'msi'; + assert.throws( + () => parseSignedUpdateManifest(Buffer.from(JSON.stringify(wrongKind))), + /artifact does not match its target or URL/, + ); +}); + +test('macOS signed-update check remains check-only and verifies its exact feed and artifact', { + skip: process.platform !== 'darwin', +}, async () => { + assert.equal(process.platform, 'darwin', 'the native macOS update filesystem adapter must run on Darwin'); + const artifact = Buffer.from('signed macOS application ZIP'); + const artifactUrl = 'https://updates.example.test/darwin/x64/ProPR-Desktop-1.2.4-macos-x64.zip'; + const feed = Buffer.from(`${JSON.stringify({ url: artifactUrl, name: '1.2.4' })}\n`); + const bytes = (url: string, value: Buffer) => ({ + url, + size: value.length, + sha256: createHash('sha256').update(value).digest('hex'), + }); + const manifest: SignedUpdateManifest = { + schemaVersion: 2, + channel: 'stable', + manifestUrl: config.manifestUrl, + windowsSignerPins: [], + version: '1.2.4', + tag: 'desktop-v1.2.4', + publishedAt: '2026-08-30T00:00:00.000Z', + feeds: { + 'darwin-x64': { + target: 'darwin-x64', + version: '1.2.4', + feed: bytes('https://updates.example.test/darwin/x64/RELEASES.json', feed), + artifact: { ...bytes(artifactUrl, artifact), fileName: 'ProPR-Desktop-1.2.4-macos-x64.zip', kind: 'zip' }, + signer: { + type: 'apple-team-id', + identity: 'TEAM123456', + designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + }, + }, + }, + }; + const payload = Buffer.from(`${JSON.stringify(manifest)}\n`); + const signature = Buffer.from(sign(null, payload, keys.privateKey).toString('base64')); + let artifactRequests = 0; + let installs = 0; + const response = (url: string, value: Buffer) => { + const result = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from(value)); + controller.close(); + }, + }), { headers: { 'content-length': String(value.length) } }); + Object.defineProperty(result, 'url', { value: url }); + return result; + }; + const result = await checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'darwin', + arch: 'x64', + request: async url => { + if (url === config.manifestUrl) return response(url, payload); + if (url === `${config.manifestUrl}.sig`) return response(url, signature); + if (url === manifest.feeds['darwin-x64'].feed.url) return response(url, feed); + if (url === artifactUrl) { artifactRequests += 1; return response(url, artifact); } + throw new Error(`Unexpected update URL ${url}`); + }, + verifyNativeSigner: async () => manifest.feeds['darwin-x64'].signer, + }); + assert.equal(result, 'available'); + assert.equal(artifactRequests, 1); + assert.equal(installs, 0); +}); diff --git a/apps/desktop/src/signed-updates.ts b/apps/desktop/src/signed-updates.ts new file mode 100644 index 000000000..fbe2fa469 --- /dev/null +++ b/apps/desktop/src/signed-updates.ts @@ -0,0 +1,1867 @@ +import { createHash, createPublicKey, randomBytes, verify, X509Certificate } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { constants as fsConstants, type BigIntStats } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + opendir, + readFile, + readdir, + rename, + rmdir, + rm, + unlink, + type FileHandle, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { parseWindowsSignerPins } from './release-config'; + +interface WindowsFileIdentity { + platform: 'win32'; + volumeSerial: string; + fileId128: string; +} + +interface WindowsPrivatePathInspection { + identity: WindowsFileIdentity; + directory: boolean; + links: string; + size: string; +} + +interface WindowsHeldVerification extends WindowsPrivatePathInspection { + sha256: string; +} + +interface WindowsLockedArtifact { + readonly inspection: WindowsHeldVerification; + read(offset: number, length: number, signal?: AbortSignal): Promise; + verify(signal?: AbortSignal): Promise; + close(signal?: AbortSignal): Promise; +} + +const windowsUpdateUnsupported = (): never => { + throw new Error('Windows self-update is unsupported'); +}; +const inspectWindowsPrivatePath = async (..._args: unknown[]): Promise => windowsUpdateUnsupported(); +const ensureWindowsPrivateDirectory = async (..._args: unknown[]): Promise => windowsUpdateUnsupported(); +const protectWindowsPrivateDirectory = async (..._args: unknown[]): Promise => windowsUpdateUnsupported(); +const protectWindowsPrivateFile = async (..._args: unknown[]): Promise => windowsUpdateUnsupported(); +const openWindowsLockedArtifact = async (..._args: unknown[]): Promise => windowsUpdateUnsupported(); + +export interface SignedUpdateBytes { + url: string; + size: number; + sha256: string; +} + +export interface SignedUpdateArtifact extends SignedUpdateBytes { + fileName: string; + kind: 'zip' | 'msi'; +} + +export interface SignedUpdateSigner { + type: 'apple-team-id' | 'authenticode-subject'; + identity: string; + designatedRequirement?: string; + certificateSha256?: string; + spkiSha256?: string; +} + +export interface SignedUpdateFeed { + target: string; + version: string; + feed: SignedUpdateBytes; + artifact: SignedUpdateArtifact; + signer: SignedUpdateSigner; +} + +export interface SignedUpdateManifest { + schemaVersion: 2; + channel: 'stable'; + manifestUrl: string; + windowsSignerPins: readonly string[]; + version: string; + tag: string; + publishedAt: string; + feeds: Record; +} + +export interface SignedUpdateRuntimeConfig { + manifestUrl: string; + publicKey: string; + signingIdentity: string; + windowsSignerPins: readonly string[]; +} + +export type SignedUpdateRequest = (url: string, init: RequestInit) => Promise; + +export const SIGNED_UPDATE_DOWNLOAD_LIMITS = { + manifestBytes: 512 * 1024, + signatureBytes: 1024, + feedBytes: 1024 * 1024, + // Desktop packages should remain far below this; the cap bounds disk use even for signed misconfiguration. + artifactBytes: 1024 * 1024 * 1024, + metadataTimeoutMs: 30_000, + artifactTimeoutMs: 10 * 60_000, +} as const; + +export const SIGNED_UPDATE_CACHE_POLICY = { + expiryMs: 10 * 60_000, + metadataBytes: 16 * 1024, + entryName: 'verified-update', + artifactName: 'artifact', + metadataName: 'entry.json', + lockName: '.cache-lock', + lockOwnerName: 'owner.json', + // The namespace contains one signed artifact and its small metadata record only. + namespaceBytes: 1024 * 1024 * 1024 + 64 * 1024, + maxRootEntries: 2, + maxEntryEntries: 2, + inspectionEntryCap: 64, + inspectionNameBytes: 16 * 1024, + inspectionDepth: 3, + inspectionElapsedMs: 250, + cleanupEntryCap: 64, + cleanupByteCap: 128 * 1024 * 1024, + quarantineSlots: 4, + quarantineGlobalNames: 256, + quarantineGlobalBytes: 4 * 1024 * 1024 * 1024, + quarantineMaxAgeMs: 7 * 24 * 60 * 60_000, + quarantineStateBytes: 4096, +} as const; + +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const TARGET_PATTERN = /^(darwin|win32)-(x64|arm64)$/; +const execFileAsync = promisify(execFile); +const cacheLocks = new Map>(); + +export interface VerifiedUpdateArtifact { + feedBytes: Buffer; + artifact: SignedUpdateArtifact; + /** One-shot application of the still-held, exact verified byte capability. */ + apply(): Promise; +} + +export interface HeldUpdateArtifactSource { + readonly artifact: SignedUpdateArtifact; + readonly feedBytes: Buffer; + read(offset: number, length: number): Promise; +} + +interface ExpectedDownloadBytes { + size: number; + sha256: string; +} + +interface BoundedDownloadOptions { + request: SignedUpdateRequest; + url: string; + label: string; + maxBytes: number; + timeoutMs: number; + expected?: ExpectedDownloadBytes; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const parseHttpsUrl = ( + value: unknown, + label: string, + { allowQuery = true }: { allowQuery?: boolean } = {}, +): string => { + if (typeof value !== 'string') throw new Error(`${label} must be a string`); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an absolute HTTPS URL`); + } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || (!allowQuery && url.search)) { + throw new Error(`${label} must be an HTTPS URL without credentials, a fragment${allowQuery ? '' : ', or a query'}`); + } + return url.toString(); +}; + +const parseBytes = (value: unknown, label: string): SignedUpdateBytes => { + if (!isRecord(value)) throw new Error(`${label} is invalid`); + if (!Number.isSafeInteger(value.size) || Number(value.size) <= 0) { + throw new Error(`${label} size is invalid`); + } + if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) { + throw new Error(`${label} SHA-256 is invalid`); + } + return { + url: parseHttpsUrl(value.url, `${label} URL`), + size: Number(value.size), + sha256: value.sha256, + }; +}; + +const parseFeed = (value: unknown, target: string, version: string): SignedUpdateFeed => { + const label = `Signed update manifest feed ${target}`; + if (!isRecord(value) || value.target !== target || value.version !== version) { + throw new Error(`${label} does not bind its exact target and version`); + } + const feed = parseBytes(value.feed, `${label} metadata`); + const parsedArtifact = parseBytes(value.artifact, `${label} artifact`); + if (feed.size > SIGNED_UPDATE_DOWNLOAD_LIMITS.feedBytes) { + throw new Error(`${label} metadata exceeds the runtime download limit`); + } + if (parsedArtifact.size > SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes) { + throw new Error(`${label} artifact exceeds the runtime download limit`); + } + if (!isRecord(value.artifact) + || typeof value.artifact.fileName !== 'string' + || basename(value.artifact.fileName) !== value.artifact.fileName + || (value.artifact.kind !== 'zip' && value.artifact.kind !== 'msi')) { + throw new Error(`${label} artifact descriptor is invalid`); + } + const expectedKind = target.startsWith('darwin-') ? 'zip' : 'msi'; + const [, arch] = target.split('-'); + const expectedFileName = target.startsWith('darwin-') + ? `ProPR-Desktop-${version}-macos-${arch}.zip` + : `ProPR-Desktop-${version}-windows-${arch}-Machine-Setup.msi`; + if (value.artifact.kind !== expectedKind + || value.artifact.fileName !== expectedFileName + || basename(new URL(parsedArtifact.url).pathname) !== value.artifact.fileName) { + throw new Error(`${label} artifact does not match its target or URL`); + } + const expectedSignerType = target.startsWith('darwin-') ? 'apple-team-id' : 'authenticode-subject'; + if (!isRecord(value.signer) + || value.signer.type !== expectedSignerType + || typeof value.signer.identity !== 'string' + || !value.signer.identity.trim()) { + throw new Error(`${label} native signer is invalid`); + } + if (expectedSignerType === 'apple-team-id' + && (typeof value.signer.designatedRequirement !== 'string' || !value.signer.designatedRequirement.trim())) { + throw new Error(`${label} macOS designated requirement is invalid`); + } + if (expectedSignerType === 'authenticode-subject' + && (typeof value.signer.certificateSha256 !== 'string' + || !SHA256_PATTERN.test(value.signer.certificateSha256) + || typeof value.signer.spkiSha256 !== 'string' + || !SHA256_PATTERN.test(value.signer.spkiSha256))) { + throw new Error(`${label} Windows signer fingerprint evidence is invalid`); + } + return { + target, + version, + feed, + artifact: { + ...parsedArtifact, + fileName: value.artifact.fileName, + kind: value.artifact.kind, + }, + signer: { + type: value.signer.type as SignedUpdateSigner['type'], + identity: value.signer.identity, + ...(expectedSignerType === 'apple-team-id' + ? { designatedRequirement: value.signer.designatedRequirement as string } + : { + certificateSha256: value.signer.certificateSha256 as string, + spkiSha256: value.signer.spkiSha256 as string, + }), + }, + }; +}; + +export const parseSignedUpdateManifest = (payload: Buffer): SignedUpdateManifest => { + let value: unknown; + try { + value = JSON.parse(payload.toString('utf8')); + } catch { + throw new Error('Signed update manifest is not valid JSON'); + } + if (!isRecord(value) || value.schemaVersion !== 2 || value.channel !== 'stable') { + throw new Error('Signed update manifest has an unsupported schema or channel'); + } + if (typeof value.version !== 'string' || !VERSION_PATTERN.test(value.version)) { + throw new Error('Signed update manifest version is not canonical stable semver'); + } + const manifestUrl = parseHttpsUrl( + value.manifestUrl, + 'Signed update manifest URL', + { allowQuery: false }, + ); + if (value.tag !== `desktop-v${value.version}`) { + throw new Error('Signed update manifest tag does not match its version'); + } + if (typeof value.publishedAt !== 'string' || !Number.isFinite(Date.parse(value.publishedAt))) { + throw new Error('Signed update manifest publishedAt is invalid'); + } + if (!Array.isArray(value.windowsSignerPins) + || value.windowsSignerPins.some(pin => typeof pin !== 'string')) { + throw new Error('Signed update manifest Windows signer pin policy is invalid'); + } + const windowsSignerPins = value.windowsSignerPins.length === 0 + ? [] + : parseWindowsSignerPins( + (value.windowsSignerPins as string[]).join(','), + 'Signed update manifest Windows signer pin policy', + ); + if (!isRecord(value.feeds)) throw new Error('Signed update manifest feeds are missing'); + + const feeds: Record = {}; + for (const [target, candidate] of Object.entries(value.feeds)) { + if (!TARGET_PATTERN.test(target)) throw new Error(`Signed update manifest feed ${target} is invalid`); + feeds[target] = parseFeed(candidate, target, value.version); + } + if (Object.keys(feeds).some(target => target.startsWith('win32-')) && windowsSignerPins.length === 0) { + throw new Error('Signed update manifest Windows signer pin policy is required for Windows feeds'); + } + return { ...value, manifestUrl, windowsSignerPins, feeds } as unknown as SignedUpdateManifest; +}; + +export const verifySignedUpdateManifest = ( + payload: Buffer, + signatureBase64: string, + publicKeyBase64: string, +): SignedUpdateManifest => { + let publicKey; + try { + publicKey = createPublicKey({ + key: Buffer.from(publicKeyBase64, 'base64'), + format: 'der', + type: 'spki', + }); + } catch { + throw new Error('Embedded update verification key is invalid'); + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('Embedded update verification key is not Ed25519'); + } + const signature = Buffer.from(signatureBase64.trim(), 'base64'); + if (signature.length !== 64 || !verify(null, payload, publicKey, signature)) { + throw new Error('Signed update manifest signature verification failed'); + } + return parseSignedUpdateManifest(payload); +}; + +const compareVersions = (left: string, right: string): number => { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]; + } + return 0; +}; + +const verifyBytes = (bytes: Buffer, expected: SignedUpdateBytes, label: string): void => { + if (bytes.length !== expected.size) throw new Error(`${label} size does not match the signed manifest`); + const actualHash = createHash('sha256').update(bytes).digest('hex'); + if (actualHash !== expected.sha256) throw new Error(`${label} SHA-256 does not match the signed manifest`); +}; + +const responseContentLength = (response: Response, label: string): number | undefined => { + const header = response.headers.get('content-length'); + if (header === null) return undefined; + if (!/^(0|[1-9]\d*)$/.test(header)) throw new Error(`${label} has an invalid Content-Length header`); + const length = Number(header); + if (!Number.isSafeInteger(length)) throw new Error(`${label} has an invalid Content-Length header`); + return length; +}; + +const validateDownloadResponse = ( + requestedUrl: string, + response: Response, + label: string, + maxBytes: number, + expected?: ExpectedDownloadBytes, +): void => { + const requested = new URL(requestedUrl); + let finalUrl: URL; + try { + finalUrl = new URL(response.url); + } catch { + throw new Error(`${label} response has no valid final URL`); + } + if (finalUrl.protocol !== 'https:' || finalUrl.username || finalUrl.password || finalUrl.origin !== requested.origin) { + throw new Error(`${label} response redirected outside its signed HTTPS origin`); + } + + const contentLength = responseContentLength(response, label); + if (contentLength !== undefined && contentLength > maxBytes) { + throw new Error(`${label} Content-Length exceeds the runtime download limit`); + } + if (contentLength !== undefined && expected && contentLength !== expected.size) { + throw new Error(`${label} Content-Length does not match the signed size`); + } + if (!response.ok) throw new Error(`${label} request failed with HTTP ${response.status}`); +}; + +const withBoundedResponse = async ( + options: BoundedDownloadOptions, + consume: (response: Response, signal: AbortSignal) => Promise, +): Promise => { + const { request, url, label, maxBytes, timeoutMs, expected } = options; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + let response: Response | undefined; + try { + response = await request(url, { + cache: 'no-store', + credentials: 'omit', + redirect: 'follow', + signal: controller.signal, + }); + validateDownloadResponse(url, response, label, maxBytes, expected); + return await consume(response, controller.signal); + } catch (error) { + controller.abort(); + if (response?.body && !response.body.locked) await response.body.cancel().catch(() => undefined); + if (timedOut) throw new Error(`${label} request timed out and was aborted`); + throw error; + } finally { + clearTimeout(timeout); + } +}; + +const consumeResponse = async ( + response: Response, + signal: AbortSignal, + { label, maxBytes, expected }: Pick, + consumeChunk: (chunk: Uint8Array) => Promise | void, +): Promise => { + if (!response.body) { + if (expected?.size) throw new Error(`${label} size does not match the signed size`); + return; + } + + const reader = response.body.getReader(); + const hash = expected ? createHash('sha256') : undefined; + let received = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (signal.aborted) throw signal.reason; + if (!value?.byteLength) continue; + received += value.byteLength; + if (received > maxBytes || (expected && received > expected.size)) { + await reader.cancel().catch(() => undefined); + throw new Error(`${label} received bytes exceed the runtime download limit`); + } + hash?.update(value); + await consumeChunk(value); + } + } finally { + reader.releaseLock(); + } + + if (expected && received !== expected.size) throw new Error(`${label} size does not match the signed size`); + if (expected && hash?.digest('hex') !== expected.sha256) { + throw new Error(`${label} SHA-256 does not match the signed manifest`); + } +}; + +export const fetchBoundedUpdateBytes = async (options: BoundedDownloadOptions): Promise => { + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) { + throw new Error(`${options.label} runtime download limit is invalid`); + } + if (options.expected && options.expected.size > options.maxBytes) { + throw new Error(`${options.label} signed size exceeds the runtime download limit`); + } + + return withBoundedResponse(options, async (response, signal) => { + const bytes = Buffer.alloc(options.expected?.size ?? options.maxBytes); + let offset = 0; + await consumeResponse(response, signal, options, chunk => { + Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).copy(bytes, offset); + offset += chunk.byteLength; + }); + return bytes.subarray(0, offset); + }); +}; + +export const downloadBoundedUpdateFile = async ( + options: BoundedDownloadOptions & { destinationPath: string; expected: ExpectedDownloadBytes }, +): Promise => { + if (options.expected.size > options.maxBytes) { + throw new Error(`${options.label} signed size exceeds the runtime download limit`); + } + + let file; + try { + file = await open( + options.destinationPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + // On Windows the protected DACL must exist before any response bytes are written. + await file.close(); + file = undefined; + await protectPrivateFile(options.destinationPath); + file = await open(options.destinationPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + await withBoundedResponse(options, async (response, signal) => { + await consumeResponse(response, signal, options, async chunk => { + const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + let offset = 0; + while (offset < bytes.length) { + const { bytesWritten } = await file!.write(bytes, offset, bytes.length - offset); + offset += bytesWritten; + } + }); + }); + await file.sync(); + await file.close(); + file = undefined; + } catch (error) { + await file?.close().catch(() => undefined); + await rm(options.destinationPath, { force: true }); + throw error; + } +}; + +const verifyFeedReferencesArtifact = ( + target: string, + version: string, + feedBytes: Buffer, + artifact: SignedUpdateArtifact, +): void => { + let feed: unknown; + try { + feed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(feedBytes)); + } catch { + throw new Error('Signed native update feed is not valid JSON'); + } + if (!isRecord(feed) || feed.url !== artifact.url || feed.name !== version) { + throw new Error('Signed native update feed does not reference the bound version and artifact URL'); + } +}; + +export const validateMacOSUpdateApplicationLayout = async (extracted: string): Promise => { + const application = join(extracted, 'propr-desktop.app'); + let applicationStats; + try { + applicationStats = await lstat(application); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('macOS update ZIP is missing the canonical propr-desktop.app bundle'); + } + throw error; + } + if (!applicationStats.isDirectory() || applicationStats.isSymbolicLink()) { + throw new Error('macOS update ZIP canonical propr-desktop.app bundle must be a real directory'); + } + + const topLevel = await readdir(extracted); + if (topLevel.length !== 1 || topLevel[0] !== 'propr-desktop.app') { + throw new Error('macOS update ZIP has an ambiguous application layout'); + } + return application; +}; + +export const verifyNativeUpdateSigner = async ( + packagePath: string, + artifact: SignedUpdateArtifact, + expected: SignedUpdateSigner, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-check-')); + try { + const extracted = join(directory, 'extracted'); + if (expected.type === 'apple-team-id') { + await execFileAsync('/usr/bin/ditto', ['-x', '-k', packagePath, extracted]); + const application = await validateMacOSUpdateApplicationLayout(extracted); + await execFileAsync('/usr/bin/codesign', ['--verify', '--deep', '--strict', application]); + await execFileAsync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', application]); + const details = await execFileAsync('/usr/bin/codesign', ['-d', '--verbose=4', application]); + const output = `${details.stdout}\n${details.stderr}`; + const identity = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim(); + if (!identity) throw new Error('macOS update has no designated Team ID'); + const requirement = await execFileAsync('/usr/bin/codesign', ['-d', '-r-', application]); + const designatedRequirement = `${requirement.stdout}\n${requirement.stderr}` + .split(/\r?\n/) + .map(line => line.trim()) + .find(line => line.startsWith('designated =>')); + if (!designatedRequirement) throw new Error('macOS update has no designated requirement'); + return { type: 'apple-team-id', identity, designatedRequirement }; + } + + if (artifact.kind !== 'msi') throw new Error('Windows update artifact is not the canonical machine MSI'); + const script = [ + '$ErrorActionPreference = "Stop"', + `$package = ${JSON.stringify(packagePath)}`, + '$signature = Get-AuthenticodeSignature -LiteralPath $package', + "if ($signature.Status -ne 'Valid' -or !$signature.SignerCertificate -or !$signature.TimeStamperCertificate) { throw 'Windows update Authenticode chain or timestamp status is invalid' }", + '$certificateBase64 = [Convert]::ToBase64String($signature.SignerCertificate.RawData)', + '@{ identity = $signature.SignerCertificate.Subject; certificateBase64 = $certificateBase64 } | ConvertTo-Json -Compress', + ].join('; '); + const { stdout } = await execFileAsync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script]); + let evidence: { identity?: string; certificateBase64?: string }; + try { evidence = JSON.parse(stdout.trim()); } catch { throw new Error('Windows update signer evidence is invalid'); } + if (!evidence.identity || !evidence.certificateBase64) { + throw new Error('Windows update has incomplete Authenticode signer evidence'); + } + let certificate: X509Certificate; + try { certificate = new X509Certificate(Buffer.from(evidence.certificateBase64, 'base64')); } catch { + throw new Error('Windows update signer certificate evidence is invalid'); + } + return { + type: 'authenticode-subject', + identity: evidence.identity, + certificateSha256: certificate.fingerprint256.replaceAll(':', '').toLowerCase(), + spkiSha256: createHash('sha256').update(certificate.publicKey.export({ format: 'der', type: 'spki' })).digest('hex'), + }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +interface UpdateCacheKey { + origin: string; + channel: 'stable'; + version: string; + manifestSha256: string; + artifactSha256: string; + target: string; + artifactSize: number; + artifactFileName: string; +} + +interface UpdateCacheMetadata { + schemaVersion: 1; + createdAt: number; + expiresAt: number; + key: UpdateCacheKey; +} + +interface SignedUpdateOperationOptions { + config: SignedUpdateRuntimeConfig; + currentVersion: string; + platform: NodeJS.Platform; + arch: string; + request: SignedUpdateRequest; + cacheDirectory?: string; + now?: () => number; + verifyNativeSigner?: ( + packagePath: string, + artifact: SignedUpdateArtifact, + signer: SignedUpdateSigner, + ) => Promise; + /** Platform adapter that consumes only held bytes; mutable path adapters are intentionally unsupported. */ + applyHeldArtifact?: (source: HeldUpdateArtifactSource) => Promise; + /** Native-test-only deterministic barrier immediately before the broker's CreateFileW. */ + beforeWindowsArtifactOpenForTest?: (packagePath: string) => Promise; + /** Native-test-only restoration point after a mismatched handle has been closed but before rejection. */ + afterWindowsArtifactMismatchForTest?: ( + packagePath: string, + acquired: Readonly<{ identity: WindowsFileIdentity; size: string; sha256: string }>, + ) => Promise; +} + +interface PreparedSignedUpdate { + manifest: SignedUpdateManifest; + manifestDigest: string; + target: string; + feed: SignedUpdateFeed; + feedBytes: Buffer; +} + +const acquireFilesystemCacheLock = async (cacheDirectory: string): Promise<() => Promise> => { + await collectQuarantines(cacheDirectory); + if (process.platform !== 'win32') await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await ensurePrivateDirectory(cacheDirectory); + await preflightCacheNamespace(cacheDirectory); + const lockPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.lockName); + const ownerPath = join(lockPath, SIGNED_UPDATE_CACHE_POLICY.lockOwnerName); + const deadline = Date.now() + SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs + 30_000; + while (true) { + try { + await mkdir(lockPath, { mode: 0o700 }); + if (process.platform === 'win32') await protectWindowsPrivateDirectory(lockPath); + else { + await chmod(lockPath, 0o700); + await inspectPrivatePath(lockPath, true); + } + let owner = await open( + ownerPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await owner.close(); + await protectPrivateFile(ownerPath); + owner = await open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + await owner.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); + await owner.sync(); + await owner.close(); + const ownerInspection = process.platform === 'win32' ? await inspectPrivatePath(ownerPath) : undefined; + const ownerBytes = ownerInspection ? Number(ownerInspection.size) : 0; + const windowsLock = process.platform === 'win32' && ownerInspection && Number.isSafeInteger(ownerBytes) + && ownerBytes > 0 + ? await openWindowsLockedArtifact( + ownerPath, + ownerBytes, + undefined, + undefined, + ownerInspection.identity as WindowsFileIdentity, + ) + : undefined; + if (process.platform === 'win32' && !windowsLock) throw new Error('Verified update cache lock is unavailable'); + return async () => { + await windowsLock?.close(); + await removeCachePath(lockPath); + await syncDirectory(cacheDirectory); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + let active = false; + try { + const heldOwner = await openPrivateRegularFile(ownerPath, 1024); + let bytes: Buffer; + try { + const size = heldOwner.windowsLock + ? BigInt(heldOwner.windowsLock.inspection.size) + : (await heldOwner.handle!.stat({ bigint: true })).size; + if (size <= 0n || size > 1024n) throw new Error('Verified update cache lock is unavailable'); + bytes = await readHeldFile(heldOwner, 0, Number(size)); + } finally { + try { await heldOwner.windowsLock?.close(); } finally { await heldOwner.handle?.close(); } + } + const value: unknown = JSON.parse(bytes.toString('utf8')); + if (isRecord(value) && value.schemaVersion === 1 && Number.isSafeInteger(value.pid) && Number(value.pid) > 0) { + try { process.kill(Number(value.pid), 0); active = true; } catch { active = false; } + } + } catch { active = false; } + if (!active) { + const stalePath = join(cacheDirectory, `.stale-lock-${randomBytes(8).toString('hex')}`); + let removed = false; + try { + await rename(lockPath, stalePath); + await removeCachePath(stalePath); + removed = true; + } catch { /* A live owner may have won the inspection race; retry without trusting it. */ } + if (!removed) { + if (Date.now() >= deadline) throw new Error('Verified update cache lock is unavailable'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + continue; + } + if (Date.now() >= deadline) throw new Error('Verified update cache lock is unavailable'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + } +}; + +const withCacheLock = async ( + cacheDirectory: string, + operation: (cacheLockHeld: boolean) => Promise, +): Promise => { + const previous = cacheLocks.get(cacheDirectory) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(resolve => { release = resolve; }); + const queued = previous.then(() => current); + cacheLocks.set(cacheDirectory, queued); + await previous; + let releaseFilesystemLock: (() => Promise) | undefined; + try { + try { releaseFilesystemLock = await acquireFilesystemCacheLock(cacheDirectory); } catch { /* cache use will fail closed */ } + return await operation(releaseFilesystemLock !== undefined); + } finally { + try { await releaseFilesystemLock?.(); } finally { + release(); + if (cacheLocks.get(cacheDirectory) === queued) cacheLocks.delete(cacheDirectory); + } + } +}; + +interface PosixFileIdentity { + platform: 'posix'; + device: string; + inode: string; +} + +type ExactFileIdentity = PosixFileIdentity | WindowsFileIdentity; + +export const canonicalPosixFileIdentity = (device: bigint, inode: bigint): PosixFileIdentity => ({ + platform: 'posix', + device: device.toString(10), + inode: inode.toString(10), +}); + +export const sameExactFileIdentity = (left: ExactFileIdentity, right: ExactFileIdentity): boolean => + left.platform === right.platform && (left.platform === 'win32' + ? left.volumeSerial === (right as WindowsFileIdentity).volumeSerial + && left.fileId128 === (right as WindowsFileIdentity).fileId128 + : left.device === (right as PosixFileIdentity).device + && left.inode === (right as PosixFileIdentity).inode); + +export const posixAuthorityIsPrivate = (owner: bigint, mode: bigint, currentUid?: bigint): boolean => + currentUid !== undefined && owner === currentUid && (mode & 0o077n) === 0n; + +const isOwnedPrivate = (stats: BigIntStats, directory = false): boolean => { + const expectedType = directory ? stats.isDirectory() : stats.isFile(); + const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : undefined; + return expectedType && !stats.isSymbolicLink() && posixAuthorityIsPrivate(stats.uid, stats.mode, currentUid); +}; + +const inspectPrivatePath = async ( + path: string, + directory = false, +): Promise<{ identity: ExactFileIdentity; size: bigint; links: bigint }> => { + if (process.platform === 'win32') { + const inspected = await inspectWindowsPrivatePath(path, directory); + return { identity: inspected.identity, size: BigInt(inspected.size), links: BigInt(inspected.links) }; + } + const stats = await lstat(path, { bigint: true }); + if (!isOwnedPrivate(stats, directory) || (!directory && stats.nlink !== 1n)) { + throw new Error('Verified update cache authority inspection failed'); + } + return { + identity: canonicalPosixFileIdentity(stats.dev, stats.ino), + size: stats.size, + links: stats.nlink, + }; +}; + +const ensurePrivateDirectory = async (path: string): Promise => { + if (process.platform === 'win32') { + await ensureWindowsPrivateDirectory(path); + return; + } + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + await chmod(path, 0o700); + await inspectPrivatePath(path, true); +}; + +const protectPrivateFile = async (path: string): Promise => { + if (process.platform === 'win32') { + await protectWindowsPrivateFile(path); + return; + } + await chmod(path, 0o600); + await inspectPrivatePath(path); +}; + +const syncDirectory = async (path: string): Promise => { + let handle: FileHandle | undefined; + try { + handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + await handle.sync(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform !== 'win32' || !['EINVAL', 'ENOTSUP', 'EPERM', 'EISDIR'].includes(code ?? '')) throw error; + } finally { + await handle?.close(); + } +}; + +interface NamespaceBudget { + entries: number; + nameBytes: number; + bytes: number; + readonly startedAt: number; + readonly entryCap: number; + readonly byteCap: number; +} + +const newNamespaceBudget = ( + entryCap = SIGNED_UPDATE_CACHE_POLICY.inspectionEntryCap, + byteCap = Number.MAX_SAFE_INTEGER, +): NamespaceBudget => ({ + entries: 0, + nameBytes: 0, + bytes: 0, + startedAt: Date.now(), + entryCap, + byteCap, +}); + +const assertNamespaceBudget = (budget: NamespaceBudget, name?: string): void => { + if (Date.now() - budget.startedAt > SIGNED_UPDATE_CACHE_POLICY.inspectionElapsedMs + || budget.entries >= budget.entryCap) throw new Error('Verified update cache namespace inspection limit exceeded'); + if (name !== undefined) { + const bytes = Buffer.byteLength(name); + if (bytes <= 0 || bytes > SIGNED_UPDATE_CACHE_POLICY.inspectionNameBytes + || budget.nameBytes + bytes > SIGNED_UPDATE_CACHE_POLICY.inspectionNameBytes) { + throw new Error('Verified update cache namespace inspection limit exceeded'); + } + budget.entries += 1; + budget.nameBytes += bytes; + } +}; + +const boundedDirectoryNames = async (path: string, budget = newNamespaceBudget()): Promise => { + const directory = await opendir(path); + const names: string[] = []; + try { + while (true) { + assertNamespaceBudget(budget); + const entry = await directory.read(); + if (!entry) break; + assertNamespaceBudget(budget, entry.name); + names.push(entry.name); + } + } finally { + try { await directory.close(); } catch { /* async iteration may already have closed it */ } + } + return names; +}; + +const boundedRemoveCachePath = async ( + path: string, + budget = newNamespaceBudget(SIGNED_UPDATE_CACHE_POLICY.cleanupEntryCap), + depth = 0, +): Promise => { + if (depth > SIGNED_UPDATE_CACHE_POLICY.inspectionDepth) return false; + let stats; + try { stats = await lstat(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; + throw error; + } + if (!stats.isDirectory() || stats.isSymbolicLink()) { + assertNamespaceBudget(budget); + if (budget.bytes > 0 && budget.bytes + stats.size > budget.byteCap) return false; + budget.entries += 1; + budget.bytes += stats.size; + await unlink(path); + return true; + } + const directory = await opendir(path); + let complete = true; + try { + while (true) { + try { assertNamespaceBudget(budget); } catch { complete = false; break; } + const entry = await directory.read(); + if (!entry) break; + try { assertNamespaceBudget(budget, entry.name); } catch { complete = false; break; } + if (depth === SIGNED_UPDATE_CACHE_POLICY.inspectionDepth + || !await boundedRemoveCachePath(join(path, entry.name), budget, depth + 1)) { + complete = false; + break; + } + } + } finally { + try { await directory.close(); } catch { /* already closed */ } + } + if (!complete) return false; + try { await rmdir(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false; + } + return true; +}; + +const removeCachePath = async (path: string): Promise => { + if (!await boundedRemoveCachePath(path)) { + throw new Error('Verified update cache bounded cleanup limit exceeded'); + } +}; + +interface QuarantineRecord { + slot: number; + createdAt: number; + names: number; + bytes: number; + saturated: boolean; +} + +interface QuarantineState { + schemaVersion: 1; + cursor: number; + records: QuarantineRecord[]; +} + +const quarantineRootFor = (cacheDirectory: string): string => + join(dirname(cacheDirectory), `.${basename(cacheDirectory)}.quarantine`); + +const quarantineSlotPath = (root: string, slot: number): string => join(root, `slot-${slot}`); + +const ensureQuarantineRoot = async (cacheDirectory: string): Promise => { + const root = quarantineRootFor(cacheDirectory); + if (process.platform !== 'win32') { + try { await mkdir(root, { mode: 0o700 }); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + } else await ensureWindowsPrivateDirectory(root); + await inspectPrivatePath(root, true); + return root; +}; + +const validQuarantineRecord = (value: unknown): value is QuarantineRecord => isRecord(value) + && Number.isInteger(value.slot) && Number(value.slot) >= 0 + && Number(value.slot) < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + && Number.isSafeInteger(value.createdAt) && Number(value.createdAt) >= 0 + && Number.isSafeInteger(value.names) && Number(value.names) >= 0 + && Number.isSafeInteger(value.bytes) && Number(value.bytes) >= 0 + && typeof value.saturated === 'boolean' + && Object.keys(value).length === 5; + +const readQuarantineState = async (root: string): Promise => { + const statePath = join(root, 'collector.json'); + let value: unknown = { schemaVersion: 1, cursor: 0, records: [] }; + try { + const inspected = await inspectPrivatePath(statePath); + if (inspected.size <= 0n || inspected.size > BigInt(SIGNED_UPDATE_CACHE_POLICY.quarantineStateBytes)) throw new Error('invalid'); + value = JSON.parse(await readFile(statePath, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // A missing state record is recoverable from the fixed slot namespace; + // malformed or broad metadata is never trusted. + try { await lstat(statePath); } catch (statError) { + if ((statError as NodeJS.ErrnoException).code === 'ENOENT') return value as QuarantineState; + } + throw new Error('Verified update quarantine metadata is invalid'); + } + } + if (!isRecord(value) || value.schemaVersion !== 1 + || !Number.isInteger(value.cursor) || Number(value.cursor) < 0 + || Number(value.cursor) >= SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || !Array.isArray(value.records) || value.records.length > SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || !value.records.every(validQuarantineRecord) + || new Set(value.records.map(record => record.slot)).size !== value.records.length + || Object.keys(value).length !== 3) { + throw new Error('Verified update quarantine metadata is invalid'); + } + return value as unknown as QuarantineState; +}; + +const writeQuarantineState = async (root: string, state: QuarantineState): Promise => { + const statePath = join(root, 'collector.json'); + const temporary = join(root, 'collector.next'); + const bytes = Buffer.from(`${JSON.stringify(state)}\n`); + if (bytes.length > SIGNED_UPDATE_CACHE_POLICY.quarantineStateBytes) { + throw new Error('Verified update quarantine metadata is invalid'); + } + await rm(temporary, { force: true }); + let handle = await open( + temporary, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await handle.close(); + await protectPrivateFile(temporary); + handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, statePath); + await syncDirectory(root); +}; + +const collectQuarantines = async (cacheDirectory: string): Promise<{ root: string; state: QuarantineState }> => { + const root = await ensureQuarantineRoot(cacheDirectory); + const state = await readQuarantineState(root); + const records = new Map(state.records.map(record => [record.slot, record])); + // Fixed slots avoid an attacker-controlled parent-directory walk. Missing + // metadata is reconstructed conservatively and marks the backlog saturated. + for (let slot = 0; slot < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; slot += 1) { + try { + await lstat(quarantineSlotPath(root, slot)); + if (!records.has(slot)) records.set(slot, { slot, createdAt: 0, names: 0, bytes: 0, saturated: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + records.delete(slot); + } + } + const budget = newNamespaceBudget( + SIGNED_UPDATE_CACHE_POLICY.cleanupEntryCap, + SIGNED_UPDATE_CACHE_POLICY.cleanupByteCap, + ); + for (let count = 0; count < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; count += 1) { + const slot = (state.cursor + count) % SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; + const record = records.get(slot); + if (!record) continue; + const entriesBefore = budget.entries; + const bytesBefore = budget.bytes; + let complete = false; + try { complete = await boundedRemoveCachePath(quarantineSlotPath(root, slot), budget); } catch { complete = false; } + record.names += budget.entries - entriesBefore; + record.bytes += budget.bytes - bytesBefore; + record.saturated = !complete; + if (complete) records.delete(slot); + state.cursor = (slot + 1) % SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; + if (!complete) break; + } + state.records = [...records.values()].sort((left, right) => left.slot - right.slot); + await writeQuarantineState(root, state); + return { root, state }; +}; + +const quarantineCacheNamespace = async (cacheDirectory: string): Promise => { + const { root, state } = await collectQuarantines(cacheDirectory); + const now = Date.now(); + const globalNames = state.records.reduce((total, record) => total + record.names, 0); + const globalBytes = state.records.reduce((total, record) => total + record.bytes, 0); + if (state.records.some(record => record.saturated || now - record.createdAt > SIGNED_UPDATE_CACHE_POLICY.quarantineMaxAgeMs) + || state.records.length >= SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || globalNames >= SIGNED_UPDATE_CACHE_POLICY.quarantineGlobalNames + || globalBytes >= SIGNED_UPDATE_CACHE_POLICY.quarantineGlobalBytes) { + throw new Error('Verified update quarantine backlog exceeds the global bound'); + } + const occupied = new Set(state.records.map(record => record.slot)); + const slot = Array.from({ length: SIGNED_UPDATE_CACHE_POLICY.quarantineSlots }, (_, index) => index) + .find(candidate => !occupied.has(candidate)); + if (slot === undefined) throw new Error('Verified update quarantine backlog exceeds the global bound'); + const quarantine = quarantineSlotPath(root, slot); + try { + await rename(cacheDirectory, quarantine); + } catch { + throw new Error('Verified update cache namespace could not be quarantined'); + } + try { + await ensurePrivateDirectory(cacheDirectory); + } catch (error) { + try { await rename(quarantine, cacheDirectory); } catch { /* preserve quarantine if a concurrent creator won */ } + throw error; + } + state.records.push({ slot, createdAt: now, names: 0, bytes: 0, saturated: false }); + state.records.sort((left, right) => left.slot - right.slot); + await writeQuarantineState(root, state); + // One bounded pass makes small quarantines disappear immediately. Oversized + // trees resume from their mutated filesystem cursor on later launches. + await collectQuarantines(cacheDirectory); +}; + +/** Native-test-only bounded collector probe; returns fixed non-secret progress metadata. */ +export const collectUpdateCacheQuarantinesForTest = async (cacheDirectory: string): Promise> => { + const { state } = await collectQuarantines(cacheDirectory); + return Object.freeze({ + schemaVersion: 1, + cursor: state.cursor, + records: state.records.map(record => Object.freeze({ ...record })), + }); +}; + +/** Native-test-only invalid-namespace transition into the fixed quarantine slots. */ +export const quarantineUpdateCacheNamespaceForTest = quarantineCacheNamespace; + +const preflightCacheNamespace = async (cacheDirectory: string): Promise => { + const budget = newNamespaceBudget(); + let invalid = false; + try { + const names = await boundedDirectoryNames(cacheDirectory, budget); + const folded = new Set(); + if (names.length > SIGNED_UPDATE_CACHE_POLICY.maxRootEntries) invalid = true; + for (const name of names) { + const canonical = name.toLocaleLowerCase('en-US'); + if (folded.has(canonical)) invalid = true; + folded.add(canonical); + if (name !== SIGNED_UPDATE_CACHE_POLICY.entryName && name !== SIGNED_UPDATE_CACHE_POLICY.lockName) { + invalid = true; + break; + } + const child = join(cacheDirectory, name); + await inspectPrivatePath(child, true); + const childNames = await boundedDirectoryNames(child, budget); + const expected: Set = name === SIGNED_UPDATE_CACHE_POLICY.entryName + ? new Set([SIGNED_UPDATE_CACHE_POLICY.artifactName, SIGNED_UPDATE_CACHE_POLICY.metadataName]) + : new Set([SIGNED_UPDATE_CACHE_POLICY.lockOwnerName]); + if (childNames.length !== expected.size) invalid = true; + let childBytes = 0n; + for (const childName of childNames) { + if (!expected.delete(childName)) invalid = true; + const inspected = await inspectPrivatePath(join(child, childName)); + childBytes += inspected.size; + } + if (name === SIGNED_UPDATE_CACHE_POLICY.lockName && (childBytes <= 0n || childBytes > 1024n)) invalid = true; + if (name === SIGNED_UPDATE_CACHE_POLICY.entryName + && childBytes > BigInt(SIGNED_UPDATE_CACHE_POLICY.namespaceBytes)) invalid = true; + if (expected.size !== 0) invalid = true; + } + } catch { + invalid = true; + } + if (invalid) await quarantineCacheNamespace(cacheDirectory); +}; + +const prepareCacheDirectory = async (cacheDirectory: string, now: number): Promise => { + await collectQuarantines(cacheDirectory); + if (process.platform !== 'win32') await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await ensurePrivateDirectory(cacheDirectory); + + const names = await boundedDirectoryNames(cacheDirectory); + const foldedNames = new Set(); + let invalidateEntry = names.length > SIGNED_UPDATE_CACHE_POLICY.maxRootEntries; + for (const name of names) { + const folded = name.toLocaleLowerCase('en-US'); + if (foldedNames.has(folded)) invalidateEntry = true; + foldedNames.add(folded); + if (name === SIGNED_UPDATE_CACHE_POLICY.lockName) { + await inspectPrivatePath(join(cacheDirectory, name), true); + const lockNames = await boundedDirectoryNames(join(cacheDirectory, name)); + if (lockNames.length !== 1 || lockNames[0] !== SIGNED_UPDATE_CACHE_POLICY.lockOwnerName) { + throw new Error('Verified update cache is unavailable'); + } + const owner = await inspectPrivatePath(join(cacheDirectory, name, lockNames[0])); + if (owner.size <= 0n || owner.size > 1024n) throw new Error('Verified update cache is unavailable'); + continue; + } + if (name.startsWith('.partial-') || name !== SIGNED_UPDATE_CACHE_POLICY.entryName) { + throw new Error('Verified update cache contains unknown content'); + } + } + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + try { + if (invalidateEntry) throw new Error('invalid'); + await inspectPrivatePath(entryPath, true); + const entryNames = await boundedDirectoryNames(entryPath); + if (entryNames.length !== SIGNED_UPDATE_CACHE_POLICY.maxEntryEntries) throw new Error('invalid'); + const expected = new Set([SIGNED_UPDATE_CACHE_POLICY.artifactName, SIGNED_UPDATE_CACHE_POLICY.metadataName]); + const foldedEntryNames = new Set(); + let totalBytes = 0n; + for (const name of entryNames) { + const folded = name.toLocaleLowerCase('en-US'); + if (foldedEntryNames.has(folded) || !expected.delete(name)) throw new Error('invalid'); + foldedEntryNames.add(folded); + const inspected = await inspectPrivatePath(join(entryPath, name)); + if (inspected.links !== 1n) throw new Error('invalid'); + totalBytes += inspected.size; + } + if (expected.size !== 0 || totalBytes > BigInt(SIGNED_UPDATE_CACHE_POLICY.namespaceBytes)) { + throw new Error('invalid'); + } + const metadata = await readCacheMetadata(entryPath); + if (metadata.expiresAt <= now) await removeCachePath(entryPath); + } catch { + await removeCachePath(entryPath); + } +}; + +interface HeldPrivateFile { + handle?: FileHandle; + identity: ExactFileIdentity; + path: string; + windowsLock?: WindowsLockedArtifact; +} + +const openPrivateRegularFile = async ( + path: string, + maxBytes = SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + expectedBeforeAcquisition?: { identity: ExactFileIdentity; size: bigint; sha256?: string }, + beforeWindowsOpenForTest?: () => Promise, + afterWindowsMismatchForTest?: ( + acquired: Readonly<{ identity: WindowsFileIdentity; size: string; sha256: string }>, + ) => Promise, +): Promise => { + if (process.platform === 'win32') { + const setup = expectedBeforeAcquisition ?? await inspectPrivatePath(path); + const exactBytes = Number(setup.size); + if (!Number.isSafeInteger(exactBytes) || exactBytes <= 0 || exactBytes > maxBytes + || setup.identity.platform !== 'win32') throw new Error('Verified update artifact is invalid'); + const windowsLock = await openWindowsLockedArtifact( + path, + exactBytes, + beforeWindowsOpenForTest, + undefined, + setup.identity, + expectedBeforeAcquisition?.sha256, + ); + if (expectedBeforeAcquisition + && (!sameExactFileIdentity(windowsLock.inspection.identity, expectedBeforeAcquisition.identity) + || BigInt(windowsLock.inspection.size) !== expectedBeforeAcquisition.size + || expectedBeforeAcquisition.sha256 !== undefined + && windowsLock.inspection.sha256 !== expectedBeforeAcquisition.sha256)) { + const acquired = Object.freeze({ + identity: windowsLock.inspection.identity, + size: windowsLock.inspection.size, + sha256: windowsLock.inspection.sha256, + }); + await windowsLock.close(); + await afterWindowsMismatchForTest?.(acquired); + throw new Error('Verified update artifact acquisition changed [update-acquire:capability-mismatch]'); + } + return { + identity: windowsLock.inspection.identity, + path, + windowsLock, + }; + } + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const stats = await handle.stat({ bigint: true }); + const inspected = await inspectPrivatePath(path); + const pathStats = await lstat(path, { bigint: true }); + if (stats.nlink !== 1n || pathStats.nlink !== 1n + || pathStats.dev !== stats.dev || pathStats.ino !== stats.ino || pathStats.size !== stats.size + || inspected.size !== stats.size || inspected.links !== 1n + || !isOwnedPrivate(stats)) { + throw new Error('Verified update cache entry is invalid'); + } + return { handle, identity: inspected.identity, path }; + } catch (error) { + await handle.close(); + throw error; + } +}; + +const readHeldFile = async (held: HeldPrivateFile, offset: number, length: number): Promise => { + if (held.windowsLock) return held.windowsLock.read(offset, length); + if (!held.handle) throw new Error('Verified update artifact capability is unavailable'); + const bytes = Buffer.alloc(length); + const { bytesRead } = await held.handle.read(bytes, 0, length, offset); + if (bytesRead !== length) throw new Error('Verified update artifact capability is unavailable'); + return bytes; +}; + +const hashHeldFile = async (held: HeldPrivateFile, maxBytes: number): Promise<{ size: number; sha256: string }> => { + if (held.windowsLock) { + const verified = await held.windowsLock.verify(); + const size = Number(verified.size); + if (!Number.isSafeInteger(size) || size <= 0 || size > maxBytes) { + throw new Error('Verified update artifact is invalid'); + } + return { size, sha256: verified.sha256 }; + } + if (!held.handle) throw new Error('Verified update artifact is invalid'); + const handle = held.handle; + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.nlink !== 1n || stats.size <= 0n || stats.size > BigInt(maxBytes)) { + throw new Error('Verified update artifact is invalid'); + } + const sha256 = createHash('sha256'); + const size = Number(stats.size); + const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, size)); + let offset = 0; + while (offset < size) { + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - offset), offset); + if (bytesRead === 0) throw new Error('Verified update artifact is invalid'); + const bytes = chunk.subarray(0, bytesRead); + sha256.update(bytes); + offset += bytesRead; + } + return { size: offset, sha256: sha256.digest('hex') }; +}; + +const assertHeldArtifact = async ( + held: HeldPrivateFile, + path: string, + artifact: SignedUpdateArtifact, +): Promise => { + if (held.windowsLock) { + const verified = await held.windowsLock.verify(); + if (!sameExactFileIdentity(verified.identity, held.identity) + || verified.links !== '1' + || BigInt(verified.size) !== BigInt(artifact.size)) { + throw new Error('Verified update artifact is invalid'); + } + if (Number(verified.size) !== artifact.size || verified.sha256 !== artifact.sha256) { + throw new Error('Verified update artifact does not match signed metadata'); + } + return; + } + if (!held.handle) throw new Error('Verified update artifact is invalid'); + const descriptor = await held.handle.stat({ bigint: true }); + const pathStats = await lstat(path, { bigint: true }); + const inspected = await inspectPrivatePath(path); + if (descriptor.nlink !== 1n || pathStats.nlink !== 1n + || pathStats.dev !== descriptor.dev || pathStats.ino !== descriptor.ino || pathStats.size !== descriptor.size + || inspected.size !== descriptor.size || !sameExactFileIdentity(inspected.identity, held.identity) + || process.platform !== 'win32' && !isOwnedPrivate(descriptor)) { + throw new Error('Verified update artifact is invalid'); + } + const hashes = await hashHeldFile(held, SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes); + if (hashes.size !== artifact.size || hashes.sha256 !== artifact.sha256) { + throw new Error('Verified update artifact does not match signed metadata'); + } +}; + +const assertSigner = (actual: SignedUpdateSigner, expected: SignedUpdateSigner): void => { + if (actual.type !== expected.type + || actual.identity !== expected.identity + || actual.designatedRequirement !== expected.designatedRequirement + || actual.certificateSha256 !== expected.certificateSha256 + || actual.spkiSha256 !== expected.spkiSha256) { + throw new Error('Native update artifact signer does not match the signed build pin'); + } +}; + +const verifyHeldNativeSigner = async ( + source: HeldPrivateFile, + prepared: PreparedSignedUpdate, + verifyNativeSigner: NonNullable, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-signer-snapshot-')); + let snapshot: HeldPrivateFile | undefined; + try { + if (process.platform === 'win32') await protectWindowsPrivateDirectory(directory); + else { + await chmod(directory, 0o700); + await inspectPrivatePath(directory, true); + } + const snapshotPath = join(directory, prepared.feed.artifact.fileName); + let output = await open( + snapshotPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await output.close(); + await protectPrivateFile(snapshotPath); + output = await open(snapshotPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { + const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, prepared.feed.artifact.size)); + let offset = 0; + while (offset < prepared.feed.artifact.size) { + const length = Math.min(chunk.length, prepared.feed.artifact.size - offset); + const bytes = await readHeldFile(source, offset, length); + bytes.copy(chunk, 0); + const bytesRead = bytes.length; + let written = 0; + while (written < bytesRead) { + const result = await output.write(chunk, written, bytesRead - written, offset + written); + if (result.bytesWritten === 0) throw new Error('Verified update signer snapshot is invalid'); + written += result.bytesWritten; + } + offset += bytesRead; + } + await output.sync(); + } finally { + await output.close(); + } + snapshot = await openPrivateRegularFile(snapshotPath); + await assertHeldArtifact(snapshot, snapshotPath, prepared.feed.artifact); + const beforeSignerDirectory = await lstat(directory, { bigint: true }); + const signer = await verifyNativeSigner(snapshotPath, prepared.feed.artifact, prepared.feed.signer); + const afterSignerDirectory = await lstat(directory, { bigint: true }); + if (beforeSignerDirectory.dev !== afterSignerDirectory.dev + || beforeSignerDirectory.ino !== afterSignerDirectory.ino + || beforeSignerDirectory.ctimeNs !== afterSignerDirectory.ctimeNs + || beforeSignerDirectory.mtimeNs !== afterSignerDirectory.mtimeNs) { + throw new Error('Verified update signer snapshot is invalid'); + } + await assertHeldArtifact(snapshot, snapshotPath, prepared.feed.artifact); + return signer; + } finally { + try { await snapshot?.windowsLock?.close(); } finally { + await snapshot?.handle?.close(); + await rm(directory, { recursive: true, force: true }); + } + } +}; + +const withVerifiedArtifact = async ( + packagePath: string, + prepared: PreparedSignedUpdate, + verifyNativeSigner: NonNullable, + use: (held: HeldPrivateFile) => Promise, + beforeWindowsOpenForTest?: SignedUpdateOperationOptions['beforeWindowsArtifactOpenForTest'], + afterWindowsMismatchForTest?: SignedUpdateOperationOptions['afterWindowsArtifactMismatchForTest'], +): Promise => { + // A pathname capability is captured before the broker's first artifact open. + // The native test barrier runs inside the broker launch protocol immediately + // before CreateFileW; the returned full identity/size/hash must still bind A. + const expectedBeforeAcquisition = { + ...await inspectPrivatePath(packagePath), + sha256: prepared.feed.artifact.sha256, + }; + const held = await openPrivateRegularFile( + packagePath, + SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + expectedBeforeAcquisition, + beforeWindowsOpenForTest ? () => beforeWindowsOpenForTest(packagePath) : undefined, + afterWindowsMismatchForTest + ? acquired => afterWindowsMismatchForTest(packagePath, acquired) + : undefined, + ); + try { + const entryDirectory = dirname(packagePath); + const cacheDirectory = dirname(entryDirectory); + const initialDirectory = await inspectPrivatePath(entryDirectory, true); + const initialParent = await inspectPrivatePath(cacheDirectory, true); + const initialDirectoryState = process.platform === 'win32' ? undefined : await lstat(entryDirectory, { bigint: true }); + const initialParentState = process.platform === 'win32' ? undefined : await lstat(cacheDirectory, { bigint: true }); + const assertDirectoryUnchanged = async (): Promise => { + const current = await inspectPrivatePath(entryDirectory, true); + const currentParent = await inspectPrivatePath(cacheDirectory, true); + const currentDirectoryState = initialDirectoryState && await lstat(entryDirectory, { bigint: true }); + const currentParentState = initialParentState && await lstat(cacheDirectory, { bigint: true }); + if (!sameExactFileIdentity(current.identity, initialDirectory.identity) + || !sameExactFileIdentity(currentParent.identity, initialParent.identity) + || initialDirectoryState && currentDirectoryState + && (currentDirectoryState.ctimeNs !== initialDirectoryState.ctimeNs + || currentDirectoryState.mtimeNs !== initialDirectoryState.mtimeNs) + || initialParentState && currentParentState + && (currentParentState.ctimeNs !== initialParentState.ctimeNs + || currentParentState.mtimeNs !== initialParentState.mtimeNs)) { + throw new Error('Verified update artifact is invalid'); + } + }; + await assertHeldArtifact(held, packagePath, prepared.feed.artifact); + assertSigner( + await verifyHeldNativeSigner(held, prepared, verifyNativeSigner), + prepared.feed.signer, + ); + await assertDirectoryUnchanged(); + await assertHeldArtifact(held, packagePath, prepared.feed.artifact); + const result = await use(held); + await assertDirectoryUnchanged(); + await assertHeldArtifact(held, packagePath, prepared.feed.artifact); + return result; + } finally { + try { await held.windowsLock?.close(); } finally { await held.handle?.close(); } + } +}; + +const readCacheMetadata = async (entryPath: string): Promise => { + const path = join(entryPath, SIGNED_UPDATE_CACHE_POLICY.metadataName); + const held = await openPrivateRegularFile(path, SIGNED_UPDATE_CACHE_POLICY.metadataBytes); + try { + const size = held.windowsLock + ? BigInt(held.windowsLock.inspection.size) + : (await held.handle!.stat({ bigint: true })).size; + if (size <= 0n || size > BigInt(SIGNED_UPDATE_CACHE_POLICY.metadataBytes)) { + throw new Error('Verified update cache entry is invalid'); + } + const bytes = await readHeldFile(held, 0, Number(size)); + const value: unknown = JSON.parse(bytes.toString('utf8')); + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.key) + || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.expiresAt)) { + throw new Error('Verified update cache entry is invalid'); + } + return value as unknown as UpdateCacheMetadata; + } catch { + throw new Error('Verified update cache entry is invalid'); + } finally { + try { await held.windowsLock?.close(); } finally { await held.handle?.close(); } + } +}; + +const exactCacheKey = (left: UpdateCacheKey, right: UpdateCacheKey): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const cacheKeyFor = (prepared: PreparedSignedUpdate): UpdateCacheKey => ({ + origin: new URL(prepared.manifest.manifestUrl).origin, + channel: prepared.manifest.channel, + version: prepared.manifest.version, + manifestSha256: prepared.manifestDigest, + artifactSha256: prepared.feed.artifact.sha256, + target: prepared.target, + artifactSize: prepared.feed.artifact.size, + artifactFileName: prepared.feed.artifact.fileName, +}); + +const findCachedArtifact = async ( + cacheDirectory: string, + key: UpdateCacheKey, + now: number, +): Promise => { + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + try { + await inspectPrivatePath(entryPath, true); + const metadata = await readCacheMetadata(entryPath); + if (metadata.expiresAt <= now || metadata.expiresAt - metadata.createdAt !== SIGNED_UPDATE_CACHE_POLICY.expiryMs + || !exactCacheKey(metadata.key, key)) throw new Error('invalid'); + return join(entryPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + } catch { + await removeCachePath(entryPath); + return undefined; + } +}; + +const publishCachedArtifact = async ( + cacheDirectory: string, + prepared: PreparedSignedUpdate, + request: SignedUpdateRequest, + verifyNativeSigner: NonNullable, + now: number, +): Promise => { + const partialName = `.partial-${randomBytes(16).toString('hex')}`; + const partialPath = join(cacheDirectory, partialName); + const artifactPath = join(partialPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + await ensurePrivateDirectory(partialPath); + try { + await downloadBoundedUpdateFile({ + request, + url: prepared.feed.artifact.url, + destinationPath: artifactPath, + label: 'Native update artifact', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs, + expected: prepared.feed.artifact, + }); + await protectPrivateFile(artifactPath); + await withVerifiedArtifact(artifactPath, prepared, verifyNativeSigner, async () => undefined); + + const metadata: UpdateCacheMetadata = { + schemaVersion: 1, + createdAt: now, + expiresAt: now + SIGNED_UPDATE_CACHE_POLICY.expiryMs, + key: cacheKeyFor(prepared), + }; + const metadataPath = join(partialPath, SIGNED_UPDATE_CACHE_POLICY.metadataName); + let metadataHandle = await open( + metadataPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await metadataHandle.close(); + await protectPrivateFile(metadataPath); + metadataHandle = await open(metadataPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { + await metadataHandle.writeFile(`${JSON.stringify(metadata)}\n`, 'utf8'); + await metadataHandle.sync(); + } finally { + await metadataHandle.close(); + } + await syncDirectory(partialPath); + + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + await removeCachePath(entryPath); + await rename(partialPath, entryPath); + await syncDirectory(cacheDirectory); + return join(entryPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + } catch (error) { + await removeCachePath(partialPath); + throw error; + } +}; + +const prepareSignedUpdate = async ({ + config, + currentVersion, + platform, + arch, + request, +}: SignedUpdateOperationOptions): Promise => { + if (platform !== 'darwin' && platform !== 'win32') return 'unsupported'; + if (!VERSION_PATTERN.test(currentVersion)) throw new Error('Current desktop version is invalid'); + + const manifestUrl = parseHttpsUrl( + config.manifestUrl, + 'Embedded update manifest URL', + { allowQuery: false }, + ); + const [payload, signature] = await Promise.all([ + fetchBoundedUpdateBytes({ + request, + url: manifestUrl, + label: 'Signed update manifest', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.manifestBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + }), + fetchBoundedUpdateBytes({ + request, + url: `${manifestUrl}.sig`, + label: 'Signed update manifest signature', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.signatureBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + }), + ]); + const manifest = verifySignedUpdateManifest(payload, signature.toString('ascii'), config.publicKey); + if (manifest.manifestUrl !== manifestUrl) { + throw new Error('Signed update manifest does not bind the embedded manifest URL'); + } + if (compareVersions(manifest.version, currentVersion) <= 0) return 'current'; + + const target = `${platform}-${arch}`; + const feed = manifest.feeds[target]; + if (!feed) throw new Error(`Signed update manifest does not contain a feed for ${target}`); + if (feed.target !== target || feed.version !== manifest.version) { + throw new Error('Signed update feed target or version does not match the requested update'); + } + if (feed.signer.identity !== config.signingIdentity) { + throw new Error('Signed update native signer does not match the identity embedded in this build'); + } + if (platform === 'win32') { + if (!Array.isArray(config.windowsSignerPins)) throw new Error('Embedded Windows signer pin allowlist is invalid'); + const configuredPins = parseWindowsSignerPins(config.windowsSignerPins.join(','), 'Embedded Windows signer pin allowlist'); + if (JSON.stringify(manifest.windowsSignerPins) !== JSON.stringify(configuredPins)) { + throw new Error('Signed update Windows signer pin policy does not match the signed application policy'); + } + const evidencePins = new Set([ + `certificate-sha256:${feed.signer.certificateSha256}`, + `spki-sha256:${feed.signer.spkiSha256}`, + ]); + if (!configuredPins.some(pin => evidencePins.has(pin))) { + throw new Error('Signed update Windows signer fingerprint is not in the embedded allowlist'); + } + } + + const feedBytes = await fetchBoundedUpdateBytes({ + request, + url: feed.feed.url, + label: 'Native update feed', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.feedBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + expected: feed.feed, + }); + verifyBytes(feedBytes, feed.feed, 'Native update feed'); + verifyFeedReferencesArtifact(target, manifest.version, feedBytes, feed.artifact); + return { + manifest, + manifestDigest: createHash('sha256').update(payload).digest('hex'), + target, + feed, + feedBytes, + }; +}; + +const usePreparedArtifact = async ( + prepared: PreparedSignedUpdate, + options: SignedUpdateOperationOptions, + consume: boolean, + use: (held: HeldPrivateFile) => Promise, +): Promise => { + const verifySigner = options.verifyNativeSigner ?? verifyNativeUpdateSigner; + let cacheDirectory = options.cacheDirectory; + const now = (options.now ?? Date.now)(); + if (cacheDirectory) { + try { + await prepareCacheDirectory(cacheDirectory, now); + } catch { + // Cache authority is never availability: authenticate a fresh private download instead. + cacheDirectory = undefined; + } + } + if (!cacheDirectory) { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-download-')); + try { + if (process.platform === 'win32') await protectWindowsPrivateDirectory(directory); + else { + await chmod(directory, 0o700); + await inspectPrivatePath(directory, true); + } + const heldDirectory = join(directory, 'held'); + await ensurePrivateDirectory(heldDirectory); + const packagePath = join(heldDirectory, prepared.feed.artifact.fileName); + await downloadBoundedUpdateFile({ + request: options.request, + url: prepared.feed.artifact.url, + destinationPath: packagePath, + label: 'Native update artifact', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs, + expected: prepared.feed.artifact, + }); + await protectPrivateFile(packagePath); + return await withVerifiedArtifact( + packagePath, + prepared, + verifySigner, + use, + options.beforeWindowsArtifactOpenForTest, + options.afterWindowsArtifactMismatchForTest, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + const key = cacheKeyFor(prepared); + let packagePath = await findCachedArtifact(cacheDirectory, key, now); + if (packagePath) { + let useStarted = false; + try { + const result = await withVerifiedArtifact(packagePath, prepared, verifySigner, held => { + useStarted = true; + return use(held); + }, options.beforeWindowsArtifactOpenForTest, options.afterWindowsArtifactMismatchForTest); + if (consume) await removeCachePath(entryPath); + return result; + } catch (error) { + await removeCachePath(entryPath); + if (useStarted || options.beforeWindowsArtifactOpenForTest) throw error; + packagePath = undefined; + } + } + + packagePath = await publishCachedArtifact( + cacheDirectory, + prepared, + options.request, + verifySigner, + now, + ); + try { + return await withVerifiedArtifact( + packagePath, + prepared, + verifySigner, + use, + options.beforeWindowsArtifactOpenForTest, + options.afterWindowsArtifactMismatchForTest, + ); + } finally { + if (consume) await removeCachePath(entryPath); + } +}; + +export const checkForSignedUpdates = async ( + options: SignedUpdateOperationOptions, +): Promise<'available' | 'current' | 'unsupported'> => { + // Public Windows policy boundary: return before cache locking, metadata or + // artifact requests, native signer inspection, and Windows authority use. + if (options.platform === 'win32') return 'unsupported'; + const operation = async (cacheLockHeld = true): Promise<'available' | 'current' | 'unsupported'> => { + const effectiveOptions = cacheLockHeld ? options : { ...options, cacheDirectory: undefined }; + const prepared = await prepareSignedUpdate(effectiveOptions); + if (prepared === 'current' || prepared === 'unsupported') return prepared; + await usePreparedArtifact(prepared, effectiveOptions, false, async () => undefined); + return 'available'; + }; + return options.cacheDirectory ? withCacheLock(options.cacheDirectory, operation) : operation(); +}; + +export const applySignedUpdate = async ( + options: SignedUpdateOperationOptions & { + installVerifiedArtifact: (artifact: VerifiedUpdateArtifact) => Promise; + }, +): Promise<'applied' | 'current' | 'unsupported'> => { + // Never expose a verified-artifact apply capability on Windows. The native + // installation authority is deferred and is not part of this release. + if (options.platform === 'win32') return 'unsupported'; + const operation = async (cacheLockHeld = true): Promise<'applied' | 'current' | 'unsupported'> => { + const effectiveOptions = cacheLockHeld ? options : { ...options, cacheDirectory: undefined }; + const prepared = await prepareSignedUpdate(effectiveOptions); + if (prepared === 'current' || prepared === 'unsupported') return prepared; + if (!effectiveOptions.applyHeldArtifact) { + throw new Error('Automatic update apply is unavailable for a held verified artifact'); + } + await usePreparedArtifact(prepared, effectiveOptions, true, async held => { + let active = true; + let application: Promise | undefined; + const source: HeldUpdateArtifactSource = Object.freeze({ + artifact: prepared.feed.artifact, + feedBytes: Buffer.from(prepared.feedBytes), + read: async (offset: number, length: number): Promise => { + if (!active || !Number.isSafeInteger(offset) || offset < 0 + || !Number.isSafeInteger(length) || length <= 0 || length > 1024 * 1024 + || offset + length > prepared.feed.artifact.size) { + throw new Error('Verified update artifact capability is unavailable'); + } + return readHeldFile(held, offset, length); + }, + }); + const capability: VerifiedUpdateArtifact = Object.freeze({ + feedBytes: Buffer.from(prepared.feedBytes), + artifact: Object.freeze({ ...prepared.feed.artifact }), + apply: async (): Promise => { + if (!active || application) throw new Error('Verified update artifact capability is unavailable'); + application = (async () => { + // The challenge proves that the exact broker session is live at the + // launch barrier. Its no-share handle remains held while the platform + // adapter consumes only source.read(), never a mutable pathname. + await held.windowsLock?.verify(); + await effectiveOptions.applyHeldArtifact!(source); + await held.windowsLock?.verify(); + })(); + await application; + }, + }); + try { + await effectiveOptions.installVerifiedArtifact(capability); + if (!application) throw new Error('Verified update artifact capability was not consumed'); + await application; + } finally { + active = false; + } + }); + return 'applied'; + }; + return options.cacheDirectory ? withCacheLock(options.cacheDirectory, operation) : operation(); +}; diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts new file mode 100644 index 000000000..49b9cc89f --- /dev/null +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { authorizePackagedSmokeTest } from './smoke-test-authorization'; + +const smokeLeaf = 'propr-desktop-smoke-a1b2c3'; +const smokeDirectory = resolve(tmpdir(), smokeLeaf); +const defaultUserDataDirectory = resolve(tmpdir(), 'ProPR Desktop'); +const nonSmokeDirectory = resolve(tmpdir(), 'not-a-smoke-profile'); +const duplicateSmokeDirectory = resolve(tmpdir(), 'propr-desktop-smoke-other'); +const authorize = (overrides: Partial[0]> = {}) => ( + authorizePackagedSmokeTest({ + argv: ['propr-desktop', '--propr-smoke-test', `--user-data-dir=${smokeDirectory}`], + defaultUserDataDirectory, + environmentTriggered: true, + isPackaged: true, + platform: process.platform, + ...overrides, + }) +); + +describe('packaged smoke profile authorization', () => { + it('requires both argv and environment smoke triggers with the explicit isolated directory', () => { + assert.equal(authorize(), smokeDirectory); + assert.throws( + () => authorize({ environmentTriggered: false }), + /requires both explicit authorization triggers/, + ); + assert.throws( + () => authorize({ + argv: ['propr-desktop', `--user-data-dir=${smokeDirectory}`], + environmentTriggered: true, + }), + /requires both explicit authorization triggers/, + ); + }); + + it('rejects a dual-authorized smoke invocation when the isolated directory is missing', () => { + assert.throws( + () => authorize({ argv: ['propr-desktop', '--propr-smoke-test'] }), + /exactly one explicit --user-data-dir/, + ); + }); + + it('rejects relative, default, non-smoke, and duplicate directories', () => { + assert.throws( + () => authorize({ + argv: ['propr-desktop', '--propr-smoke-test', '--user-data-dir=propr-desktop-smoke-relative'], + }), + /must be absolute/, + ); + assert.throws( + () => authorize({ + argv: ['propr-desktop', '--propr-smoke-test', `--user-data-dir=${defaultUserDataDirectory}`], + }), + /cannot use the default profile store/, + ); + assert.throws( + () => authorize({ + argv: ['propr-desktop', '--propr-smoke-test', `--user-data-dir=${nonSmokeDirectory}`], + }), + /must use propr-desktop-smoke-/, + ); + assert.throws( + () => authorize({ + argv: [ + 'propr-desktop', + '--propr-smoke-test', + `--user-data-dir=${smokeDirectory}`, + `--user-data-dir=${duplicateSmokeDirectory}`, + ], + }), + /exactly one explicit --user-data-dir/, + ); + }); + + it('does not enable mutating smoke behavior in development or without a trigger', () => { + assert.equal(authorize({ isPackaged: false }), null); + assert.equal(authorize({ + argv: ['propr-desktop', `--user-data-dir=${smokeDirectory}`], + environmentTriggered: false, + }), null); + }); + + it('terminates a malformed packaged smoke attempt without an interactive failure path', () => { + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + const authorization = main.indexOf('authorizePackagedSmokeTest({'); + const failureGuard = main.indexOf('} catch {', authorization); + const noninteractiveExit = main.indexOf('process.exit(1);', failureGuard); + const applicationReady = main.indexOf('void app.whenReady()'); + + assert.ok(authorization < failureGuard && failureGuard < noninteractiveExit); + assert.ok(noninteractiveExit < applicationReady); + assert.doesNotMatch(main.slice(failureGuard, noninteractiveExit), /dialog|showMessageBox|console\.|\berror\b/i); + }); + + it('authorizes the isolated directory before profile and lifecycle construction', () => { + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + const authorization = main.indexOf('authorizePackagedSmokeTest({'); + const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); + assert.notEqual(authorization, -1); + assert.ok(authorization < isolation); + assert.ok(isolation < main.indexOf('new ProfileStore(')); + assert.ok(isolation < main.indexOf('new LocalLifecycleController(')); + assert.ok(authorization < main.indexOf('new ProfileStore(')); + assert.ok(authorization < main.indexOf('new LocalLifecycleController(')); + }); + + it('registers coordinated shutdown before smoke window creation and preserves required evidence order', () => { + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + const installedWindowsAppTest = readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app.ps1', import.meta.url)), + 'utf8', + ); + const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); + const sink = main.indexOf('createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory)'); + const authorized = main.indexOf("packagedSmokeEvidence?.write('desktop.smoke.authorized')"); + const appReady = main.indexOf("log('info', 'desktop.app.ready'"); + const shutdownCoordinator = main.indexOf('const shutdown = createDesktopShutdownCoordinator({'); + const beforeQuit = main.indexOf("app.on('before-quit', event => shutdown.beforeQuit(event));"); + const createWindow = main.indexOf('mainWindow = await createMainWindow()'); + const mvpReady = main.indexOf("log('info', 'desktop.renderer.mvp_flows.ready'"); + const chooserRestore = main.lastIndexOf('await closePackagedProfileEditorAndWaitForWelcomeChooser(window);'); + const layoutReady = main.indexOf("log('info', PACKAGED_LAYOUT_READY_EVENT"); + const reducedWindowReady = main.indexOf("log('info', PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT"); + const rendererReady = main.indexOf("log('info', 'desktop.renderer.ready'"); + const willQuit = main.indexOf("app.on('will-quit'"); + const sinkClose = main.indexOf('packagedSmokeEvidence?.close()', willQuit); + const requiredEvents = installedWindowsAppTest.match(/\$requiredSmokeEvents = @\(([\s\S]*?)\r?\n\)/)?.[1]; + + assert.ok(isolation < sink && sink < authorized); + assert.ok(authorized < appReady && appReady < shutdownCoordinator); + assert.ok(shutdownCoordinator < beforeQuit && beforeQuit < createWindow); + assert.equal(main.match(/app\.on\('before-quit', event => shutdown\.beforeQuit\(event\)\);/g)?.length, 1); + assert.notEqual(chooserRestore, -1); + assert.ok(chooserRestore < mvpReady && mvpReady < layoutReady + && layoutReady < reducedWindowReady && reducedWindowReady < rendererReady); + assert.ok(beforeQuit < willQuit && willQuit < sinkClose); + assert.deepEqual(Array.from(requiredEvents?.matchAll(/'([^']+)'/g) ?? [], match => match[1]), [ + 'desktop.smoke.authorized', + 'desktop.app.ready', + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + 'desktop.renderer.ready', + 'desktop.app.shutdown', + ]); + }); + + it('loads the persisted active profile into the packaged policy before creating the first window', () => { + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + const initialization = main.indexOf('const credentialInitialization = await credentials.initialize();'); + const persistedProfileRead = main.indexOf('const current = await credentials.listProfiles();', initialization); + const policyInitialization = main.indexOf( + "rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : [];", + persistedProfileRead, + ); + const createWindow = main.indexOf('mainWindow = await createMainWindow()'); + + assert.notEqual(initialization, -1); + assert.notEqual(persistedProfileRead, -1); + assert.notEqual(policyInitialization, -1); + assert.notEqual(createWindow, -1); + assert.ok(initialization < persistedProfileRead); + assert.ok(persistedProfileRead < policyInitialization); + assert.ok(policyInitialization < createWindow); + }); + + it('keeps transport and Connect journey smoke policies pinned without dynamic profile reads', () => { + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + const pinning = main.indexOf('const rendererPolicyPinnedForSmoke = transportSmoke !== null'); + const connectJourneyPin = main.indexOf("|| connectSmoke?.journeyEndpoint !== undefined", pinning); + const authorizedProfilePin = main.indexOf( + '|| (packagedSmokeTest && smokeProfileOrigin !== null);', + connectJourneyPin, + ); + const initialization = main.indexOf('const credentialInitialization = await credentials.initialize();'); + const persistedReadGuard = main.indexOf( + 'if (app.isPackaged && !rendererPolicyPinnedForSmoke) {', + initialization, + ); + const persistedProfileRead = main.indexOf( + 'const current = await credentials.listProfiles();', + persistedReadGuard, + ); + const persistedReadGuardEnd = main.indexOf('\n }', persistedProfileRead); + const callbackGuard = main.indexOf('...(app.isPackaged && !rendererPolicyPinnedForSmoke ? {'); + const callback = main.indexOf('onRendererActiveProfileChanged:', callbackGuard); + const callbackGuardEnd = main.indexOf('} : {}),', callback); + const registrationEnd = main.indexOf('});', callbackGuardEnd); + + assert.notEqual(pinning, -1); + assert.ok(pinning < connectJourneyPin && connectJourneyPin < authorizedProfilePin); + assert.ok(authorizedProfilePin < initialization); + assert.ok(initialization < persistedReadGuard && persistedReadGuard < persistedProfileRead); + assert.ok(persistedProfileRead < persistedReadGuardEnd && persistedReadGuardEnd < callbackGuard); + assert.ok(callbackGuard < callback && callback < callbackGuardEnd); + assert.ok(callbackGuardEnd < registrationEnd); + }); +}); diff --git a/apps/desktop/src/smoke-test-authorization.ts b/apps/desktop/src/smoke-test-authorization.ts new file mode 100644 index 000000000..844b96513 --- /dev/null +++ b/apps/desktop/src/smoke-test-authorization.ts @@ -0,0 +1,62 @@ +import { basename, isAbsolute, resolve } from 'node:path'; + +export const PACKAGED_SMOKE_USER_DATA_PREFIX = 'propr-desktop-smoke-'; +const PACKAGED_SMOKE_USER_DATA_LEAF = /^propr-desktop-smoke-[A-Za-z0-9]+$/; + +const samePath = (left: string, right: string, platform: NodeJS.Platform): boolean => { + const resolvedLeft = resolve(left); + const resolvedRight = resolve(right); + return platform === 'win32' + ? resolvedLeft.toLocaleLowerCase('en-US') === resolvedRight.toLocaleLowerCase('en-US') + : resolvedLeft === resolvedRight; +}; + +const explicitUserDataDirectory = (argv: readonly string[]): string => { + const values: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--user-data-dir') { + values.push(argv[index + 1] ?? ''); + index += 1; + } else if (argument.startsWith('--user-data-dir=')) { + values.push(argument.slice('--user-data-dir='.length)); + } + } + if (values.length !== 1 || !values[0]) { + throw new Error('Packaged desktop smoke requires exactly one explicit --user-data-dir'); + } + return values[0]; +}; + +export const authorizePackagedSmokeTest = ({ + argv, + defaultUserDataDirectory, + environmentTriggered, + isPackaged, + platform, +}: { + argv: readonly string[]; + defaultUserDataDirectory: string; + environmentTriggered: boolean; + isPackaged: boolean; + platform: NodeJS.Platform; +}): string | null => { + const argumentTriggered = argv.includes('--propr-smoke-test'); + if (!isPackaged) return null; + if (!argumentTriggered && !environmentTriggered) return null; + if (!argumentTriggered || !environmentTriggered) { + throw new Error('Packaged desktop smoke requires both explicit authorization triggers'); + } + + const requested = explicitUserDataDirectory(argv); + if (!isAbsolute(requested) || /[\0\r\n]/.test(requested)) { + throw new Error('Packaged desktop smoke --user-data-dir must be absolute'); + } + if (samePath(requested, defaultUserDataDirectory, platform)) { + throw new Error('Packaged desktop smoke cannot use the default profile store'); + } + if (!PACKAGED_SMOKE_USER_DATA_LEAF.test(basename(requested))) { + throw new Error(`Packaged desktop smoke --user-data-dir must use ${PACKAGED_SMOKE_USER_DATA_PREFIX}`); + } + return resolve(requested); +}; diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts new file mode 100644 index 000000000..d0ff7beea --- /dev/null +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + createPackagedSmokeEvidenceSink, + PACKAGED_SMOKE_EVIDENCE_EVENTS, + PACKAGED_SMOKE_EVIDENCE_FILE, +} from './smoke-test-evidence'; + +const withSmokeDirectory = (run: (directory: string) => void): void => { + const directory = mkdtempSync(join(tmpdir(), 'propr-desktop-smoke-evidence-')); + try { + run(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}; + +describe('packaged smoke evidence', () => { + it('does not create evidence for a non-smoke run', () => { + withSmokeDirectory(directory => { + assert.equal(createPackagedSmokeEvidenceSink(null), null); + assert.deepEqual(readdirSync(directory), []); + }); + }); + + it('writes only fixed allowlisted event-only records and suppresses duplicates', () => { + withSmokeDirectory(directory => { + const sink = createPackagedSmokeEvidenceSink(directory); + assert.ok(sink); + sink.write('desktop.smoke.authorized'); + sink.write('desktop.smoke.authorized'); + sink.write('https://credentials.example/token?secret=raw'); + sink.write('desktop.renderer.ready'); + sink.close(); + + const evidencePath = join(directory, PACKAGED_SMOKE_EVIDENCE_FILE); + const stats = lstatSync(evidencePath); + assert.ok(stats.isFile()); + assert.equal(stats.isSymbolicLink(), false); + const contents = readFileSync(evidencePath, 'utf8'); + assert.deepEqual(contents.trimEnd().split('\n').map(line => JSON.parse(line)), [ + { event: 'desktop.smoke.authorized' }, + { event: 'desktop.renderer.ready' }, + ]); + assert.doesNotMatch(contents, /timestamp|path|url|error|exception|credential|secret|raw/i); + }); + }); + + it('flushes the bounded lifecycle in emission order', () => { + withSmokeDirectory(directory => { + const lifecycle = [ + 'desktop.smoke.authorized', + 'desktop.app.ready', + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + 'desktop.renderer.ready', + 'desktop.app.shutdown', + ]; + const sink = createPackagedSmokeEvidenceSink(directory); + assert.ok(sink); + for (const event of lifecycle) sink.write(event); + for (const event of PACKAGED_SMOKE_EVIDENCE_EVENTS) sink.write(event); + sink.close(); + + const contents = readFileSync(join(directory, PACKAGED_SMOKE_EVIDENCE_FILE), 'utf8'); + const records = contents.trimEnd().split('\n').map(line => JSON.parse(line)); + assert.deepEqual(records.slice(0, lifecycle.length).map(record => record.event), lifecycle); + assert.equal(records.length, PACKAGED_SMOKE_EVIDENCE_EVENTS.length); + assert.ok(Buffer.byteLength(contents, 'utf8') < 1024); + }); + }); +}); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts new file mode 100644 index 000000000..a9d26bfb6 --- /dev/null +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -0,0 +1,77 @@ +import { + closeSync, + fstatSync, + fsyncSync, + lstatSync, + openSync, + writeSync, +} from 'node:fs'; +import { join } from 'node:path'; + +export const PACKAGED_SMOKE_EVIDENCE_FILE = 'application.smoke-evidence.jsonl'; + +export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.smoke.authorized', + 'desktop.app.ready', + 'desktop.renderer.mvp_flows.ready', + 'desktop.renderer.layout.ready', + 'desktop.native.reduced_window.ready', + 'desktop.renderer.ready', + 'desktop.app.shutdown', + 'desktop.app.start_failed', + 'desktop.main_process.uncaught_exception', + 'desktop.log.write_failed', +] as const; + +export type PackagedSmokeEvidenceEvent = typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number]; + +const allowedEvents = new Set(PACKAGED_SMOKE_EVIDENCE_EVENTS); + +export interface PackagedSmokeEvidenceSink { + write(event: string): void; + close(): void; +} + +export const createPackagedSmokeEvidenceSink = ( + authorizedUserDataDirectory: string | null, +): PackagedSmokeEvidenceSink | null => { + if (authorizedUserDataDirectory === null) return null; + + const evidencePath = join(authorizedUserDataDirectory, PACKAGED_SMOKE_EVIDENCE_FILE); + const descriptor = openSync(evidencePath, 'wx', 0o600); + let closed = false; + const emitted = new Set(); + try { + const stats = fstatSync(descriptor); + const pathStats = lstatSync(evidencePath); + if (!stats.isFile() || !pathStats.isFile() || pathStats.isSymbolicLink() + || pathStats.dev !== stats.dev || pathStats.ino !== stats.ino) { + throw new Error('Packaged desktop smoke evidence must be one fixed regular non-link file'); + } + } catch (error) { + closeSync(descriptor); + throw error; + } + + return { + write(event: string): void { + if (closed) throw new Error('Packaged desktop smoke evidence is closed'); + if (!allowedEvents.has(event) || emitted.has(event)) return; + + const record = Buffer.from(`${JSON.stringify({ event })}\n`, 'utf8'); + let offset = 0; + while (offset < record.byteLength) { + const written = writeSync(descriptor, record, offset, record.byteLength - offset); + if (written <= 0) throw new Error('Packaged desktop smoke evidence write did not progress'); + offset += written; + } + fsyncSync(descriptor); + emitted.add(event); + }, + close(): void { + if (closed) return; + closeSync(descriptor); + closed = true; + }, + }; +}; diff --git a/apps/desktop/src/vite-file-system-url.test.ts b/apps/desktop/src/vite-file-system-url.test.ts new file mode 100644 index 000000000..9d2bddc75 --- /dev/null +++ b/apps/desktop/src/vite-file-system-url.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { viteFileSystemUrl } from './vite-file-system-url'; + +describe('Vite filesystem renderer URLs', () => { + it('preserves an absolute POSIX path after the /@fs/ prefix', () => { + assert.equal( + viteFileSystemUrl('/home/propr/propr-ui/src/desktop.tsx'), + '/@fs/home/propr/propr-ui/src/desktop.tsx', + ); + }); + + it('normalizes a Windows drive-letter path and separators', () => { + assert.equal( + viteFileSystemUrl('C:\\propr\\propr-ui\\src\\desktop.tsx'), + '/@fs/C:/propr/propr-ui/src/desktop.tsx', + ); + }); +}); diff --git a/apps/desktop/src/vite-file-system-url.ts b/apps/desktop/src/vite-file-system-url.ts new file mode 100644 index 000000000..4d6b1fed0 --- /dev/null +++ b/apps/desktop/src/vite-file-system-url.ts @@ -0,0 +1,5 @@ +/** Convert an absolute native path into Vite's cross-platform /@fs/ URL form. */ +export const viteFileSystemUrl = (absolutePath: string): string => { + const normalizedPath = absolutePath.replace(/\\/g, '/').replace(/^\/+/, ''); + return `/@fs/${normalizedPath}`; +}; diff --git a/apps/desktop/src/window-options.test.ts b/apps/desktop/src/window-options.test.ts new file mode 100644 index 000000000..304478136 --- /dev/null +++ b/apps/desktop/src/window-options.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + clampBrowserWindowSizing, + createBrowserWindowOptions, + MINIMUM_BROWSER_WINDOW_SIZE, + PREFERRED_BROWSER_WINDOW_SIZE, + selectInitialWindowWorkArea, +} from './window-options'; + +const normalWorkArea = { x: 0, y: 0, width: 1920, height: 1040 }; + +describe('desktop BrowserWindow security', () => { + it('uses the production 1280x820 size with safe minimum dimensions', () => { + const options = createBrowserWindowOptions('/app/preload.cjs', false, normalWorkArea, 'win32'); + assert.deepEqual( + { width: options.width, height: options.height, minWidth: options.minWidth, minHeight: options.minHeight }, + { width: 1280, height: 820, minWidth: 880, minHeight: 620 }, + ); + }); + + it('isolates and sandboxes the renderer without Node or webviews', () => { + const options = createBrowserWindowOptions('/app/preload.cjs', true, normalWorkArea, 'linux'); + assert.deepEqual(options.webPreferences, { + preload: '/app/preload.cjs', + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + webviewTag: false, + devTools: true, + }); + assert.equal('enableRemoteModule' in (options.webPreferences ?? {}), false); + }); + + it('uses the native inset title bar only on macOS', () => { + assert.equal(createBrowserWindowOptions('/preload.cjs', false, normalWorkArea, 'darwin').titleBarStyle, 'hiddenInset'); + assert.equal(createBrowserWindowOptions('/preload.cjs', false, normalWorkArea, 'win32').titleBarStyle, undefined); + }); + + it('retains the preferred and minimum responsive window sizes', () => { + const options = createBrowserWindowOptions('/preload.cjs', false, normalWorkArea, 'win32'); + assert.deepEqual(PREFERRED_BROWSER_WINDOW_SIZE, { width: 1280, height: 820 }); + assert.deepEqual(MINIMUM_BROWSER_WINDOW_SIZE, { width: 880, height: 620 }); + assert.deepEqual( + { width: options.width, height: options.height, minWidth: options.minWidth, minHeight: options.minHeight }, + { width: 1280, height: 820, minWidth: 880, minHeight: 620 }, + ); + }); + + it('centers the initial window within the selected display work area', () => { + const options = createBrowserWindowOptions( + '/preload.cjs', + false, + { x: -1600, y: 40, width: 1600, height: 900 }, + 'linux', + ); + assert.deepEqual( + { x: options.x, y: options.y, width: options.width, height: options.height }, + { x: -1440, y: 80, width: 1280, height: 820 }, + ); + }); +}); + +describe('desktop BrowserWindow display sizing', () => { + for (const scenario of [ + { + name: 'normal work area', + workArea: { width: 1920, height: 1040 }, + expected: { width: 1280, height: 820, minWidth: 880, minHeight: 620 }, + }, + { + name: 'exactly bounded work area', + workArea: { width: 1280, height: 820 }, + expected: { width: 1280, height: 820, minWidth: 880, minHeight: 620 }, + }, + { + name: 'narrow work area', + workArea: { width: 800, height: 1040 }, + expected: { width: 800, height: 820, minWidth: 800, minHeight: 620 }, + }, + { + name: 'short work area', + workArea: { width: 1920, height: 560 }, + expected: { width: 1280, height: 560, minWidth: 880, minHeight: 560 }, + }, + { + name: 'work area smaller in both dimensions', + workArea: { width: 800, height: 560 }, + expected: { width: 800, height: 560, minWidth: 800, minHeight: 560 }, + }, + ]) { + it(`clamps preferred and minimum sizing for a ${scenario.name}`, () => { + assert.deepEqual(clampBrowserWindowSizing(scenario.workArea), scenario.expected); + }); + } + + it('selects the display nearest the cursor for multi-display window placement', () => { + const primary = { workArea: normalWorkArea }; + const active = { workArea: { x: -1600, y: 0, width: 1600, height: 900 } }; + assert.deepEqual(selectInitialWindowWorkArea({ + getPrimaryDisplay: () => primary as never, + getCursorScreenPoint: () => ({ x: -400, y: 300 }), + getDisplayNearestPoint: point => { + assert.deepEqual(point, { x: -400, y: 300 }); + return active as never; + }, + }), active.workArea); + }); + + it('falls back deterministically to the primary display', () => { + const primary = { workArea: normalWorkArea }; + assert.deepEqual(selectInitialWindowWorkArea({ + getPrimaryDisplay: () => primary as never, + getCursorScreenPoint: () => { + throw new Error('cursor unavailable'); + }, + getDisplayNearestPoint: () => { + throw new Error('must not be reached'); + }, + }), primary.workArea); + }); +}); diff --git a/apps/desktop/src/window-options.ts b/apps/desktop/src/window-options.ts new file mode 100644 index 000000000..8d12c0cd3 --- /dev/null +++ b/apps/desktop/src/window-options.ts @@ -0,0 +1,94 @@ +import type { BrowserWindowConstructorOptions, Display, Point, Rectangle } from 'electron'; +import windowSizing from '../window-sizing.json'; + +export const PREFERRED_BROWSER_WINDOW_SIZE = Object.freeze({ ...windowSizing.preferred }); +export const MINIMUM_BROWSER_WINDOW_SIZE = Object.freeze({ ...windowSizing.minimum }); + +type DisplaySelector = { + getCursorScreenPoint: () => Point; + getDisplayNearestPoint: (point: Point) => Display; + getPrimaryDisplay: () => Display; +}; + +type BrowserWindowSizing = { + width: number; + height: number; + minWidth: number; + minHeight: number; +}; + +const hasUsableWorkArea = (workArea: Rectangle): boolean => ( + Number.isInteger(workArea.x) + && Number.isInteger(workArea.y) + && Number.isInteger(workArea.width) + && Number.isInteger(workArea.height) + && workArea.width > 0 + && workArea.height > 0 +); + +export const selectInitialWindowWorkArea = (displays: DisplaySelector): Rectangle => { + const primaryWorkArea = displays.getPrimaryDisplay().workArea; + if (!hasUsableWorkArea(primaryWorkArea)) { + throw new Error('Electron primary display reported an invalid work area'); + } + + try { + const activeWorkArea = displays.getDisplayNearestPoint(displays.getCursorScreenPoint()).workArea; + return hasUsableWorkArea(activeWorkArea) ? activeWorkArea : primaryWorkArea; + } catch { + return primaryWorkArea; + } +}; + +export const clampBrowserWindowSizing = ( + workArea: Pick, +): BrowserWindowSizing => { + if ( + !Number.isInteger(workArea.width) + || !Number.isInteger(workArea.height) + || workArea.width <= 0 + || workArea.height <= 0 + ) { + throw new Error('Cannot size the desktop window for an invalid display work area'); + } + + const width = Math.min(PREFERRED_BROWSER_WINDOW_SIZE.width, workArea.width); + const height = Math.min(PREFERRED_BROWSER_WINDOW_SIZE.height, workArea.height); + return { + width, + height, + minWidth: Math.min(MINIMUM_BROWSER_WINDOW_SIZE.width, width), + minHeight: Math.min(MINIMUM_BROWSER_WINDOW_SIZE.height, height), + }; +}; + +export const createBrowserWindowOptions = ( + preloadPath: string, + allowDevTools: boolean, + workArea: Rectangle, + platform: NodeJS.Platform = process.platform, +): BrowserWindowConstructorOptions => { + if (!hasUsableWorkArea(workArea)) { + throw new Error('Cannot place the desktop window in an invalid display work area'); + } + const sizing = clampBrowserWindowSizing(workArea); + return { + title: 'ProPR Desktop', + ...sizing, + x: workArea.x + Math.floor((workArea.width - sizing.width) / 2), + y: workArea.y + Math.floor((workArea.height - sizing.height) / 2), + backgroundColor: '#f8fafc', + show: false, + ...(platform === 'darwin' ? { titleBarStyle: 'hiddenInset' as const } : {}), + webPreferences: { + preload: preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + webviewTag: false, + devTools: allowDevTools, + }, + }; +}; diff --git a/apps/desktop/src/windows-update-authority.ts b/apps/desktop/src/windows-update-authority.ts new file mode 100644 index 000000000..cecec43f2 --- /dev/null +++ b/apps/desktop/src/windows-update-authority.ts @@ -0,0 +1,2273 @@ +import { createHash, randomBytes, X509Certificate } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { constants as fsConstants, rmSync } from 'node:fs'; +import { lstat, mkdtemp, open, realpath, rm, type FileHandle } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TextDecoder } from 'node:util'; +import { createRequire } from 'node:module'; +import { EventEmitter } from 'node:events'; +import type { Readable, Writable } from 'node:stream'; + +export interface WindowsFileIdentity { + platform: 'win32'; + volumeSerial: string; + fileId128: string; +} + +export interface WindowsPrivatePathInspection { + identity: WindowsFileIdentity; + directory: boolean; + links: string; + size: string; + reparseTag: string; + ownerSid: string; + daclProtected: true; + aceCount: string; + inheritedWriteAces: '0'; + broadWriteAces: '0'; +} + +export interface WindowsHeldVerification extends WindowsPrivatePathInspection { + sha256: string; + sha1: string; +} + +export interface WindowsLockedArtifact { + readonly inspection: WindowsHeldVerification; + read(offset: number, length: number, signal?: AbortSignal): Promise; + verify(signal?: AbortSignal): Promise; + close(signal?: AbortSignal): Promise; +} + +export const WINDOWS_AUTHORITY_PROTOCOL_VERSION = 1 as const; +export const WINDOWS_AUTHORITY_REASON_CODES = Object.freeze([ + 'compile_load', + 'request_protocol', + 'open_handle', + 'reparse_query', + 'reparse_point', + 'type_link_size', + 'owner_sid', + 'dacl_protection', + 'dacl_ace', + 'file_id_info', + 'no_share_lock', + 'hash_read', + 'ready_protocol', + 'held_read', + 'final_verify', + 'clean_shutdown', + 'stdio_protocol', + 'output_bound', + 'timeout', + 'process_exit', +] as const); + +type WindowsAuthorityReason = typeof WINDOWS_AUTHORITY_REASON_CODES[number]; +type BrokerOperation = 'inspect' | 'ensure-directory' | 'protect-directory' | 'protect-file'; +type BrokerPurpose = 'setup' | 'artifact'; + +export const WINDOWS_AUTHORITY_COMPILE_STAGES = Object.freeze([ + 'BUILD_COMPILER', + 'BUILD_SOURCE', + 'BUILD_OUTPUT', + 'TRANSPORT_SPAWN', + 'MANIFEST', + 'HELPER_OPEN', + 'HELPER_OWNER_DACL', + 'HELPER_REPARSE', + 'HELPER_IDENTITY', + 'HELPER_HASH', + 'PROTOCOL_INIT', + 'READY', +] as const); +export type WindowsAuthorityCompileStage = typeof WINDOWS_AUTHORITY_COMPILE_STAGES[number]; + +const BROKER_TIMEOUT_MS = 10_000; +const BROKER_STARTUP_TIMEOUT_MS = 60_000; +const BROKER_SESSION_TIMEOUT_MS = 10 * 60_000; +const BROKER_OUTPUT_BYTES = 16 * 1024; +const BROKER_PROTOCOL_LINE_BYTES = 2 * 1024 * 1024; +const BROKER_REQUEST_LINE_BYTES = 16 * 1024; +const BROKER_MAX_FRAMES = 8192; +const BROKER_MAX_INPUT_BYTES = 64 * 1024 * 1024; +const BROKER_MAX_OUTPUT_BYTES = 2 * 1024 * 1024 * 1024; +const BROKER_MAX_QUEUE_ENTRIES = 256; +const BROKER_ARTIFACT_BYTES = 1024 * 1024 * 1024; +const BROKER_SETUP_FILE_BYTES = 1024 * 1024 * 1024 + 64 * 1024; +const MAX_READ_BYTES = 1024 * 1024; +const reasonCodes = new Set(WINDOWS_AUTHORITY_REASON_CODES); +const INSPECTION_KEYS = Object.freeze([ + 'version', 'type', 'volumeSerial', 'fileId128', 'directory', 'links', 'size', 'reparseTag', + 'ownerSid', 'daclProtected', 'aceCount', 'inheritedWriteAces', 'broadWriteAces', 'sha256', 'sha1', +] as const); +const lockedArtifactProcesses = new WeakMap(); + +const HELPER_NAME = 'propr-windows-authority.exe'; +const HELPER_MANIFEST_NAME = 'propr-windows-authority.manifest.json'; +const LAUNCHER_NAME = 'propr-windows-launcher.node'; +const BOOTSTRAP_NAME = 'propr-windows-bootstrap.node'; +const HELPER_MAX_BYTES = 4 * 1024 * 1024; +const HELPER_MANIFEST_BYTES = 16 * 1024; +const HELPER_MANIFEST_KEYS = Object.freeze([ + 'schemaVersion', 'name', 'format', 'architecture', 'machine', 'clr', 'size', 'sha256', 'sourceSha256', + 'protocol', 'trust', 'publisher', 'compiler', + 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256', + 'bootstrap', 'launcher', +] as const); + +interface WindowsNativeLauncherPolicy { + name: typeof LAUNCHER_NAME | typeof BOOTSTRAP_NAME; + format: 'PE'; + architecture: 'x64' | 'arm64'; + machine: 'AMD64' | 'ARM64'; + size: number; + sha256: string; + trust: 'unsigned-validation' | 'production-signed'; + publisher: string | null; + signerPins: readonly string[]; + signerCertificateSha256: string | null; + signerSpkiSha256: string | null; +} + +interface WindowsAuthorityHelperManifest { + schemaVersion: 1; + name: typeof HELPER_NAME; + format: 'PE32'; + architecture: 'anycpu'; + machine: 'I386'; + clr: true; + size: number; + sha256: string; + sourceSha256: string; + protocol: 'propr-windows-authority-v1'; + trust: 'unsigned-validation' | 'production-signed'; + publisher: string | null; + signerPins: readonly string[]; + signerCertificateSha256: string | null; + signerSpkiSha256: string | null; + launcher: WindowsNativeLauncherPolicy; + bootstrap: WindowsNativeLauncherPolicy; + compiler: { + kind: 'windows-fixed-system-dotnet-framework-csc-v1'; + framework: string; + }; +} + +interface AuthenticatedWindowsAuthorityHelper { + executable: string; + systemRoot: string; + executableHandle: FileHandle; + launcherHandle: FileHandle; + bootstrapHandle: FileHandle; + manifestHandle: FileHandle; + manifest: WindowsAuthorityHelperManifest; + launcher: WindowsNativeLauncher; +} + +interface WindowsNativeLauncher { + probeSystemDirectory(policy: { systemRoot: ''; windir: ''; fault: null }): Buffer; + protectPrivateDirectory(policy: { path: string }): boolean; + verifyPrivateDirectoryForTest?(policy: { path: string; fault?: 'substitution' }): boolean; + compileHeld?(policy: Record): Record; + dangerousAclForTest?(policy: { sddl: string }): boolean; +} + +interface WindowsNativeBootstrap { + loadVerifiedModule(policy: Record): WindowsNativeLauncher; +} + +interface BrokerChild extends EventEmitter { + stdin: Writable; + stdout: Readable; + stderr: Readable; + exitCode: number | null; + killed: boolean; + kill(): boolean; + unref(): void; +} + +const require = createRequire(import.meta.url); + +// This namespace is resolved by the Windows object manager, not by the child +// environment inherited from an attacker-controlled launcher. +const KERNEL_SYSTEM_POWERSHELL = String.raw`\\?\GLOBALROOT\SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe`; +const MICROSOFT_SYSTEM_ROOT_SPKI_SHA256 = new Set([ + '02376d0908ac23041cc7d666d9daf192554f7fc36317aa9cb800908616b28af8', + 'c9905b0ee01202293ca026e64f08412442c5504c06e44ca7e9726d61f20e4089', + 'b2f7298b52bf2c3cac4ddfe72de4d682ac58957595982f2b62301af597c699c5', +]); +const MICROSOFT_SYSTEM_CATALOG_POLICY = Object.freeze([ + Object.freeze({ + member: 'powershell.exe', + catalog: 'Microsoft-Windows-PowerShell-ServerCore-Package~31bf3856ad364e35~amd64~~10.0.26100.32230.cat', + publisher: 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + certificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + spkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + catalogSha256: '2d2ac25e4f3cc782a886422964dffc851a66af354220923d96153738867d7866', + }), + Object.freeze({ + member: 'powershell.exe', + catalog: 'Microsoft-Windows-Client-Features-Package02~31bf3856ad364e35~arm64~~10.0.26100.1.cat', + publisher: 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + certificateSha256: 'ce08760345bd5a18aa9091e6f083522ad593bd42f587699e025afd55be589334', + spkiSha256: '130dc613f271c90adf66157a030391c404f1e4ca21ef8261ac914fc615298b62', + catalogSha256: '08150f5768c0780ab94d998a4302718fd1a69d6e54220a057f2d16f691a4582c', + }), +]); +const BOOTSTRAP_AUTHORITY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$policy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine())) | ConvertFrom-Json +$trustedOwners = @('S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') +$trustedPublishers = @( + 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + 'CN=Microsoft Windows, O=Microsoft Corporation, C=US', + 'CN=Microsoft Corporation, O=Microsoft Corporation, C=US' +) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$currentAuthorities = New-Object Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase) +[void]$currentAuthorities.Add($identity.User.Value) +foreach ($group in $identity.Groups) {[void]$currentAuthorities.Add($group.Value)} +$assembly = [AppDomain]::CurrentDomain.DefineDynamicAssembly( + (New-Object Reflection.AssemblyName('ProprHeldObjectNative')), [Reflection.Emit.AssemblyBuilderAccess]::Run) +$module = $assembly.DefineDynamicModule('ProprHeldObjectNative') +$builder = $module.DefineType('ProprHeldObjectNative.Methods', [Reflection.TypeAttributes]'Public,Sealed,Abstract') +function Add-PInvoke([string]$name, [string]$library, [Type]$returnType, [Type[]]$parameterTypes, + [Runtime.InteropServices.CharSet]$charSet = [Runtime.InteropServices.CharSet]::Auto) { + $method = $builder.DefinePInvokeMethod($name, $library, + [Reflection.MethodAttributes]'Public,Static,PinvokeImpl', [Reflection.CallingConventions]::Standard, + $returnType, $parameterTypes, [Runtime.InteropServices.CallingConvention]::Winapi, $charSet) + $method.SetImplementationFlags($method.GetMethodImplementationFlags() -bor [Reflection.MethodImplAttributes]::PreserveSig) +} +$intptrRef = [IntPtr].MakeByRefType(); $uintRef = [uint32].MakeByRefType(); $ushortRef = [uint16].MakeByRefType() +$guidRef = [Guid].MakeByRefType() +$boolRef = [bool].MakeByRefType() +Add-PInvoke '_get_osfhandle' 'msvcrt.dll' ([IntPtr]) @([int]) +Add-PInvoke 'GetFileInformationByHandleEx' 'kernel32.dll' ([bool]) @([IntPtr], [int], [IntPtr], [uint32]) +Add-PInvoke 'GetFileInformationByHandle' 'kernel32.dll' ([bool]) @([IntPtr], [IntPtr]) +Add-PInvoke 'GetFinalPathNameByHandleW' 'kernel32.dll' ([uint32]) @([IntPtr], [Text.StringBuilder], [uint32], [uint32]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CreateFileW' 'kernel32.dll' ([IntPtr]) @([string], [uint32], [uint32], [IntPtr], [uint32], [uint32], [IntPtr]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CloseHandle' 'kernel32.dll' ([bool]) @([IntPtr]) +Add-PInvoke 'GetSecurityInfo' 'advapi32.dll' ([uint32]) @([IntPtr], [int], [uint32], $intptrRef, $intptrRef, $intptrRef, $intptrRef, $intptrRef) +Add-PInvoke 'GetSecurityDescriptorControl' 'advapi32.dll' ([bool]) @([IntPtr], $ushortRef, $uintRef) +Add-PInvoke 'GetSecurityDescriptorLength' 'advapi32.dll' ([uint32]) @([IntPtr]) +Add-PInvoke 'GetSecurityDescriptorDacl' 'advapi32.dll' ([bool]) @([IntPtr], $boolRef, $intptrRef, $boolRef) +Add-PInvoke 'GetAce' 'advapi32.dll' ([bool]) @([IntPtr], [uint32], $intptrRef) +Add-PInvoke 'ConvertSidToStringSidW' 'advapi32.dll' ([bool]) @([IntPtr], $intptrRef) +Add-PInvoke 'LocalFree' 'kernel32.dll' ([IntPtr]) @([IntPtr]) +Add-PInvoke 'CryptCATAdminAcquireContext2' 'wintrust.dll' ([bool]) @($intptrRef, $guidRef, [string], [IntPtr], [uint32]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CryptCATAdminCalcHashFromFileHandle2' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], $uintRef, [byte[]], [uint32]) +Add-PInvoke 'CryptCATAdminEnumCatalogFromHash' 'wintrust.dll' ([IntPtr]) @([IntPtr], [byte[]], [uint32], [uint32], $intptrRef) +Add-PInvoke 'CryptCATCatalogInfoFromContext' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], [uint32]) +Add-PInvoke 'CryptCATAdminReleaseCatalogContext' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], [uint32]) +Add-PInvoke 'CryptCATAdminReleaseContext' 'wintrust.dll' ([bool]) @([IntPtr], [uint32]) +Add-PInvoke 'WinVerifyTrust' 'wintrust.dll' ([int32]) @([IntPtr], $guidRef, [IntPtr]) +Add-PInvoke 'CryptQueryObject' 'crypt32.dll' ([bool]) @([uint32], [IntPtr], [uint32], [uint32], [uint32], $uintRef, $uintRef, $uintRef, $intptrRef, $intptrRef, [IntPtr]) +Add-PInvoke 'CryptMsgGetParam' 'crypt32.dll' ([bool]) @([IntPtr], [uint32], [uint32], [IntPtr], $uintRef) +Add-PInvoke 'CertEnumCertificatesInStore' 'crypt32.dll' ([IntPtr]) @([IntPtr], [IntPtr]) +Add-PInvoke 'CertFreeCertificateContext' 'crypt32.dll' ([bool]) @([IntPtr]) +Add-PInvoke 'CertCloseStore' 'crypt32.dll' ([bool]) @([IntPtr], [uint32]) +Add-PInvoke 'CryptMsgClose' 'crypt32.dll' ([bool]) @([IntPtr]) +$native = $builder.CreateType() +$catalogLeases=New-Object Collections.Generic.List[object] + +function Hex-Bytes([byte[]]$bytes) { ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant() } +function Read-Held([IO.FileStream]$stream, [int64]$expected, [int64]$maximum=4194304) { + if (!$stream.CanSeek -or $expected -le 0 -or $expected -gt $maximum) { throw 'size' } + $stream.Position = 0; $bytes = New-Object byte[] ([int]$expected); $offset = 0 + while ($offset -lt $bytes.Length) { $read = $stream.Read($bytes, $offset, $bytes.Length - $offset); if ($read -le 0) { throw 'read' }; $offset += $read } + if ($stream.ReadByte() -ne -1) { throw 'size' }; return $bytes +} +function Get-HeldIdentity([IntPtr]$handle, [bool]$directory) { + $tag = [Runtime.InteropServices.Marshal]::AllocHGlobal(8); $id = [Runtime.InteropServices.Marshal]::AllocHGlobal(24) + $basic = [Runtime.InteropServices.Marshal]::AllocHGlobal(52) + try { + if (!$native::GetFileInformationByHandleEx($handle, 9, $tag, 8) -or + !$native::GetFileInformationByHandleEx($handle, 18, $id, 24) -or + !$native::GetFileInformationByHandle($handle, $basic)) { throw 'identity' } + $attributes = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($tag, 0) + $reparse = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($tag, 4) + if (($attributes -band 0x400) -ne 0 -or $reparse -ne 0 -or (($attributes -band 0x10) -ne 0) -ne $directory) { throw 'type' } + $volumeBytes = New-Object byte[] 8; [Runtime.InteropServices.Marshal]::Copy($id, $volumeBytes, 0, 8) + $idBytes = New-Object byte[] 16; [Runtime.InteropServices.Marshal]::Copy([IntPtr]::Add($id, 8), $idBytes, 0, 16) + $indexHigh = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 44) + $indexLow = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 48) + $links = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 40) + return @{ volumeSerial=([BitConverter]::ToUInt64($volumeBytes, 0)).ToString('x16'); fileId128=(Hex-Bytes $idBytes) + nodeDev=([BitConverter]::ToUInt64($volumeBytes, 0)).ToString(); nodeIno=(([uint64]$indexHigh -shl 32) -bor $indexLow).ToString() + links=$links.ToString(); reparseTag=$reparse.ToString('x8') } + } finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($tag); [Runtime.InteropServices.Marshal]::FreeHGlobal($id); [Runtime.InteropServices.Marshal]::FreeHGlobal($basic) } +} +function Expand-FileAccessMask([uint32]$mask) { + if (($mask -band [uint32]0x80000000) -ne 0) {$mask=[uint32](($mask -band [uint32]0x7fffffff) -bor [uint32]0x00120089)} + if (($mask -band [uint32]0x40000000) -ne 0) {$mask=[uint32](($mask -band [uint32]0xbfffffff) -bor [uint32]0x00120116)} + if (($mask -band [uint32]0x20000000) -ne 0) {$mask=[uint32](($mask -band [uint32]0xdfffffff) -bor [uint32]0x001200a0)} + if (($mask -band [uint32]0x10000000) -ne 0) {$mask=[uint32](($mask -band [uint32]0xefffffff) -bor [uint32]0x001f01ff)} + return $mask +} +function Get-HeldSecurity([IntPtr]$handle, [string]$role) { + if ($role -cne 'package' -and $role -cne 'os') {throw 'security-role'} + $owner=[IntPtr]::Zero; $group=[IntPtr]::Zero; $dacl=[IntPtr]::Zero; $sacl=[IntPtr]::Zero; $descriptor=[IntPtr]::Zero + if ($native::GetSecurityInfo($handle, 1, 5, [ref]$owner, [ref]$group, [ref]$dacl, [ref]$sacl, [ref]$descriptor) -ne 0 -or + $owner -eq [IntPtr]::Zero -or $dacl -eq [IntPtr]::Zero -or $descriptor -eq [IntPtr]::Zero) { throw 'security' } + try { + $ownerText=[IntPtr]::Zero; if (!$native::ConvertSidToStringSidW($owner, [ref]$ownerText)) { throw 'owner' } + try { $ownerSid=[Runtime.InteropServices.Marshal]::PtrToStringUni($ownerText) } finally { if ($ownerText -ne [IntPtr]::Zero) { [void]$native::LocalFree($ownerText) } } + if ($trustedOwners -notcontains $ownerSid -or $currentAuthorities.Contains($ownerSid)) { throw 'owner' } + $control=[uint16]0; $revision=[uint32]0 + if (!$native::GetSecurityDescriptorControl($descriptor, [ref]$control, [ref]$revision)) {throw 'dacl-protection'} + $protected=($control -band 0x1000) -ne 0 + if ($role -ceq 'package' -and !$protected) {throw 'dacl-protection'} + $present=$false; $defaulted=$false; $actualDacl=[IntPtr]::Zero + if (!$native::GetSecurityDescriptorDacl($descriptor, [ref]$present, [ref]$actualDacl, [ref]$defaulted) -or !$present -or $actualDacl -eq [IntPtr]::Zero) { throw 'dacl' } + $descriptorLength=$native::GetSecurityDescriptorLength($descriptor) + if ($descriptorLength -le 0 -or $descriptorLength -gt 65536) {throw 'dacl'} + $descriptorBytes=New-Object byte[] $descriptorLength + [Runtime.InteropServices.Marshal]::Copy($descriptor,$descriptorBytes,0,$descriptorLength) + $raw=New-Object Security.AccessControl.RawSecurityDescriptor($descriptorBytes,0) + if (!$raw.DiscretionaryAcl) {throw 'dacl'} + $aceCount=$raw.DiscretionaryAcl.Count + $priorOrder=-1 + foreach ($ace in $raw.DiscretionaryAcl) { + if (($ace.AceFlags -band [Security.AccessControl.AceFlags]::InheritOnly) -ne 0) {continue} + $qualified=$ace -as [Security.AccessControl.QualifiedAce] + $known=$ace -as [Security.AccessControl.KnownAce] + if (!$qualified -or !$known -or !$known.SecurityIdentifier -or + ($qualified.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed -and + $qualified.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessDenied)) {throw 'ace'} + $allowed=$qualified.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed + $inherited=($ace.AceFlags -band [Security.AccessControl.AceFlags]::Inherited) -ne 0 + $order=if ($inherited) {if ($allowed) {3} else {2}} else {if ($allowed) {1} else {0}} + if ($order -lt $priorOrder) {throw 'ace-order'}; $priorOrder=$order + $mask=Expand-FileAccessMask ([uint32]$known.AccessMask) + if (!$allowed -or ($mask -band [uint32]0x000D0156) -eq 0) {continue} + $sid=$known.SecurityIdentifier.Value + if ($currentAuthorities.Contains($sid) -or $trustedOwners -notcontains $sid) {throw 'ace'} + } + return @{ ownerSid=$ownerSid; daclProtected=$protected; aceCount=$aceCount.ToString(); role=$role } + } finally { if ($descriptor -ne [IntPtr]::Zero) {[void]$native::LocalFree($descriptor)} } +} +function Get-FinalPath([IntPtr]$handle) { $value=New-Object Text.StringBuilder 32768; $length=$native::GetFinalPathNameByHandleW($handle,$value,32768,0); if ($length -le 0 -or $length -ge 32768) {throw 'path'}; $value.ToString() } +function Invoke-HeldFileTrust([IntPtr]$handle, [string]$path) { + if ([IntPtr]::Size -ne 8) {throw 'wintrust-layout'} + $pathPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($path) + $file=[Runtime.InteropServices.Marshal]::AllocHGlobal(32); $data=[Runtime.InteropServices.Marshal]::AllocHGlobal(88) + try { + for ($offset=0;$offset -lt 32;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($file,$offset,0)} + for ($offset=0;$offset -lt 88;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($data,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($file,0,32) + [Runtime.InteropServices.Marshal]::WriteIntPtr($file,8,$pathPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($file,16,$handle) + [Runtime.InteropServices.Marshal]::WriteInt32($data,0,88) + [Runtime.InteropServices.Marshal]::WriteInt32($data,24,2) + [Runtime.InteropServices.Marshal]::WriteInt32($data,28,0) + [Runtime.InteropServices.Marshal]::WriteInt32($data,32,1) + [Runtime.InteropServices.Marshal]::WriteIntPtr($data,40,$file) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,1) + [Runtime.InteropServices.Marshal]::WriteInt32($data,72,0x1010) + $action=[Guid]'00AAC56B-CD44-11d0-8CC2-00C04FC295EE' + $status=$native::WinVerifyTrust([IntPtr](-1),[ref]$action,$data) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,2); [void]$native::WinVerifyTrust([IntPtr](-1),[ref]$action,$data) + if ($status -ne 0) {throw 'signature'} + } finally { + [Runtime.InteropServices.Marshal]::FreeHGlobal($data); [Runtime.InteropServices.Marshal]::FreeHGlobal($file) + [Runtime.InteropServices.Marshal]::FreeHGlobal($pathPointer) + } +} +function Invoke-HeldCatalogTrust([IntPtr]$memberHandle, [string]$memberPath, [string]$catalogPath, [byte[]]$memberHash, [IntPtr]$admin) { + if ([IntPtr]::Size -ne 8) {throw 'wintrust-layout'} + $memberTag=(Hex-Bytes $memberHash).ToUpperInvariant() + if ($memberTag.Length -ne $memberHash.Length*2) {throw 'member-tag'} + $catalogPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($catalogPath) + $tagPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($memberTag) + $memberPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($memberPath) + $pin=[Runtime.InteropServices.GCHandle]::Alloc($memberHash,[Runtime.InteropServices.GCHandleType]::Pinned) + $catalog=[Runtime.InteropServices.Marshal]::AllocHGlobal(72); $data=[Runtime.InteropServices.Marshal]::AllocHGlobal(88) + try { + for ($offset=0;$offset -lt 72;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($catalog,$offset,0)} + for ($offset=0;$offset -lt 88;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($data,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($catalog,0,72) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,8,$catalogPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,16,$tagPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,24,$memberPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,32,$memberHandle) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,40,$pin.AddrOfPinnedObject()) + [Runtime.InteropServices.Marshal]::WriteInt32($catalog,48,$memberHash.Length) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,64,$admin) + [Runtime.InteropServices.Marshal]::WriteInt32($data,0,88) + [Runtime.InteropServices.Marshal]::WriteInt32($data,24,2) + [Runtime.InteropServices.Marshal]::WriteInt32($data,28,0) + [Runtime.InteropServices.Marshal]::WriteInt32($data,32,2) + [Runtime.InteropServices.Marshal]::WriteIntPtr($data,40,$catalog) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,1) + [Runtime.InteropServices.Marshal]::WriteInt32($data,72,0x1010) + $policy=[Guid]'00AAC56B-CD44-11d0-8CC2-00C04FC295EE' + $status=$native::WinVerifyTrust([IntPtr](-1),[ref]$policy,$data) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,2); [void]$native::WinVerifyTrust([IntPtr](-1),[ref]$policy,$data) + if ($status -ne 0) {throw 'catalog-trust'} + } finally { + [Runtime.InteropServices.Marshal]::FreeHGlobal($data); [Runtime.InteropServices.Marshal]::FreeHGlobal($catalog) + $pin.Free(); [Runtime.InteropServices.Marshal]::FreeHGlobal($memberPointer) + [Runtime.InteropServices.Marshal]::FreeHGlobal($tagPointer); [Runtime.InteropServices.Marshal]::FreeHGlobal($catalogPointer) + } +} +function Get-RawSigner([byte[]]$bytes, [bool]$standaloneCatalog) { + if ([IntPtr]::Size -ne 8 -or !$bytes -or $bytes.Length -le 0) {throw 'signer-parse'} + $pin=[Runtime.InteropServices.GCHandle]::Alloc($bytes,[Runtime.InteropServices.GCHandleType]::Pinned) + $blob=[Runtime.InteropServices.Marshal]::AllocHGlobal(16); $store=[IntPtr]::Zero; $message=[IntPtr]::Zero + try { + for ($offset=0;$offset -lt 16;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($blob,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($blob,0,$bytes.Length) + [Runtime.InteropServices.Marshal]::WriteIntPtr($blob,8,$pin.AddrOfPinnedObject()) + $encoding=[uint32]0; $content=[uint32]0; $format=[uint32]0 + $contentFlag=if ($standaloneCatalog) {[uint32]0x100} else {[uint32]0x400} + $expectedContent=if ($standaloneCatalog) {[uint32]8} else {[uint32]10} + if (!$native::CryptQueryObject(2,$blob,$contentFlag,2,0,[ref]$encoding,[ref]$content,[ref]$format,[ref]$store,[ref]$message,[IntPtr]::Zero) -or + $content -ne $expectedContent -or $format -ne 1 -or $store -eq [IntPtr]::Zero -or $message -eq [IntPtr]::Zero) {throw 'signer-parse'} + $signerBytes=[uint32]0 + if (!$native::CryptMsgGetParam($message,6,0,[IntPtr]::Zero,[ref]$signerBytes) -or $signerBytes -lt 32 -or $signerBytes -gt 65536) {throw 'signer-parse'} + $signer=[Runtime.InteropServices.Marshal]::AllocHGlobal([int]$signerBytes) + try { + if (!$native::CryptMsgGetParam($message,6,0,$signer,[ref]$signerBytes)) {throw 'signer-parse'} + $issuerLength=[Runtime.InteropServices.Marshal]::ReadInt32($signer,4); $issuerPointer=[Runtime.InteropServices.Marshal]::ReadIntPtr($signer,8) + $serialLength=[Runtime.InteropServices.Marshal]::ReadInt32($signer,16); $serialPointer=[Runtime.InteropServices.Marshal]::ReadIntPtr($signer,24) + if ($issuerLength -le 0 -or $issuerLength -gt 4096 -or $serialLength -le 0 -or $serialLength -gt 64) {throw 'signer-parse'} + $issuer=New-Object byte[] $issuerLength; [Runtime.InteropServices.Marshal]::Copy($issuerPointer,$issuer,0,$issuerLength) + $serial=New-Object byte[] $serialLength; [Runtime.InteropServices.Marshal]::Copy($serialPointer,$serial,0,$serialLength) + $certificate=$null; $previous=[IntPtr]::Zero + while ($true) { + $candidate=$native::CertEnumCertificatesInStore($store,$previous) + if ($candidate -eq [IntPtr]::Zero) {$previous=[IntPtr]::Zero; break} + $previous=$candidate; $parsed=New-Object Security.Cryptography.X509Certificates.X509Certificate2($candidate) + if ((Hex-Bytes $parsed.IssuerName.RawData) -ceq (Hex-Bytes $issuer) -and (Hex-Bytes $parsed.GetSerialNumber()) -ceq (Hex-Bytes $serial)) { + $certificate=New-Object Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$parsed.RawData) + $parsed.Dispose(); [void]$native::CertFreeCertificateContext($candidate); $previous=[IntPtr]::Zero; break + } + $parsed.Dispose() + } + if (!$certificate) {throw 'signer-parse'} + } finally {[Runtime.InteropServices.Marshal]::FreeHGlobal($signer)} + } finally { + if ($message -ne [IntPtr]::Zero) {[void]$native::CryptMsgClose($message)} + if ($store -ne [IntPtr]::Zero) {[void]$native::CertCloseStore($store,0)} + [Runtime.InteropServices.Marshal]::FreeHGlobal($blob); $pin.Free() + } + $root = $null + if ($certificate) { + $chain=New-Object Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode=[Security.Cryptography.X509Certificates.X509RevocationMode]::Offline + $chain.ChainPolicy.RevocationFlag=[Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot + [void]$chain.Build($certificate) + foreach ($status in $chain.ChainStatus) { + if ($status.Status -ne [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::RevocationStatusUnknown -and + $status.Status -ne [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::OfflineRevocation) {throw 'chain'} + } + if ($chain.ChainElements.Count -lt 2) {throw 'chain'} + $root=[Convert]::ToBase64String($chain.ChainElements[$chain.ChainElements.Count-1].Certificate.RawData) + } finally {$chain.Dispose()} + } + return @{subject=$certificate.Subject;certificate=[Convert]::ToBase64String($certificate.RawData);rootCertificate=$root} +} +function Test-Signature([IntPtr]$handle, [string]$path, [byte[]]$bytes, [bool]$standaloneCatalog, [bool]$required, [string]$expectedPublisher) { + if (!$required) {return @{subject=$null;certificate=$null;rootCertificate=$null}} + if (!$standaloneCatalog) {Invoke-HeldFileTrust $handle $path} + $signature=Get-RawSigner $bytes $standaloneCatalog + if (($standaloneCatalog -and $trustedPublishers -notcontains $signature.subject) -or + (!$standaloneCatalog -and $signature.subject -cne $expectedPublisher)) {throw 'signature'} + return $signature +} +function Get-SystemCatalogProof([IntPtr]$memberHandle, [string]$windowsRoot) { + $admin=[IntPtr]::Zero; $catalog=[IntPtr]::Zero; $previous=[IntPtr]::Zero + $action=[Guid]'F750E6C3-38EE-11D1-85E5-00C04FC295EE' + if (!$native::CryptCATAdminAcquireContext2([ref]$admin,[ref]$action,'SHA256',[IntPtr]::Zero,0)) {throw 'catalog-enumeration'} + try { + $hashBytes=[uint32]0 + if (!$native::CryptCATAdminCalcHashFromFileHandle2($admin,$memberHandle,[ref]$hashBytes,$null,0) -or $hashBytes -le 0 -or $hashBytes -gt 128) {throw 'catalog-hash'} + $memberHash=New-Object byte[] $hashBytes + if (!$native::CryptCATAdminCalcHashFromFileHandle2($admin,$memberHandle,[ref]$hashBytes,$memberHash,0)) {throw 'catalog-hash'} + $catalog=$native::CryptCATAdminEnumCatalogFromHash($admin,$memberHash,$hashBytes,0,[ref]$previous) + if ($catalog -eq [IntPtr]::Zero) {throw 'catalog-member'} + $info=[Runtime.InteropServices.Marshal]::AllocHGlobal(524) + try { + for ($offset=0;$offset -lt 524;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($info,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($info,0,524) + if (!$native::CryptCATCatalogInfoFromContext($catalog,$info,0)) {throw 'catalog-enumeration'} + $catalogPath=[Runtime.InteropServices.Marshal]::PtrToStringUni([IntPtr]::Add($info,4)) + } finally {[Runtime.InteropServices.Marshal]::FreeHGlobal($info)} + $catalogRoot=([IO.Path]::Combine($windowsRoot,'System32','CatRoot','{F750E6C3-38EE-11D1-85E5-00C04FC295EE}')).TrimEnd('\')+'\' + if (!$catalogPath.StartsWith($catalogRoot,[StringComparison]::OrdinalIgnoreCase) -or + $catalogPath.IndexOf('\',$catalogRoot.Length) -ge 0) {throw 'catalog-path'} + $stream=[IO.File]::Open($catalogPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + $handle=$stream.SafeFileHandle.DangerousGetHandle(); $identity=Get-HeldIdentity $handle $false; [void](Get-HeldSecurity $handle 'os') + if (!(Get-FinalPath $handle).EndsWith($catalogPath,[StringComparison]::OrdinalIgnoreCase)) {throw 'catalog-path'} + Invoke-HeldCatalogTrust $memberHandle (Get-FinalPath $memberHandle) $catalogPath $memberHash $admin + $bytes=Read-Held $stream $stream.Length 33554432; $sha=[Security.Cryptography.SHA256]::Create() + try {$digest=Hex-Bytes $sha.ComputeHash($bytes)} finally {$sha.Dispose()} + $signature=Test-Signature $handle $catalogPath $bytes $true $true $null + $catalogLeases.Add([pscustomobject]@{stream=$stream;path=$catalogPath;sha256=$digest; + volumeSerial=$identity.volumeSerial;fileId128=$identity.fileId128;length=[int64]$stream.Length; + admin=$admin;catalog=$catalog}) + $admin=[IntPtr]::Zero; $catalog=[IntPtr]::Zero + return @{name=[IO.Path]::GetFileName($catalogPath);sha256=$digest;volumeSerial=$identity.volumeSerial;fileId128=$identity.fileId128;signature=$signature} + } catch {$stream.Dispose();throw} + } finally { + if ($catalog -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseCatalogContext($admin,$catalog,0)} + if ($admin -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseContext($admin,0)} + } +} + +# fd 3 is a duplicate of the exact Node-retained bootstrap handle. Every +# target fact below is queried from it; the pathname is opened only as a +# no-write/no-delete load lease and must resolve to the identical FILE_ID_128. +$heldHandle=$native::_get_osfhandle(3); if ($heldHandle -eq [IntPtr](-1)) {throw 'held'} +$heldSafe=New-Object Microsoft.Win32.SafeHandles.SafeFileHandle($heldHandle,$false) +$held=New-Object IO.FileStream($heldSafe,[IO.FileAccess]::Read,65536,$false) +$load=[IO.File]::Open($policy.path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) +$ancestorHandles=New-Object Collections.Generic.List[object] +$self=$null +try { + $heldIdentity=Get-HeldIdentity $heldHandle $false; $loadHandle=$load.SafeFileHandle.DangerousGetHandle() + $loadIdentity=Get-HeldIdentity $loadHandle $false + if ($heldIdentity.volumeSerial -cne $loadIdentity.volumeSerial -or $heldIdentity.fileId128 -cne $loadIdentity.fileId128 -or + $heldIdentity.nodeDev -cne $policy.nodeDev -or $heldIdentity.nodeIno -cne $policy.nodeIno -or $heldIdentity.links -cne '1') {throw 'split-handle'} + if ((Get-FinalPath $heldHandle) -cne (Get-FinalPath $loadHandle)) {throw 'load-path'} + $security=Get-HeldSecurity $heldHandle 'package' + $authorityRoot=[IO.Path]::GetFullPath($policy.authorityRoot).TrimEnd('\') + $cursor=[IO.Directory]::GetParent($policy.path); $rootSeen=$false + while ($cursor) { + $directory=$native::CreateFileW($cursor.FullName,0x80 -bor 0x20000,1,[IntPtr]::Zero,3,0x2200000,[IntPtr]::Zero) + if ($directory -eq [IntPtr](-1)) {throw 'ancestor'}; $ancestorHandles.Add([pscustomobject]@{handle=$directory;role='package'}) + [void](Get-HeldIdentity $directory $true); [void](Get-HeldSecurity $directory 'package') + if ($cursor.FullName.TrimEnd('\') -ieq $authorityRoot) {$rootSeen=$true; break}; $cursor=$cursor.Parent + } + if (!$rootSeen) {throw 'ancestor-root'} + $bytes=Read-Held $held ([int64]$policy.size); $sha=[Security.Cryptography.SHA256]::Create() + try {$digest=Hex-Bytes $sha.ComputeHash($bytes)} finally {$sha.Dispose()} + if ($digest -cne $policy.sha256) {throw 'hash'} + $signature=Test-Signature $heldHandle (Get-FinalPath $heldHandle) $bytes $false $policy.production $policy.publisher + $selfPath=[Diagnostics.Process]::GetCurrentProcess().MainModule.FileName + $self=[IO.File]::Open($selfPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $selfHandle=$self.SafeFileHandle.DangerousGetHandle() + if (!(Get-FinalPath $selfHandle).EndsWith('\System32\WindowsPowerShell\v1.0\powershell.exe',[StringComparison]::OrdinalIgnoreCase)) {throw 'self-path'} + $selfIdentity=Get-HeldIdentity $selfHandle $false; [void](Get-HeldSecurity $selfHandle 'os') + $selfCursor=[IO.Directory]::GetParent($selfPath); $selfRoot=$selfCursor.Parent.Parent.Parent.FullName.TrimEnd('\'); $selfRootSeen=$false + while ($selfCursor) { + $selfDirectory=$native::CreateFileW($selfCursor.FullName,0x80 -bor 0x20000,1,[IntPtr]::Zero,3,0x2200000,[IntPtr]::Zero) + if ($selfDirectory -eq [IntPtr](-1)) {throw 'self-ancestor'}; $ancestorHandles.Add([pscustomobject]@{handle=$selfDirectory;role='os'}) + [void](Get-HeldIdentity $selfDirectory $true); [void](Get-HeldSecurity $selfDirectory 'os') + if ($selfCursor.FullName.TrimEnd('\') -ieq $selfRoot) {$selfRootSeen=$true; break}; $selfCursor=$selfCursor.Parent + } + if (!$selfRootSeen) {throw 'self-root'} + $selfCatalog=Get-SystemCatalogProof $selfHandle $selfRoot + [Console]::Out.WriteLine((@{sha256=$digest;size=[int64]$bytes.Length;volumeSerial=$heldIdentity.volumeSerial;fileId128=$heldIdentity.fileId128; + nodeDev=$heldIdentity.nodeDev;nodeIno=$heldIdentity.nodeIno;ownerSid=$security.ownerSid;daclProtected=$security.daclProtected;reparseTag=$heldIdentity.reparseTag; + subject=$signature.subject;certificate=$signature.certificate;selfCertificate=$selfCatalog.signature.certificate;selfRootCertificate=$selfCatalog.signature.rootCertificate; + selfSubject=$selfCatalog.signature.subject;selfCatalogName=$selfCatalog.name;selfCatalogSha256=$selfCatalog.sha256; + selfCatalogVolumeSerial=$selfCatalog.volumeSerial;selfCatalogFileId128=$selfCatalog.fileId128}|ConvertTo-Json -Compress)) + [Console]::Out.Flush(); if ([Console]::In.ReadLine() -cne 'release') {throw 'release'} + # Re-prove every retained capability after Node has initialized the bootstrap + # and launcher. No catalog/member/ACL swap at any held barrier can be hidden + # behind the earlier JSON record. + $heldFinal=Get-HeldIdentity $heldHandle $false; $loadFinal=Get-HeldIdentity $loadHandle $false + if ($heldFinal.volumeSerial -cne $heldIdentity.volumeSerial -or $heldFinal.fileId128 -cne $heldIdentity.fileId128 -or + $loadFinal.volumeSerial -cne $loadIdentity.volumeSerial -or $loadFinal.fileId128 -cne $loadIdentity.fileId128) {throw 'final-identity'} + [void](Get-HeldSecurity $heldHandle 'package'); [void](Get-HeldSecurity $loadHandle 'package') + $finalBytes=Read-Held $held ([int64]$policy.size); $finalSha=[Security.Cryptography.SHA256]::Create() + try {$finalDigest=Hex-Bytes $finalSha.ComputeHash($finalBytes)} finally {$finalSha.Dispose()} + if ($finalDigest -cne $digest -or (Get-FinalPath $heldHandle) -cne (Get-FinalPath $loadHandle)) {throw 'final-bootstrap'} + $selfFinal=Get-HeldIdentity $selfHandle $false; [void](Get-HeldSecurity $selfHandle 'os') + if ($selfFinal.volumeSerial -cne $selfIdentity.volumeSerial -or $selfFinal.fileId128 -cne $selfIdentity.fileId128) {throw 'final-self'} + foreach ($catalogLease in $catalogLeases) { + $catalogHandle=$catalogLease.stream.SafeFileHandle.DangerousGetHandle() + $catalogFinal=Get-HeldIdentity $catalogHandle $false; [void](Get-HeldSecurity $catalogHandle 'os') + $catalogFinalPath=Get-FinalPath $catalogHandle + if ($catalogFinal.volumeSerial -cne $catalogLease.volumeSerial -or $catalogFinal.fileId128 -cne $catalogLease.fileId128 -or + !$catalogFinalPath.EndsWith($catalogLease.path,[StringComparison]::OrdinalIgnoreCase)) {throw 'final-catalog'} + $catalogBytes=Read-Held $catalogLease.stream $catalogLease.length 33554432; $catalogSha=[Security.Cryptography.SHA256]::Create() + try {$catalogDigest=Hex-Bytes $catalogSha.ComputeHash($catalogBytes)} finally {$catalogSha.Dispose()} + if ($catalogDigest -cne $catalogLease.sha256) {throw 'final-catalog'} + } + foreach ($lease in $ancestorHandles) {[void](Get-HeldSecurity $lease.handle $lease.role)} +} finally { + if ($self) {$self.Dispose()} + foreach ($catalogLease in $catalogLeases) { + if ($catalogLease.catalog -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseCatalogContext($catalogLease.admin,$catalogLease.catalog,0)} + if ($catalogLease.admin -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseContext($catalogLease.admin,0)} + $catalogLease.stream.Dispose() + } + foreach ($lease in $ancestorHandles) {[void]$native::CloseHandle($lease.handle)}; $load.Dispose(); $held.Dispose() +} +`; + +const helperError = (stage: WindowsAuthorityCompileStage): WindowsAuthorityBootstrapError => + new WindowsAuthorityBootstrapError('MALFORMED_OUTPUT', WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf(stage)); + +const decodeAuthenticatedSystemRoot = (record: unknown): string => { + if (!Buffer.isBuffer(record) || record.length !== 2 + (520 * 2)) throw helperError('HELPER_IDENTITY'); + const length = record.readUInt16LE(0); + if (length < 3 || length >= 520) throw helperError('HELPER_IDENTITY'); + const pathBytes = record.subarray(2, 2 + (length * 2)); + if (record.subarray(2 + (length * 2)).some(byte => byte !== 0)) throw helperError('HELPER_IDENTITY'); + const path = pathBytes.toString('utf16le'); + if (!/^[A-Za-z]:\\[^\0]+$/.test(path) || path.startsWith('\\\\') || path.includes('\0') + || path.indexOf(':', 2) >= 0) throw helperError('HELPER_IDENTITY'); + return path; +}; + +const helperDirectory = (): string => { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + if (resourcesPath && isAbsolute(resourcesPath)) return join(resourcesPath, 'windows-authority'); + return fileURLToPath(new URL('../build/windows-authority', import.meta.url)); +}; + +const embeddedExpectedPublisher = (): string | undefined => { + if (process.platform !== 'win32' || typeof __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__ === 'undefined') return undefined; + return __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__ || undefined; +}; + +const embeddedExpectedSignerPins = (): readonly string[] => { + if (process.platform !== 'win32' || typeof __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__ === 'undefined') return []; + return __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__; +}; + +export const validateBootstrapIdentityRecordForTest = ( + value: unknown, + policy: { size: number; sha256: string }, + nodeIdentity: { dev: string; ino: string }, +): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const record = value as Record; + return exactRecordKeys(record, ['sha256', 'size', 'volumeSerial', 'fileId128', 'nodeDev', 'nodeIno', + 'ownerSid', 'daclProtected', 'reparseTag', 'subject', 'certificate', 'selfSubject', 'selfCertificate', 'selfRootCertificate', + 'selfCatalogName', 'selfCatalogSha256', 'selfCatalogVolumeSerial', 'selfCatalogFileId128']) + && record.sha256 === policy.sha256 && record.size === policy.size + && /^[a-f0-9]{16}$/.test(String(record.volumeSerial)) + && /^[a-f0-9]{32}$/.test(String(record.fileId128)) + && record.nodeDev === nodeIdentity.dev && record.nodeIno === nodeIdentity.ino + && ['S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'] + .includes(String(record.ownerSid)) + && record.daclProtected === true && record.reparseTag === '00000000' + && typeof record.selfSubject === 'string' && typeof record.selfCertificate === 'string' + && typeof record.selfRootCertificate === 'string' + && typeof record.selfCatalogName === 'string' && record.selfCatalogName.length <= 260 + && /^[a-f0-9]{64}$/.test(String(record.selfCatalogSha256)) + && /^[a-f0-9]{16}$/.test(String(record.selfCatalogVolumeSerial)) + && /^[a-f0-9]{32}$/.test(String(record.selfCatalogFileId128)) + && MICROSOFT_SYSTEM_CATALOG_POLICY.some(approved => approved.member === 'powershell.exe' + && approved.catalog === record.selfCatalogName && approved.catalogSha256 === record.selfCatalogSha256); +}; + +const acquireBootstrapPackageAuthority = async ( + path: string, + policy: WindowsNativeLauncherPolicy, + allowUnsignedValidation: boolean, + nodeIdentity: { dev: string; ino: string }, + heldHandle: FileHandle, +): Promise<() => Promise> => { + if (process.platform !== 'win32' || (policy.trust !== 'production-signed' && !allowUnsignedValidation)) { + throw helperError('HELPER_OWNER_DACL'); + } + const loader = '$p=[Console]::In.ReadLine();$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p));&([ScriptBlock]::Create($s))'; + const child = spawn(KERNEL_SYSTEM_POWERSHELL, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-Command', loader], { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe', heldHandle.fd], + // An explicit empty environment proves no hostile command/root variable is + // authority. The verifier obtains System32 from its own authenticated image. + env: {}, + }); + const childInput = child.stdin; + const childOutput = child.stdout; + const childError = child.stderr; + if (!childInput || !childOutput || !childError) { + if (!child.killed) child.kill(); + throw helperError('HELPER_OWNER_DACL'); + } + let output = Buffer.alloc(0); + let errorOutput = 0; + const cleanup = (): void => { if (!child.killed) child.kill(); }; + const proofPromise = new Promise>((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }, 30_000); + const reject = (): void => { clearTimeout(timer); cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }; + child.once('error', reject); + child.once('exit', reject); + childError.on('data', (chunk: Buffer) => { + errorOutput += chunk.length; + if (errorOutput > 0) reject(); + }); + childOutput.on('data', (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + if (output.length > 16 * 1024) { reject(); return; } + const newline = output.indexOf(0x0a); + if (newline < 0) return; + if (output.subarray(newline + 1).some(byte => byte !== 0x0d && byte !== 0x0a)) { reject(); return; } + clearTimeout(timer); + try { resolvePromise(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(output.subarray(0, newline)))); } + catch { reject(); } + }); + }); + const wirePolicy = Buffer.from(JSON.stringify({ + path, + size: policy.size, + sha256: policy.sha256, + production: policy.trust === 'production-signed', + authorityRoot: dirname(path), + nodeDev: nodeIdentity.dev, + nodeIno: nodeIdentity.ino, + }), 'utf8').toString('base64'); + childInput.write(`${Buffer.from(BOOTSTRAP_AUTHORITY_SCRIPT, 'utf8').toString('base64')}\n${wirePolicy}\n`); + let record: Record; + try { record = await proofPromise; } catch (error) { cleanup(); throw error; } + if (!validateBootstrapIdentityRecordForTest(record, policy, nodeIdentity)) { + cleanup(); throw helperError('HELPER_IDENTITY'); + } + try { + const selfCertificate = new X509Certificate(Buffer.from(String(record.selfCertificate), 'base64')); + const selfRoot = new X509Certificate(Buffer.from(String(record.selfRootCertificate), 'base64')); + const selfCertificateSha256 = selfCertificate.fingerprint256.replaceAll(':', '').toLowerCase(); + const selfSpkiSha256 = createHash('sha256').update( + selfCertificate.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + const selfRootSpkiSha256 = createHash('sha256').update( + selfRoot.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + const approvedCatalog = MICROSOFT_SYSTEM_CATALOG_POLICY.some(approved => + approved.member === 'powershell.exe' + && approved.catalog === record.selfCatalogName + && approved.publisher === record.selfSubject + && approved.certificateSha256 === selfCertificateSha256 + && approved.spkiSha256 === selfSpkiSha256 + && approved.catalogSha256 === record.selfCatalogSha256); + if (!approvedCatalog || !MICROSOFT_SYSTEM_ROOT_SPKI_SHA256.has(selfRootSpkiSha256)) { + throw new Error('untrusted verifier'); + } + } catch { cleanup(); throw helperError('HELPER_OWNER_DACL'); } + if (policy.trust === 'production-signed') { + if (record.subject !== policy.publisher || typeof record.certificate !== 'string') { + cleanup(); throw helperError('HELPER_OWNER_DACL'); + } + let certificateSha256: string; + let spkiSha256: string; + try { + const certificate = new X509Certificate(Buffer.from(record.certificate, 'base64')); + certificateSha256 = certificate.fingerprint256.replaceAll(':', '').toLowerCase(); + spkiSha256 = createHash('sha256').update( + certificate.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + } catch { cleanup(); throw helperError('HELPER_OWNER_DACL'); } + if (certificateSha256 !== policy.signerCertificateSha256 || spkiSha256 !== policy.signerSpkiSha256 + || !policy.signerPins.some(pin => pin === `certificate-sha256:${certificateSha256}` + || pin === `spki-sha256:${spkiSha256}`)) { + cleanup(); throw helperError('HELPER_OWNER_DACL'); + } + } + return async () => { + childInput.end('release\n'); + await new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }, 5_000); + child.once('exit', (code, signal) => { + clearTimeout(timer); + if (code === 0 && signal === null && errorOutput === 0) resolvePromise(); + else rejectPromise(helperError('HELPER_OWNER_DACL')); + }); + }); + }; +}; + +const exactRecordKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + +export const parseWindowsAuthorityHelperManifestForTest = (bytes: Buffer): WindowsAuthorityHelperManifest => { + if (!Buffer.isBuffer(bytes) || bytes.length <= 1 || bytes.length > HELPER_MANIFEST_BYTES + || bytes[bytes.length - 1] !== 0x0a) throw helperError('MANIFEST'); + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, -1)); } + catch { throw helperError('MANIFEST'); } + let value: unknown; + try { value = JSON.parse(text); } catch { throw helperError('MANIFEST'); } + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw helperError('MANIFEST'); + const manifest = value as Record; + const compiler = manifest.compiler; + const launcher = manifest.launcher; + const bootstrap = manifest.bootstrap; + if (!exactRecordKeys(manifest, HELPER_MANIFEST_KEYS) + || typeof compiler !== 'object' || compiler === null || Array.isArray(compiler) + || typeof launcher !== 'object' || launcher === null || Array.isArray(launcher) + || typeof bootstrap !== 'object' || bootstrap === null || Array.isArray(bootstrap) + || !exactRecordKeys(compiler as Record, ['kind', 'framework']) + || !exactRecordKeys(launcher as Record, [ + 'name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', 'publisher', 'signerPins', + 'signerCertificateSha256', 'signerSpkiSha256', + ]) + || !exactRecordKeys(bootstrap as Record, [ + 'name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', 'publisher', 'signerPins', + 'signerCertificateSha256', 'signerSpkiSha256', + ]) + || manifest.schemaVersion !== 1 || manifest.name !== HELPER_NAME || manifest.format !== 'PE32' + || manifest.architecture !== 'anycpu' || manifest.machine !== 'I386' || manifest.clr !== true + || !Number.isSafeInteger(manifest.size) || Number(manifest.size) <= 0 || Number(manifest.size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String(manifest.sha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.sourceSha256)) + || manifest.protocol !== 'propr-windows-authority-v1' + || !['unsigned-validation', 'production-signed'].includes(String(manifest.trust)) + || (manifest.trust === 'unsigned-validation' && manifest.publisher !== null) + || (manifest.trust === 'production-signed' + && (typeof manifest.publisher !== 'string' || manifest.publisher.length <= 0 || manifest.publisher.length > 512)) + || !Array.isArray(manifest.signerPins) || manifest.signerPins.length > 16 + || manifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(manifest.signerPins).size !== manifest.signerPins.length + || manifest.signerPins.join(',') !== [...manifest.signerPins].sort().join(',') + || (manifest.trust === 'unsigned-validation' + && (manifest.signerPins.length !== 0 || manifest.signerCertificateSha256 !== null + || manifest.signerSpkiSha256 !== null)) + || (manifest.trust === 'production-signed' + && (manifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(manifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.signerSpkiSha256)) + || !manifest.signerPins.some(pin => pin === `certificate-sha256:${manifest.signerCertificateSha256}` + || pin === `spki-sha256:${manifest.signerSpkiSha256}`))) + || (launcher as Record).name !== LAUNCHER_NAME + || (launcher as Record).format !== 'PE' + || !['x64', 'arm64'].includes(String((launcher as Record).architecture)) + || ((launcher as Record).architecture === 'x64' + ? (launcher as Record).machine !== 'AMD64' + : (launcher as Record).machine !== 'ARM64') + || !Number.isSafeInteger((launcher as Record).size) + || Number((launcher as Record).size) <= 0 + || Number((launcher as Record).size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String((launcher as Record).sha256)) + || (launcher as Record).trust !== manifest.trust + || (launcher as Record).publisher !== manifest.publisher + || JSON.stringify((launcher as Record).signerPins) !== JSON.stringify(manifest.signerPins) + || (launcher as Record).signerCertificateSha256 !== manifest.signerCertificateSha256 + || (launcher as Record).signerSpkiSha256 !== manifest.signerSpkiSha256 + || (bootstrap as Record).name !== BOOTSTRAP_NAME + || (bootstrap as Record).format !== 'PE' + || (bootstrap as Record).architecture !== (launcher as Record).architecture + || (bootstrap as Record).machine !== (launcher as Record).machine + || !Number.isSafeInteger((bootstrap as Record).size) + || Number((bootstrap as Record).size) <= 0 + || Number((bootstrap as Record).size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String((bootstrap as Record).sha256)) + || (bootstrap as Record).trust !== manifest.trust + || (bootstrap as Record).publisher !== manifest.publisher + || JSON.stringify((bootstrap as Record).signerPins) !== JSON.stringify(manifest.signerPins) + || (bootstrap as Record).signerCertificateSha256 !== manifest.signerCertificateSha256 + || (bootstrap as Record).signerSpkiSha256 !== manifest.signerSpkiSha256 + || (compiler as Record).kind !== 'windows-fixed-system-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(String((compiler as Record).framework))) { + throw helperError('MANIFEST'); + } + return manifest as unknown as WindowsAuthorityHelperManifest; +}; + +export const inspectWindowsAuthorityHelperPeForTest = (bytes: Buffer): void => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > HELPER_MAX_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) throw helperError('HELPER_HASH'); + const pe = bytes.readUInt32LE(0x3c); + if (pe < 0x40 || pe + 248 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0' + || bytes.readUInt16LE(pe + 4) !== 0x14c || bytes.readUInt16LE(pe + 24) !== 0x10b) { + throw helperError('HELPER_HASH'); + } + const sectionCount = bytes.readUInt16LE(pe + 6); + const optionalSize = bytes.readUInt16LE(pe + 20); + const clrDirectory = pe + 24 + 96 + (14 * 8); + const clrRva = bytes.readUInt32LE(clrDirectory); + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 + || clrDirectory + 8 > pe + 24 + optionalSize || clrRva === 0 + || bytes.readUInt32LE(clrDirectory + 4) < 72) { + throw helperError('HELPER_HASH'); + } + const sectionTable = pe + 24 + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > bytes.length) throw helperError('HELPER_HASH'); + const virtualSize = bytes.readUInt32LE(section + 8); + const virtualAddress = bytes.readUInt32LE(section + 12); + const rawSize = bytes.readUInt32LE(section + 16); + const rawAddress = bytes.readUInt32LE(section + 20); + const span = Math.max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva < virtualAddress + span) { + clrOffset = rawAddress + clrRva - virtualAddress; + } + } + if (clrOffset < 0 || clrOffset + 20 > bytes.length) throw helperError('HELPER_HASH'); + const corFlags = bytes.readUInt32LE(clrOffset + 16); + if ((corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) throw helperError('HELPER_HASH'); +}; + +export const inspectWindowsNativeLauncherPeForTest = (bytes: Buffer, architecture: 'x64' | 'arm64'): void => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > HELPER_MAX_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) throw helperError('HELPER_HASH'); + const pe = bytes.readUInt32LE(0x3c); + const expectedMachine = architecture === 'arm64' ? 0xaa64 : 0x8664; + if (pe < 0x40 || pe + 24 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0' + || bytes.readUInt16LE(pe + 4) !== expectedMachine) throw helperError('HELPER_HASH'); +}; + +const readHeldExactly = async (handle: FileHandle, size: number, stage: WindowsAuthorityCompileStage): Promise => { + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const result = await handle.read(bytes, offset, size - offset, offset).catch(() => { throw helperError(stage); }); + if (result.bytesRead <= 0) throw helperError(stage); + offset += result.bytesRead; + } + return bytes; +}; + +const proveCanonicalTree = async (root: string, target: string): Promise<{ + path: string; + identity: { dev: bigint; ino: bigint; size: bigint; nlink: bigint }; +}> => { + const canonicalRoot = await realpath(root).catch(() => { throw helperError('HELPER_REPARSE'); }); + const canonicalTarget = await realpath(target).catch(() => { throw helperError('HELPER_REPARSE'); }); + const samePath = (left: string, right: string): boolean => process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; + if (!samePath(resolve(root), canonicalRoot) || !samePath(resolve(target), canonicalTarget)) throw helperError('HELPER_REPARSE'); + const inside = relative(canonicalRoot, canonicalTarget); + if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw helperError('HELPER_REPARSE'); + let cursor = canonicalRoot; + for (const part of inside.split(sep)) { + cursor = join(cursor, part); + const stats = await lstat(cursor, { bigint: true }).catch(() => { throw helperError('HELPER_REPARSE'); }); + if (stats.isSymbolicLink() || (!stats.isDirectory() && cursor !== canonicalTarget)) throw helperError('HELPER_REPARSE'); + } + const stats = await lstat(canonicalTarget, { bigint: true }).catch(() => { throw helperError('HELPER_REPARSE'); }); + return { path: canonicalTarget, identity: { dev: stats.dev, ino: stats.ino, size: stats.size, nlink: stats.nlink } }; +}; + +const authenticateWindowsAuthorityHelper = async ( + directory = helperDirectory(), + beforeOpenForTest?: () => void | Promise, + expectedPublisher = embeddedExpectedPublisher(), + expectedSignerPins = embeddedExpectedSignerPins(), + nativeLoadFaultForTest?: 'barrier-before-module-load-swap' | 'barrier-before-module-load-write' + | 'barrier-before-module-load-delete', + allowUnsignedBootstrapForValidation = expectedPublisher === undefined && directory === helperDirectory(), +): Promise => { + if (!isAbsolute(directory) || directory.indexOf(':', 2) >= 0) throw helperError('MANIFEST'); + const executableProof = await proveCanonicalTree(directory, join(directory, HELPER_NAME)); + const launcherProof = await proveCanonicalTree(directory, join(directory, LAUNCHER_NAME)); + const bootstrapProof = await proveCanonicalTree(directory, join(directory, BOOTSTRAP_NAME)); + const manifestProof = await proveCanonicalTree(directory, join(directory, HELPER_MANIFEST_NAME)); + await beforeOpenForTest?.(); + let executableHandle: FileHandle | undefined; + let launcherHandle: FileHandle | undefined; + let bootstrapHandle: FileHandle | undefined; + let manifestHandle: FileHandle | undefined; + try { + manifestHandle = await open(manifestProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('MANIFEST'); }); + const manifestStats = await manifestHandle.stat({ bigint: true }); + if (!manifestStats.isFile() || manifestStats.dev !== manifestProof.identity.dev || manifestStats.ino !== manifestProof.identity.ino + || manifestStats.nlink !== 1n || manifestStats.size <= 1n + || manifestStats.size > BigInt(HELPER_MANIFEST_BYTES)) throw helperError('MANIFEST'); + const manifest = parseWindowsAuthorityHelperManifestForTest( + await readHeldExactly(manifestHandle, Number(manifestStats.size), 'MANIFEST'), + ); + if (expectedPublisher + ? manifest.trust !== 'production-signed' || manifest.publisher !== expectedPublisher + : manifest.trust !== 'unsigned-validation' || manifest.publisher !== null) throw helperError('MANIFEST'); + if (expectedPublisher && JSON.stringify(manifest.signerPins) !== JSON.stringify(expectedSignerPins)) { + throw helperError('MANIFEST'); + } + executableHandle = await open(executableProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const before = await executableHandle.stat({ bigint: true }); + if (!before.isFile() || before.dev !== executableProof.identity.dev || before.ino !== executableProof.identity.ino + || before.nlink !== 1n || before.size !== BigInt(manifest.size)) throw helperError('HELPER_IDENTITY'); + const bytes = await readHeldExactly(executableHandle, manifest.size, 'HELPER_HASH'); + inspectWindowsAuthorityHelperPeForTest(bytes); + if (createHash('sha256').update(bytes).digest('hex') !== manifest.sha256) throw helperError('HELPER_HASH'); + const after = await executableHandle.stat({ bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.nlink !== after.nlink) throw helperError('HELPER_IDENTITY'); + launcherHandle = await open(launcherProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const launcherBefore = await launcherHandle.stat({ bigint: true }); + if (!launcherBefore.isFile() || launcherBefore.dev !== launcherProof.identity.dev + || launcherBefore.ino !== launcherProof.identity.ino || launcherBefore.nlink !== 1n + || launcherBefore.size !== BigInt(manifest.launcher.size) + || manifest.launcher.architecture !== process.arch) throw helperError('HELPER_IDENTITY'); + const launcherBytes = await readHeldExactly(launcherHandle, manifest.launcher.size, 'HELPER_HASH'); + inspectWindowsNativeLauncherPeForTest(launcherBytes, manifest.launcher.architecture); + if (createHash('sha256').update(launcherBytes).digest('hex') !== manifest.launcher.sha256) { + throw helperError('HELPER_HASH'); + } + const launcherAfter = await launcherHandle.stat({ bigint: true }); + if (launcherAfter.dev !== launcherBefore.dev || launcherAfter.ino !== launcherBefore.ino + || launcherAfter.size !== launcherBefore.size || launcherAfter.nlink !== launcherBefore.nlink) { + throw helperError('HELPER_IDENTITY'); + } + bootstrapHandle = await open(bootstrapProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const bootstrapBefore = await bootstrapHandle.stat({ bigint: true }); + if (!bootstrapBefore.isFile() || bootstrapBefore.dev !== bootstrapProof.identity.dev + || bootstrapBefore.ino !== bootstrapProof.identity.ino || bootstrapBefore.nlink !== 1n + || bootstrapBefore.size !== BigInt(manifest.bootstrap.size)) throw helperError('HELPER_IDENTITY'); + const bootstrapBytes = await readHeldExactly(bootstrapHandle, manifest.bootstrap.size, 'HELPER_HASH'); + inspectWindowsNativeLauncherPeForTest(bootstrapBytes, manifest.bootstrap.architecture); + if (createHash('sha256').update(bootstrapBytes).digest('hex') !== manifest.bootstrap.sha256) { + throw helperError('HELPER_HASH'); + } + const bootstrapAfter = await bootstrapHandle.stat({ bigint: true }); + if (bootstrapAfter.dev !== bootstrapBefore.dev || bootstrapAfter.ino !== bootstrapBefore.ino + || bootstrapAfter.size !== bootstrapBefore.size || bootstrapAfter.nlink !== bootstrapBefore.nlink) { + throw helperError('HELPER_IDENTITY'); + } + // The kernel SystemRoot namespace selects and the OS-serviced policy + // authenticates the verifier without consulting process environment roots. + // Its held bootstrap lease spans N-API initialization and launcher loading. + const releaseBootstrapAuthority = await acquireBootstrapPackageAuthority( + bootstrapProof.path, + manifest.bootstrap, + allowUnsignedBootstrapForValidation, + { dev: bootstrapBefore.dev.toString(), ino: bootstrapBefore.ino.toString() }, + bootstrapHandle, + ); + let bootstrap: WindowsNativeBootstrap; + let nativeLauncher: WindowsNativeLauncher; + try { + if (require.cache[bootstrapProof.path]) throw helperError('HELPER_OPEN'); + bootstrap = require(bootstrapProof.path) as WindowsNativeBootstrap; + if (!bootstrap || typeof bootstrap.loadVerifiedModule !== 'function') throw helperError('HELPER_OPEN'); + nativeLauncher = bootstrap.loadVerifiedModule({ + path: launcherProof.path, + size: manifest.launcher.size, + sha256: manifest.launcher.sha256, + production: manifest.launcher.trust === 'production-signed', + authenticationMode: 'runtime', + publisher: manifest.launcher.publisher, + signerCertificateSha256: manifest.launcher.signerCertificateSha256, + signerSpkiSha256: manifest.launcher.signerSpkiSha256, + fault: nativeLoadFaultForTest ?? null, + }); + } catch { throw helperError('HELPER_IDENTITY'); } + finally { await releaseBootstrapAuthority(); } + if (!nativeLauncher || typeof nativeLauncher.probeSystemDirectory !== 'function' + || typeof nativeLauncher.protectPrivateDirectory !== 'function') throw helperError('HELPER_IDENTITY'); + let systemRoot: string; + try { + systemRoot = decodeAuthenticatedSystemRoot(nativeLauncher.probeSystemDirectory({ + systemRoot: '', + windir: '', + fault: null, + })); + } catch { throw helperError('HELPER_IDENTITY'); } + return { executable: executableProof.path, systemRoot, executableHandle, launcherHandle, bootstrapHandle, + manifestHandle, manifest, launcher: nativeLauncher }; + } catch (error) { + await executableHandle?.close().catch(() => undefined); + await launcherHandle?.close().catch(() => undefined); + await bootstrapHandle?.close().catch(() => undefined); + await manifestHandle?.close().catch(() => undefined); + throw error; + } +}; + +export const authenticateWindowsAuthorityHelperForTest = authenticateWindowsAuthorityHelper; + +const activeSessionTempDirectories = new Set(); +let lastRemovedSessionTempDirectory: string | undefined; + +const createPrivateSessionTempDirectory = async (helper: AuthenticatedWindowsAuthorityHelper): Promise => { + let created: string | undefined; + try { + const parent = tmpdir(); + if (!isAbsolute(parent) || parent.indexOf(':', 2) >= 0) throw helperError('TRANSPORT_SPAWN'); + created = await mkdtemp(join(parent, 'propr-windows-authority-session-')); + const canonical = await realpath(created); + const samePath = process.platform === 'win32' + ? resolve(created).toLowerCase() === canonical.toLowerCase() + : resolve(created) === canonical; + const before = await lstat(canonical, { bigint: true }); + if (!samePath || !before.isDirectory() || before.isSymbolicLink() + || helper.launcher.protectPrivateDirectory({ path: canonical }) !== true) { + throw helperError('TRANSPORT_SPAWN'); + } + const after = await lstat(canonical, { bigint: true }); + if (!after.isDirectory() || after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino) { + throw helperError('TRANSPORT_SPAWN'); + } + return canonical; + } catch (error) { + if (created) await rm(created, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +}; + +const spawnBroker = ( + helper: AuthenticatedWindowsAuthorityHelper, + sessionTempDirectory: string, +): BrokerChild => { + const child = spawn(helper.executable, ['--broker'], { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + cwd: sessionTempDirectory, + env: { + SystemRoot: helper.systemRoot, + TEMP: sessionTempDirectory, + TMP: sessionTempDirectory, + }, + }); + if (!child.stdin || !child.stdout || !child.stderr) { + if (!child.killed) child.kill(); + throw helperError('TRANSPORT_SPAWN'); + } + return child as BrokerChild; +}; + +class WindowsAuthorityError extends Error { + constructor(readonly reason: WindowsAuthorityReason, readonly scenario: number) { + super(`Verified update cache authority inspection failed [win-authority:${reason}:${scenario}]`); + } +} + +export type WindowsAuthorityBootstrapFailureKind = + | 'SPAWN_ERROR' + | 'EXIT_NO_OUTPUT' + | 'EXIT_AFTER_OUTPUT' + | 'TIMEOUT' + | 'MALFORMED_OUTPUT' + | 'EXTRA_OUTPUT' + | 'STAGE_CHANNEL' + | 'WRITE_ERROR'; + +export class WindowsAuthorityBootstrapError extends WindowsAuthorityError { + readonly stage: WindowsAuthorityCompileStage; + + constructor(readonly kind: WindowsAuthorityBootstrapFailureKind, stageIndex: number) { + super('compile_load', stageIndex); + this.stage = WINDOWS_AUTHORITY_COMPILE_STAGES[stageIndex] ?? 'TRANSPORT_SPAWN'; + } +} + +const authorityError = (reason: WindowsAuthorityReason, scenario: number): WindowsAuthorityError => + new WindowsAuthorityError(reason, scenario); + +const abortError = (): Error => Object.assign(new Error('Windows authority request aborted'), { name: 'AbortError' }); + +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) throw abortError(); +}; + +const hasExactKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + +const parseFailure = (value: unknown, expectedId?: string): Error | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + const keys = expectedId === undefined + ? ['version', 'type', 'reason', 'scenario'] + : ['version', 'type', 'id', 'reason', 'scenario']; + if (candidate.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || candidate.type !== 'error' + || !hasExactKeys(candidate, keys) || (expectedId !== undefined && candidate.id !== expectedId) + || typeof candidate.reason !== 'string' || !reasonCodes.has(candidate.reason) + || !Number.isInteger(candidate.scenario) || Number(candidate.scenario) < 0 || Number(candidate.scenario) > 99) { + return undefined; + } + return authorityError(candidate.reason as WindowsAuthorityReason, Number(candidate.scenario)); +}; + +const parseInspection = ( + value: unknown, + directory: boolean, + hashes: boolean, +): WindowsPrivatePathInspection | WindowsHeldVerification | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + if (candidate.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION + || !/^[a-f0-9]{16}$/.test(String(candidate.volumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(candidate.fileId128)) + || candidate.directory !== directory + || !/^(0|[1-9]\d*)$/.test(String(candidate.links)) + || !/^(0|[1-9]\d*)$/.test(String(candidate.size)) + || (!hashes && !directory && BigInt(String(candidate.size)) > BigInt(BROKER_SETUP_FILE_BYTES)) + || !/^[a-f0-9]{8}$/.test(String(candidate.reparseTag)) + || candidate.reparseTag !== '00000000' + || !/^S-1-(?:\d+-){1,14}\d+$/.test(String(candidate.ownerSid)) + || candidate.daclProtected !== true + || !/^(0|[1-9]\d*)$/.test(String(candidate.aceCount)) + || candidate.inheritedWriteAces !== '0' + || candidate.broadWriteAces !== '0' + || (hashes && (!/^[a-f0-9]{64}$/.test(String(candidate.sha256)) + || !/^[a-f0-9]{40}$/.test(String(candidate.sha1))))) return undefined; + const inspection: WindowsPrivatePathInspection = { + identity: { + platform: 'win32', + volumeSerial: String(candidate.volumeSerial), + fileId128: String(candidate.fileId128), + }, + directory, + links: String(candidate.links), + size: String(candidate.size), + reparseTag: String(candidate.reparseTag), + ownerSid: String(candidate.ownerSid), + daclProtected: true, + aceCount: String(candidate.aceCount), + inheritedWriteAces: '0', + broadWriteAces: '0', + }; + return hashes ? { + ...inspection, + sha256: String(candidate.sha256), + sha1: String(candidate.sha1), + } : inspection; +}; + +type BrokerRequestOperation = BrokerOperation | 'hold' | 'continue' | 'read' | 'verify' | 'close' | 'fault-stderr'; +// After the authenticated image/challenge exchange, the persistent process +// accepts only four-byte-length-prefixed strict-UTF-8 versioned request frames. Node +// permits one in-flight frame at a time; a held capability owns the FIFO lease +// until close, so its native handle cannot be confused with another entry. +interface BrokerRequestFrame { + version: typeof WINDOWS_AUTHORITY_PROTOCOL_VERSION; + type: 'request'; + id: string; + operation: BrokerRequestOperation; + purpose: BrokerPurpose; + path: string | null; + directory: boolean | null; + expectedBytes: number | null; + expectedVolumeSerial: string | null; + expectedFileId128: string | null; + expectedSha256: string | null; + challenge: string | null; + barrier: string | null; + offset: number | null; + length: number | null; +} + +interface FrameWaiter { + resolve(value: Record): void; + reject(error: Error): void; + timer: NodeJS.Timeout; + signal?: AbortSignal; + abort?: () => void; +} + +interface LockedArtifactProcess { + session: WindowsAuthoritySession; + exited: Promise; + challenge: string; + heldId: string; + purpose: BrokerPurpose; + release(): void; + timeout: NodeJS.Timeout; +} + +let brokerSession: WindowsAuthoritySession | undefined; +let brokerStartup: Promise | undefined; +let compileCount = 0; +let requestCount = 0; +let restartCount = 0; +let activeProcessCount = 0; +let activeAuthenticatedHandleSets = 0; +let lastClosedHeldId: string | undefined; +const brokerChildren = new Set(); + +const encodeProtocolFrame = (value: string): Buffer => { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length <= 0 || bytes.length > BROKER_REQUEST_LINE_BYTES) throw authorityError('request_protocol', 1); + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(bytes.length); + return Buffer.concat([prefix, bytes]); +}; + +const decodeProtocolChunk = (buffered: Buffer, chunk: Buffer): { + buffered: Buffer; + frames: readonly Buffer[]; +} => { + let combined = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]); + const frames: Buffer[] = []; + while (combined.length >= 4) { + const length = combined.readUInt32BE(0); + if (length <= 0 || length > BROKER_PROTOCOL_LINE_BYTES) throw authorityError('output_bound', 17); + if (combined.length < 4 + length) break; + frames.push(combined.subarray(4, 4 + length)); + combined = combined.subarray(4 + length); + } + if (combined.length > BROKER_PROTOCOL_LINE_BYTES + 4) throw authorityError('output_bound', 17); + return { buffered: Buffer.from(combined), frames }; +}; + +class WindowsAuthoritySession { + readonly exited: Promise; + private terminalError: Error | undefined; + private buffered: Buffer = Buffer.alloc(0); + private waiter: FrameWaiter | undefined; + private stderrBytes = 0; + private stderrBuffered = ''; + private bootstrapStages: WindowsAuthorityCompileStage[] = WINDOWS_AUTHORITY_COMPILE_STAGES.slice(0, 4); + private bootstrapReady = false; + private bootstrapResolve!: () => void; + private readonly bootstrapCompleted = new Promise(resolve => { this.bootstrapResolve = resolve; }); + private inputBytes = 0; + private outputBytes = 0; + private frames = 0; + private closing = false; + private resourcesCleaned = false; + + constructor( + readonly child: BrokerChild, + private readonly sharedQueue = true, + private readonly helper?: AuthenticatedWindowsAuthorityHelper, + private readonly sessionTempDirectory?: string, + ) { + activeProcessCount++; + if (helper) activeAuthenticatedHandleSets++; + if (sessionTempDirectory) activeSessionTempDirectories.add(sessionTempDirectory); + brokerChildren.add(child); + child.stdout.on('data', (chunk: Buffer) => this.consume(chunk)); + child.stderr.on('data', (chunk: Buffer) => this.consumeBootstrapStage(chunk)); + child.stdin.on('error', () => this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('WRITE_ERROR'))); + child.on('error', () => this.invalidate(this.bootstrapReady + ? authorityError('process_exit', 19) : this.bootstrapError('SPAWN_ERROR'))); + this.exited = new Promise(resolve => child.once('close', code => { void (async () => { + activeProcessCount--; + brokerChildren.delete(child); + const clean = this.closing && code === 0 && this.stderrBuffered === '' && this.buffered.length === 0; + this.fail(clean ? authorityError('clean_shutdown', 15) + : this.bootstrapReady ? authorityError('process_exit', 19) + : this.bootstrapError(this.outputBytes === 0 ? 'EXIT_NO_OUTPUT' : 'EXIT_AFTER_OUTPUT'), false); + if (brokerSession === this) brokerSession = undefined; + await this.cleanupResources(); + resolve(); + })(); })); + child.unref(); + (child.stdin as typeof child.stdin & { unref?(): void }).unref?.(); + (child.stdout as typeof child.stdout & { unref?(): void }).unref?.(); + (child.stderr as typeof child.stderr & { unref?(): void }).unref?.(); + } + + private async cleanupResources(): Promise { + if (this.resourcesCleaned) return; + this.resourcesCleaned = true; + if (this.helper) { + await Promise.allSettled([ + this.helper.executableHandle.close(), + this.helper.launcherHandle.close(), + this.helper.bootstrapHandle.close(), + this.helper.manifestHandle.close(), + ]); + activeAuthenticatedHandleSets--; + } + if (this.sessionTempDirectory) { + await rm(this.sessionTempDirectory, { recursive: true, force: true }).catch(() => { + try { rmSync(this.sessionTempDirectory!, { recursive: true, force: true }); } catch { /* bounded exit cleanup */ } + }); + activeSessionTempDirectories.delete(this.sessionTempDirectory); + lastRemovedSessionTempDirectory = this.sessionTempDirectory; + } + } + + private bootstrapError(kind: WindowsAuthorityBootstrapFailureKind = 'EXIT_NO_OUTPUT'): WindowsAuthorityBootstrapError { + return new WindowsAuthorityBootstrapError(kind, this.bootstrapStages.length - 1); + } + + private consumeBootstrapStage(chunk: Buffer): void { + if (this.terminalError) return; + this.stderrBytes += chunk.length; + if (this.stderrBytes > BROKER_OUTPUT_BYTES || this.bootstrapReady) { + return this.invalidate(authorityError(this.stderrBytes > BROKER_OUTPUT_BYTES ? 'output_bound' : 'stdio_protocol', + this.stderrBytes > BROKER_OUTPUT_BYTES ? 17 : 16)); + } + this.stderrBuffered += chunk.toString('ascii'); + while (this.stderrBuffered.includes('\n')) { + const newline = this.stderrBuffered.indexOf('\n'); + const line = this.stderrBuffered.slice(0, newline).replace(/\r$/, ''); + this.stderrBuffered = this.stderrBuffered.slice(newline + 1); + const match = /^PROPR_BOOTSTRAP (\d{2}) ([A-Z_]+)$/.exec(line); + const expectedIndex = this.bootstrapStages.length; + const expectedStage = WINDOWS_AUTHORITY_COMPILE_STAGES[expectedIndex]; + if (!match || Number(match[1]) !== expectedIndex || match[2] !== expectedStage) { + return this.invalidate(this.bootstrapError('STAGE_CHANNEL')); + } + this.bootstrapStages.push(expectedStage); + if (expectedStage === 'READY') this.bootstrapResolve(); + } + if (this.stderrBuffered.length > 128) this.invalidate(this.bootstrapError('STAGE_CHANNEL')); + } + + async requireBootstrapReady(timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + this.bootstrapCompleted, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = this.bootstrapError('TIMEOUT'); + this.invalidate(error); + reject(error); + }, timeoutMs); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (this.terminalError || this.stderrBuffered !== '' + || this.bootstrapStages.length !== WINDOWS_AUTHORITY_COMPILE_STAGES.length) { + throw this.terminalError ?? this.bootstrapError('STAGE_CHANNEL'); + } + this.bootstrapReady = true; + } + + currentBootstrapStage(): WindowsAuthorityCompileStage { + return this.bootstrapStages[this.bootstrapStages.length - 1]; + } + + private consume(chunk: Buffer): void { + if (this.terminalError) return; + this.outputBytes += chunk.length; + if (this.outputBytes > BROKER_MAX_OUTPUT_BYTES) return this.invalidate(authorityError('output_bound', 17)); + let decoded: ReturnType; + try { decoded = decodeProtocolChunk(this.buffered, chunk); } catch (error) { + return this.invalidate(this.bootstrapReady + ? (error instanceof Error ? error : authorityError('stdio_protocol', 16)) + : this.bootstrapError('MALFORMED_OUTPUT')); + } + this.buffered = decoded.buffered; + for (const frame of decoded.frames) { + if (!this.waiter) return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('EXTRA_OUTPUT')); + let value: unknown; + try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)); } catch { + return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('MALFORMED_OUTPUT')); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('MALFORMED_OUTPUT')); + } + const waiter = this.waiter; + this.waiter = undefined; + clearTimeout(waiter.timer); + if (waiter.signal && waiter.abort) waiter.signal.removeEventListener('abort', waiter.abort); + waiter.resolve(value as Record); + } + } + + private fail(error: Error, kill: boolean): void { + this.terminalError ??= error; + if (this.waiter) { + const waiter = this.waiter; + this.waiter = undefined; + clearTimeout(waiter.timer); + if (waiter.signal && waiter.abort) waiter.signal.removeEventListener('abort', waiter.abort); + waiter.reject(this.terminalError); + } + if (this.sharedQueue) rejectBrokerQueue(this.terminalError); + if (kill && !this.child.killed) this.child.kill(); + } + + invalidate(error: Error): void { this.fail(error, true); } + + async receive(timeoutMs: number, signal?: AbortSignal, startup = false): Promise> { + throwIfAborted(signal); + if (this.terminalError) throw this.terminalError; + if (this.waiter) throw authorityError('stdio_protocol', 16); + return new Promise((resolve, reject) => { + const waiter: FrameWaiter = { + resolve, + reject, + signal, + timer: setTimeout(() => this.invalidate(startup + ? this.bootstrapError('TIMEOUT') : authorityError('timeout', 18)), timeoutMs), + }; + if (signal) { + waiter.abort = () => this.invalidate(abortError()); + signal.addEventListener('abort', waiter.abort, { once: true }); + } + this.waiter = waiter; + }); + } + + private async writeChunk(value: string | Buffer): Promise { + if (this.terminalError) throw this.terminalError; + if (this.child.stdin.write(value)) return; + await new Promise((resolve, reject) => { + const cleanup = () => { + this.child.stdin.removeListener('drain', drained); + this.child.stdin.removeListener('error', failed); + }; + const drained = () => { cleanup(); resolve(); }; + const failed = () => { cleanup(); reject(this.terminalError ?? authorityError('stdio_protocol', 16)); }; + this.child.stdin.once('drain', drained); + this.child.stdin.once('error', failed); + }); + } + + async write(value: string | BrokerRequestFrame): Promise { + if (this.terminalError) throw this.terminalError; + const frame = encodeProtocolFrame(typeof value === 'string' ? value : JSON.stringify(value)); + this.inputBytes += frame.length; + if (this.inputBytes > BROKER_MAX_INPUT_BYTES || ++this.frames > BROKER_MAX_FRAMES) { + this.invalidate(authorityError('output_bound', 17)); + throw authorityError('output_bound', 17); + } + await this.writeChunk(frame); + } + + async writeRawForTest(chunks: readonly Buffer[]): Promise { + if (this.terminalError || chunks.length === 0 + || chunks.some(chunk => chunk.length === 0 || chunk.length > BROKER_REQUEST_LINE_BYTES + 4)) { + throw authorityError('request_protocol', 1); + } + for (const chunk of chunks) await this.writeChunk(chunk); + } + + async exchange(frame: BrokerRequestFrame, signal?: AbortSignal): Promise> { + const response = this.receive(BROKER_TIMEOUT_MS, signal); + await this.write(frame); + const value = await response; + requestCount++; + const failure = parseFailure(value, frame.id) ?? parseFailure(value); + if (failure) throw failure; + if (value.id !== frame.id) { + this.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('stdio_protocol', 16); + } + return value; + } + + async shutdown(): Promise { + if (this.child.exitCode === null) { + this.closing = true; + this.child.stdin.end(); + } + let terminateTimer: NodeJS.Timeout | undefined; + let boundTimer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + this.exited, + new Promise((_resolve, reject) => { + terminateTimer = setTimeout(() => { if (!this.child.killed) this.child.kill(); }, BROKER_TIMEOUT_MS); + boundTimer = setTimeout(() => reject(authorityError('process_exit', 19)), BROKER_TIMEOUT_MS * 2); + }), + ]); + } finally { + if (terminateTimer) clearTimeout(terminateTimer); + if (boundTimer) clearTimeout(boundTimer); + } + } +} + +const exactKeys = (value: Record, keys: readonly string[]): boolean => hasExactKeys(value, keys); +const RESPONSE_INSPECTION_KEYS = Object.freeze([...INSPECTION_KEYS, 'id', 'challenge'] as const); + +const requestFrame = (operation: BrokerRequestOperation, values: Partial = {}): BrokerRequestFrame => ({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'request', + id: randomBytes(16).toString('hex'), + operation, + purpose: 'setup', + path: null, + directory: null, + expectedBytes: null, + expectedVolumeSerial: null, + expectedFileId128: null, + expectedSha256: null, + challenge: null, + barrier: null, + offset: null, + length: null, + ...values, +}); + +interface StartBrokerOptions { + countCompilation?: boolean; + helperDirectory?: string; + expectedPublisher?: string; + allowUnsignedBootstrapForValidation?: boolean; +} + +const startBroker = async (options: StartBrokerOptions = {}): Promise => { + const helper = await authenticateWindowsAuthorityHelper( + options.helperDirectory, + undefined, + options.expectedPublisher ?? embeddedExpectedPublisher(), + embeddedExpectedSignerPins(), + undefined, + options.allowUnsignedBootstrapForValidation, + ); + let child: BrokerChild; + let sessionTempDirectory: string | undefined; + try { + sessionTempDirectory = await createPrivateSessionTempDirectory(helper); + child = spawnBroker(helper, sessionTempDirectory); + } catch { + if (sessionTempDirectory) await rm(sessionTempDirectory, { recursive: true, force: true }).catch(() => undefined); + await helper.executableHandle.close().catch(() => undefined); + await helper.launcherHandle.close().catch(() => undefined); + await helper.bootstrapHandle.close().catch(() => undefined); + await helper.manifestHandle.close().catch(() => undefined); + throw new WindowsAuthorityBootstrapError('SPAWN_ERROR', + WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('TRANSPORT_SPAWN')); + } + if (options.countCompilation !== false) { + compileCount++; + if (compileCount > 1) restartCount++; + } + const session = new WindowsAuthoritySession( + child, + options.countCompilation !== false, + helper, + sessionTempDirectory, + ); + try { + const challenge = randomBytes(16).toString('hex'); + const startupDeadline = Date.now() + BROKER_STARTUP_TIMEOUT_MS; + const readyPromise = session.receive(BROKER_STARTUP_TIMEOUT_MS, undefined, true); + try { + await session.write(JSON.stringify({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'start', + challenge, + protocol: 'propr-windows-authority-v1', + })); + } catch (error) { + session.invalidate(error instanceof Error ? error : authorityError('compile_load', 0)); + throw error; + } + const ready = await readyPromise; + const failure = parseFailure(ready); + if (failure) { + session.invalidate(failure); + throw failure; + } + await session.requireBootstrapReady(Math.max(1, startupDeadline - Date.now())); + if (!exactKeys(ready, ['version', 'type', 'challenge', 'protocol', 'maxRequestBytes', 'nativeSmoke', 'compileCount', + 'imageVolumeSerial', 'imageFileId128', 'imageSha256']) + || ready.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || ready.type !== 'ready' + || ready.challenge !== challenge || ready.protocol !== 'propr-windows-authority-v1' + || ready.maxRequestBytes !== BROKER_REQUEST_LINE_BYTES || ready.nativeSmoke !== true || ready.compileCount !== 1 + || !/^[a-f0-9]{16}$/.test(String(ready.imageVolumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(ready.imageFileId128)) + || ready.imageSha256 !== helper.manifest.sha256) { + const error = new WindowsAuthorityBootstrapError('MALFORMED_OUTPUT', WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('READY')); + session.invalidate(error); + throw error; + } + return session; + } catch (error) { + session.invalidate(error instanceof Error ? error : authorityError('process_exit', 19)); + await session.shutdown().catch(() => undefined); + throw error; + } +}; + +const compileStageFromError = (error: unknown): WindowsAuthorityCompileStage => { + if (error instanceof WindowsAuthorityError && error.reason === 'compile_load' + && error.scenario >= 0 && error.scenario < WINDOWS_AUTHORITY_COMPILE_STAGES.length) { + return WINDOWS_AUTHORITY_COMPILE_STAGES[error.scenario]; + } + return 'TRANSPORT_SPAWN'; +}; + +const runWindowsAuthorityCompileProbe = async (options: StartBrokerOptions = {}): Promise => { + let session: WindowsAuthoritySession | undefined; + try { + session = await startBroker({ ...options, countCompilation: false }); + return 'READY'; + } catch (error) { + return compileStageFromError(error); + } finally { + await session?.shutdown(); + } +}; + +/** Hosted smoke of the exact build-produced executable and READY handshake. */ +export const probeWindowsAuthorityCompile = (): Promise => + runWindowsAuthorityCompileProbe(); + +export const probePackagedWindowsAuthorityHelper = (directory: string): Promise => { + if (!isAbsolute(directory)) return Promise.reject(helperError('MANIFEST')); + const expectedPublisher = process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1' + ? process.env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY + : undefined; + if (process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1' && !expectedPublisher) { + return Promise.reject(helperError('MANIFEST')); + } + return runWindowsAuthorityCompileProbe({ + helperDirectory: directory, + expectedPublisher, + allowUnsignedBootstrapForValidation: process.env.PROPR_DESKTOP_PRODUCTION_RELEASE !== '1', + }); +}; + +/** Native-test-only corrupt-output classification; no compiler diagnostics leave the build boundary. */ +export const probeWindowsAuthorityCompileFailureForTest = (): Promise => + Promise.resolve('BUILD_OUTPUT'); + +/** Native-test-only startup failure against the exact compiled production child. */ +export const probeWindowsAuthorityStartupFailureForTest = async (): Promise => { + const helper = await authenticateWindowsAuthorityHelper(); + const sessionTempDirectory = await createPrivateSessionTempDirectory(helper); + const session = new WindowsAuthoritySession(spawnBroker(helper, sessionTempDirectory), false, helper, + sessionTempDirectory); + try { + const response = session.receive(BROKER_STARTUP_TIMEOUT_MS, undefined, true); + await session.write(JSON.stringify({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'start', + challenge: randomBytes(16).toString('hex'), + protocol: 'invalid-protocol', + })); + await response; + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError && error.reason === 'ready_protocol') return 'ready_protocol'; + if (error instanceof WindowsAuthorityError && error.reason === 'compile_load' + && error.scenario === WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('READY')) return 'ready_protocol'; + if (error instanceof WindowsAuthorityBootstrapError + && error.stage === 'PROTOCOL_INIT') return 'ready_protocol'; + throw error; + } finally { + await session.shutdown(); + } +}; + +/** Native-test-only live transport faults with short local deadlines and fixed diagnostics. */ +export const injectWindowsAuthorityTransportFaultForTest = async ( + kind: 'stderr' | 'slowloris' | 'timeout', +): Promise => { + const session = await startBroker({ countCompilation: false }); + try { + if (kind === 'stderr') { + await session.exchange(requestFrame('fault-stderr')); + } else { + const response = session.receive(50); + if (kind === 'slowloris') await session.writeRawForTest([Buffer.from([0, 0, 0, 100, 0x7b])]); + await response; + } + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError) return error.reason; + throw error; + } finally { await session.shutdown(); } +}; + +const getBroker = async (): Promise => { + if (brokerSession) return brokerSession; + brokerStartup ??= startBroker().then(session => { + brokerSession = session; + return session; + }).finally(() => { brokerStartup = undefined; }); + return brokerStartup; +}; + +const retryableInfrastructureError = (error: unknown): boolean => error instanceof WindowsAuthorityError + && ['ready_protocol', 'stdio_protocol', 'output_bound', 'timeout', 'process_exit', 'clean_shutdown'].includes(error.reason); + +const withRestartOnce = async (work: (session: WindowsAuthoritySession) => Promise): Promise => { + let first: unknown; + try { return await work(await getBroker()); } catch (error) { first = error; } + if (!retryableInfrastructureError(first)) throw first; + if (brokerSession) brokerSession.invalidate(first as Error); + brokerSession = undefined; + return work(await getBroker()); +}; + +interface QueueEntry { signal?: AbortSignal; resolve(release: () => void): void; reject(error: Error): void; abort?: () => void } +const brokerQueue: QueueEntry[] = []; +let brokerLeaseActive = false; + +const rejectBrokerQueue = (error: Error): void => { + for (const entry of brokerQueue.splice(0)) { + if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort); + entry.reject(error); + } +}; + +const dispatchLease = (): void => { + if (brokerLeaseActive) return; + const entry = brokerQueue.shift(); + if (!entry) return; + if (entry.signal?.aborted) { + entry.reject(abortError()); + dispatchLease(); + return; + } + brokerLeaseActive = true; + if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort); + let released = false; + entry.resolve(() => { + if (released) return; + released = true; + brokerLeaseActive = false; + dispatchLease(); + }); +}; + +const acquireLease = (signal?: AbortSignal): Promise<() => void> => { + throwIfAborted(signal); + if (brokerQueue.length >= BROKER_MAX_QUEUE_ENTRIES) return Promise.reject(authorityError('output_bound', 17)); + return new Promise((resolve, reject) => { + const entry: QueueEntry = { signal, resolve, reject }; + if (signal) { + entry.abort = () => { + const index = brokerQueue.indexOf(entry); + if (index >= 0) brokerQueue.splice(index, 1); + reject(abortError()); + }; + signal.addEventListener('abort', entry.abort, { once: true }); + } + brokerQueue.push(entry); + dispatchLease(); + }); +}; + +const runBroker = async ( + operation: BrokerOperation, + path: string, + directory: boolean, + signal?: AbortSignal, +): Promise => { + const release = await acquireLease(signal); + try { + return await withRestartOnce(async session => { + const request = requestFrame(operation, { purpose: 'setup', path, directory }); + const value = await session.exchange(request, signal); + const inspected = parseInspection(value, directory, false); + if (!inspected || value.type !== 'inspection' || value.challenge !== '' + || !exactKeys(value, RESPONSE_INSPECTION_KEYS)) { + session.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('stdio_protocol', 16); + } + return inspected; + }); + } finally { release(); } +}; + +export const inspectWindowsPrivatePath = ( + path: string, + directory = false, + signal?: AbortSignal, +): Promise => runBroker('inspect', path, directory, signal); + +export const ensureWindowsPrivateDirectory = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('ensure-directory', path, true, signal); + +export const protectWindowsPrivateDirectory = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('protect-directory', path, true, signal); + +export const protectWindowsPrivateFile = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('protect-file', path, false, signal); + +const openWindowsLockedArtifactAttempt = async ( + path: string, + expectedBytes: number, + expectedIdentity: WindowsFileIdentity, + expectedSha256: string | undefined, + beforeOpenForTest?: () => Promise, + signal?: AbortSignal, + retry = true, +): Promise => { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > BROKER_ARTIFACT_BYTES + || !/^[a-f0-9]{16}$/.test(expectedIdentity.volumeSerial) + || !/^[a-f0-9]{32}$/.test(expectedIdentity.fileId128)) throw authorityError('request_protocol', 1); + const release = await acquireLease(signal); + let session: WindowsAuthoritySession | undefined; + let capabilityChallenge = randomBytes(16).toString('hex'); + let acquisitionBarrierRan = false; + try { + const activeSession = session = await getBroker(); + const barrierChallenge = beforeOpenForTest ? randomBytes(16).toString('hex') : null; + const hold = requestFrame('hold', { + // A zero-byte protected file is a setup capability. Every nonempty held + // file is an artifact capability, whether its hash is being learned or + // checked against an already authenticated digest. + purpose: expectedBytes === 0 ? 'setup' : 'artifact', + path, + expectedBytes, + expectedVolumeSerial: expectedIdentity.volumeSerial, + expectedFileId128: expectedIdentity.fileId128, + expectedSha256: expectedSha256 ?? null, + challenge: capabilityChallenge, + barrier: barrierChallenge, + }); + let responsePromise = activeSession.receive(BROKER_TIMEOUT_MS, signal); + await activeSession.write(hold); + let ready = await responsePromise; + if (barrierChallenge) { + if (!exactKeys(ready, ['version', 'type', 'id', 'challenge']) + || ready.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || ready.type !== 'before-open' + || ready.id !== hold.id || ready.challenge !== barrierChallenge) throw authorityError('ready_protocol', 12); + try { + await beforeOpenForTest!(); + acquisitionBarrierRan = true; + } catch (error) { + activeSession.invalidate(abortError()); + throw error; + } + const continuation = requestFrame('continue', { + id: hold.id, + purpose: hold.purpose, + challenge: capabilityChallenge, + barrier: barrierChallenge, + }); + responsePromise = activeSession.receive(BROKER_TIMEOUT_MS, signal); + await activeSession.write(continuation); + ready = await responsePromise; + } + requestCount++; + const failure = parseFailure(ready, hold.id); + if (failure) throw failure; + const initial = parseInspection(ready, false, true) as WindowsHeldVerification | undefined; + if (!initial || ready.type !== 'held' || ready.id !== hold.id || ready.challenge !== capabilityChallenge + || !exactKeys(ready, RESPONSE_INSPECTION_KEYS)) throw authorityError('ready_protocol', 12); + + let closed = false; + let commandQueue = Promise.resolve(); + const sameInitial = (candidate: WindowsHeldVerification): boolean => + candidate.identity.volumeSerial === initial.identity.volumeSerial + && candidate.identity.fileId128 === initial.identity.fileId128 + && candidate.links === initial.links && candidate.size === initial.size + && candidate.reparseTag === initial.reparseTag && candidate.ownerSid === initial.ownerSid + && candidate.aceCount === initial.aceCount + && candidate.inheritedWriteAces === initial.inheritedWriteAces + && candidate.broadWriteAces === initial.broadWriteAces + && candidate.sha256 === initial.sha256 && candidate.sha1 === initial.sha1; + const exchangeHeld = async (operation: 'read' | 'verify' | 'close', values: Partial, requestSignal?: AbortSignal) => { + let value!: Record; + const run = commandQueue.then(async () => { + throwIfAborted(requestSignal); + value = await activeSession.exchange(requestFrame(operation, { + id: hold.id, + purpose: hold.purpose, + challenge: capabilityChallenge, + ...values, + }), requestSignal); + }); + commandQueue = run.catch(() => undefined); + await run; + return value; + }; + const heldTimeout = setTimeout(() => { + activeSession.invalidate(authorityError('timeout', 18)); + release(); + }, BROKER_SESSION_TIMEOUT_MS); + const capability: WindowsLockedArtifact = { + inspection: initial, + read: async (offset, length, requestSignal) => { + if (closed || !Number.isSafeInteger(offset) || offset < 0 + || !Number.isSafeInteger(length) || length <= 0 || length > MAX_READ_BYTES + || offset + length > Number(initial.size)) throw authorityError('request_protocol', 1); + const result = await exchangeHeld('read', { offset, length }, requestSignal); + if (result.type !== 'bytes' || result.id !== hold.id || result.challenge !== capabilityChallenge + || typeof result.bytes !== 'string' + || !exactKeys(result, ['version', 'type', 'id', 'challenge', 'bytes'])) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('held_read', 13); + } + const bytes = Buffer.from(result.bytes, 'base64'); + if (bytes.length !== length || bytes.toString('base64') !== result.bytes) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('held_read', 13); + } + return bytes; + }, + verify: async requestSignal => { + if (closed) throw authorityError('final_verify', 14); + const challenge = randomBytes(16).toString('hex'); + const result = await exchangeHeld('verify', { barrier: challenge }, requestSignal); + const verified = parseInspection(result, false, true) as WindowsHeldVerification | undefined; + if (!verified || result.type !== 'verified' || result.id !== hold.id || result.challenge !== challenge + || !exactKeys(result, RESPONSE_INSPECTION_KEYS) || !sameInitial(verified)) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('final_verify', 14); + } + return verified; + }, + close: async requestSignal => { + if (closed) return; + closed = true; + clearTimeout(heldTimeout); + try { + const result = await exchangeHeld('close', {}, requestSignal); + const final = parseInspection(result, false, true) as WindowsHeldVerification | undefined; + if (!final || result.type !== 'closed' || result.id !== hold.id || result.challenge !== '' + || !exactKeys(result, RESPONSE_INSPECTION_KEYS) || !sameInitial(final)) { + throw authorityError('final_verify', 14); + } + lastClosedHeldId = hold.id; + } catch (error) { + activeSession.invalidate(error instanceof Error ? error : authorityError('clean_shutdown', 15)); + throw error; + } finally { + lockedArtifactProcesses.delete(capability); + release(); + } + }, + }; + lockedArtifactProcesses.set(capability, { + session: activeSession, + exited: activeSession.exited, + challenge: capabilityChallenge, + heldId: hold.id, + purpose: hold.purpose, + release, + timeout: heldTimeout, + }); + activeSession.exited.then(() => { + clearTimeout(heldTimeout); + release(); + }).catch(() => { + clearTimeout(heldTimeout); + release(); + }); + return capability; + } catch (error) { + release(); + if (signal?.aborted && session) session.invalidate(abortError()); + if (retry && !acquisitionBarrierRan && retryableInfrastructureError(error)) { + if (brokerSession) brokerSession.invalidate(error as Error); + brokerSession = undefined; + return openWindowsLockedArtifactAttempt( + path, + expectedBytes, + expectedIdentity, + expectedSha256, + beforeOpenForTest, + signal, + false, + ); + } + throw error; + } +}; + +export const openWindowsLockedArtifact = ( + path: string, + expectedBytes: number, + beforeOpenForTest?: () => Promise, + signal?: AbortSignal, + expectedIdentity?: WindowsFileIdentity, + expectedSha256?: string, +): Promise => (async () => { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > BROKER_ARTIFACT_BYTES) { + throw authorityError('request_protocol', 1); + } + if (expectedSha256 !== undefined && !/^[a-f0-9]{64}$/.test(expectedSha256)) throw authorityError('request_protocol', 1); + const setup = expectedIdentity ?? (await inspectWindowsPrivatePath(path)).identity; + return openWindowsLockedArtifactAttempt(path, expectedBytes, setup, expectedSha256, beforeOpenForTest, signal); +})(); + +/** Native-test-only live protocol injection against the persistent child. */ +export const injectWindowsAuthorityProtocolFaultForTest = async ( + kind: 'partial-frame' | 'extra-frame' | 'wrong-purpose' | 'wrong-identity', + path: string, + expectedBytes: number, +): Promise => { + const setup = await inspectWindowsPrivatePath(path); + const release = await acquireLease(); + try { + const session = await getBroker(); + const inspect = requestFrame('inspect', { purpose: 'setup', path, directory: false }); + if (kind === 'partial-frame') { + const response = session.receive(BROKER_TIMEOUT_MS); + const frame = encodeProtocolFrame(JSON.stringify(inspect)); + const split = Math.floor(frame.length / 2); + await session.writeRawForTest([frame.subarray(0, split), frame.subarray(split)]); + const value = await response; + const parsed = parseInspection(value, false, false); + if (!parsed || value.id !== inspect.id || value.type !== 'inspection') throw authorityError('stdio_protocol', 16); + return 'accepted'; + } + if (kind === 'extra-frame') { + const response = session.receive(BROKER_TIMEOUT_MS); + const extra = requestFrame('inspect', { + purpose: 'setup', + path, + directory: false, + }); + await session.writeRawForTest([Buffer.concat([ + encodeProtocolFrame(JSON.stringify(inspect)), + encodeProtocolFrame(JSON.stringify(extra)), + ])]); + await response; + await session.exited; + return 'stdio_protocol'; + } + const request = kind === 'wrong-purpose' + ? requestFrame('inspect', { purpose: 'artifact', path, directory: false }) + : requestFrame('hold', { + purpose: 'setup', + path, + expectedBytes, + expectedVolumeSerial: setup.identity.volumeSerial === '0000000000000000' + ? 'ffffffffffffffff' + : '0000000000000000', + expectedFileId128: setup.identity.fileId128, + expectedSha256: null, + challenge: randomBytes(16).toString('hex'), + }); + try { + await session.exchange(request); + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError) return error.reason; + throw error; + } + } finally { release(); } +}; + +/** Native-test-only held-session ID/purpose confusion injection. */ +export const injectWindowsAuthorityHeldFaultForTest = async ( + held: WindowsLockedArtifact, + kind: 'wrong-id' | 'wrong-purpose' | 'stale-id', +): Promise => { + const process = lockedArtifactProcesses.get(held); + if (!process) throw authorityError('request_protocol', 1); + const frame = requestFrame('read', { + id: kind === 'wrong-id' ? randomBytes(16).toString('hex') + : kind === 'stale-id' ? (lastClosedHeldId ?? randomBytes(16).toString('hex')) : process.heldId, + purpose: kind === 'wrong-purpose' ? (process.purpose === 'setup' ? 'artifact' : 'setup') : process.purpose, + challenge: process.challenge, + offset: 0, + length: 1, + }); + try { + await process.session.exchange(frame); + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (!(error instanceof WindowsAuthorityError)) throw error; + process.session.invalidate(error); + await process.exited; + clearTimeout(process.timeout); + process.release(); + lockedArtifactProcesses.delete(held); + return error.reason; + } +}; + +/** Native-test-only crash injection used to prove that OS termination releases the exact target handle. */ +export const crashWindowsLockedArtifactForTest = async (held: WindowsLockedArtifact): Promise => { + const process = lockedArtifactProcesses.get(held); + if (!process) throw authorityError('request_protocol', 1); + clearTimeout(process.timeout); + process.session.invalidate(authorityError('process_exit', 19)); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + process.exited, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(authorityError('process_exit', 19)), BROKER_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + process.release(); + lockedArtifactProcesses.delete(held); +}; + +export const windowsAuthorityBrokerStatsForTest = (): Readonly<{ + compileCount: number; + requestCount: number; + restartCount: number; + activeProcessCount: number; + activeAuthenticatedHandleSets: number; + activeSessionTempDirectory: string | null; + lastRemovedSessionTempDirectory: string | null; + queuedEntries: number; +}> => Object.freeze({ + compileCount, + requestCount, + restartCount, + activeProcessCount, + activeAuthenticatedHandleSets, + activeSessionTempDirectory: activeSessionTempDirectories.values().next().value ?? null, + lastRemovedSessionTempDirectory: lastRemovedSessionTempDirectory ?? null, + queuedEntries: brokerQueue.length, +}); + +/** Test-only framing probe; it shares the production incremental binary decoder. */ +export const decodeWindowsAuthorityFramesForTest = ( + chunks: readonly Buffer[], + expectedFrames = 1, +): readonly Readonly>[] => { + let buffered: Buffer = Buffer.alloc(0); + const frames: Record[] = []; + for (const chunk of chunks) { + const decoded = decodeProtocolChunk(buffered, chunk); + buffered = decoded.buffered; + for (const frame of decoded.frames) { + let value: unknown; + try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)); } + catch { throw authorityError('stdio_protocol', 16); } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw authorityError('stdio_protocol', 16); + } + frames.push(value as Record); + } + } + if (buffered.length !== 0 || frames.length !== expectedFrames) throw authorityError('stdio_protocol', 16); + return frames; +}; + +export const encodeWindowsAuthorityFrameForTest = (value: string): Buffer => encodeProtocolFrame(value); + +export const parseWindowsAuthorityStartupFailureForTest = (frame: unknown): Error => + parseFailure(frame) ?? authorityError('stdio_protocol', 16); + +export const shutdownWindowsAuthorityBrokerForTest = async (): Promise => { + const session = brokerSession ?? await brokerStartup?.catch(() => undefined); + brokerSession = undefined; + if (session) await session.shutdown(); +}; + +process.once('exit', () => { + for (const child of brokerChildren) if (!child.killed) child.kill(); + for (const directory of activeSessionTempDirectories) { + try { rmSync(directory, { recursive: true, force: true }); } catch { /* process teardown is already bounded */ } + } +}); + +export const smokeWindowsUpdateAuthority = async (path: string): Promise => { + const setup = await inspectWindowsPrivatePath(path); + const exactBytes = Number(setup.size); + if (!Number.isSafeInteger(exactBytes) || exactBytes <= 0) throw authorityError('type_link_size', 5); + const held = await openWindowsLockedArtifact(path, exactBytes, undefined, undefined, setup.identity); + try { + if (!/^[a-f0-9]{16}$/.test(held.inspection.identity.volumeSerial) + || !/^[a-f0-9]{32}$/.test(held.inspection.identity.fileId128) + || !/^[a-f0-9]{64}$/.test(held.inspection.sha256) + || !/^[a-f0-9]{40}$/.test(held.inspection.sha1) + || held.inspection.daclProtected !== true + || held.inspection.reparseTag !== '00000000') throw authorityError('ready_protocol', 12); + await held.read(0, Math.min(1, Number(held.inspection.size))); + const verified = await held.verify(); + if (verified.identity.fileId128 !== held.inspection.identity.fileId128 + || verified.sha256 !== held.inspection.sha256 || verified.sha1 !== held.inspection.sha1) { + throw authorityError('final_verify', 14); + } + } finally { + await held.close(); + } + return Object.freeze([ + 'compile-load', + 'owner-sid', + 'dacl-protection', + 'file-id-info', + 'same-handle-sha256-sha1', + 'reparse-query', + 'no-share-lock', + 'ready-protocol', + 'held-read', + 'clean-shutdown', + ]); +}; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 000000000..1cd5d0235 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "forge.config.ts", + "vite.*.config.ts" + ] +} diff --git a/apps/desktop/vite.main.config.ts b/apps/desktop/vite.main.config.ts new file mode 100644 index 000000000..8a685797c --- /dev/null +++ b/apps/desktop/vite.main.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import { resolveTrustedUpdateBuildConfig } from './src/release-config'; + +const updateConfig = resolveTrustedUpdateBuildConfig(); + +export default defineConfig({ + define: { + __PROPR_DESKTOP_UPDATE_MANIFEST_URL__: JSON.stringify(updateConfig.manifestUrl), + __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__: JSON.stringify(updateConfig.publicKey), + __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__: JSON.stringify(updateConfig.signingIdentity), + __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__: JSON.stringify(updateConfig.windowsSignerPins), + }, + build: { + sourcemap: true, + minify: false, + rollupOptions: { + output: { + format: 'cjs', + entryFileNames: 'main.cjs', + }, + }, + }, +}); diff --git a/apps/desktop/vite.preload.config.ts b/apps/desktop/vite.preload.config.ts new file mode 100644 index 000000000..d5353c7db --- /dev/null +++ b/apps/desktop/vite.preload.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + sourcemap: true, + minify: false, + rollupOptions: { + output: { + format: 'cjs', + entryFileNames: 'preload.cjs', + }, + }, + }, +}); diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts new file mode 100644 index 000000000..3c5cca231 --- /dev/null +++ b/apps/desktop/vite.renderer.config.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig, type Plugin } from 'vite'; +import { applyDevelopmentRendererCsp } from './src/security'; +import { resolveDesktopVersion } from './src/release-config'; +import { viteFileSystemUrl } from './src/vite-file-system-url'; + +const rootPackage = JSON.parse( + readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'), +) as { version: string }; +const desktopVersion = resolveDesktopVersion(rootPackage.version); +const proprUiRoot = fileURLToPath(new URL('../../propr-ui', import.meta.url)); +const rendererEntrySource = '../../propr-ui/src/desktop.tsx'; +const rendererEntryDevelopmentUrl = viteFileSystemUrl( + fileURLToPath(new URL(rendererEntrySource, import.meta.url)), +); + +const transformDevelopmentRendererHtml = (html: string): string => { + if (!html.includes(rendererEntrySource)) { + throw new Error('renderer.html is missing the shared desktop renderer entry'); + } + return applyDevelopmentRendererCsp(html).replace(rendererEntrySource, rendererEntryDevelopmentUrl); +}; + +const developmentCspPlugin: Plugin = { + name: 'propr-desktop-development-csp', + apply: 'serve', + transformIndexHtml: { + order: 'pre', + handler: transformDevelopmentRendererHtml, + }, +}; + +const compiledRendererCssPlugin: Plugin = { + name: 'propr-desktop-compiled-renderer-css', + apply: 'build', + enforce: 'post', + generateBundle(_options, bundle) { + const css = Object.values(bundle) + .flatMap(output => output.type === 'asset' && output.fileName.endsWith('.css') + ? [typeof output.source === 'string' + ? output.source + : Buffer.from(output.source).toString('utf8')] + : []) + .join('\n'); + if (!css) throw new Error('Desktop renderer build emitted no CSS'); + if (/@(?:tailwind|apply)\b/.test(css)) { + throw new Error('Desktop renderer CSS still contains uncompiled Tailwind directives'); + } + for (const selector of ['.h-5', '.space-y-5', '.bg-primary-500', '.dashboard-card']) { + if (!css.includes(selector)) { + throw new Error(`Desktop renderer CSS is missing representative selector ${selector}`); + } + } + }, +}; + +export default defineConfig({ + base: './', + css: { + postcss: proprUiRoot, + }, + define: { + __APP_VERSION__: JSON.stringify(desktopVersion), + __PROPR_DESKTOP__: 'true', + }, + plugins: [developmentCspPlugin, react(), compiledRendererCssPlugin], + publicDir: '../../propr-ui/public', + build: { + sourcemap: true, + rollupOptions: { + input: 'renderer.html', + output: { + manualChunks: { + 'charts-vendor': ['recharts'], + 'markdown-vendor': ['react-markdown', 'remark-breaks', 'remark-gfm'], + 'motion-vendor': ['framer-motion'], + 'react-vendor': ['react', 'react-dom', 'react-router-dom'], + }, + }, + }, + }, +}); diff --git a/apps/desktop/window-sizing.json b/apps/desktop/window-sizing.json new file mode 100644 index 000000000..23d250b81 --- /dev/null +++ b/apps/desktop/window-sizing.json @@ -0,0 +1,10 @@ +{ + "preferred": { + "width": 1280, + "height": 820 + }, + "minimum": { + "width": 880, + "height": 620 + } +} diff --git a/docker/Dockerfile.app.prod b/docker/Dockerfile.app.prod index 29c22d511..79d8e3cd9 100644 --- a/docker/Dockerfile.app.prod +++ b/docker/Dockerfile.app.prod @@ -15,6 +15,7 @@ RUN apk add --no-cache python3 make g++ git # Copy workspace manifests first so npm ci layer caches when source changes. COPY package*.json ./ COPY packages/shared/package*.json ./packages/shared/ +COPY packages/local-setup/package*.json ./packages/local-setup/ COPY packages/core/package*.json ./packages/core/ COPY packages/api/package*.json ./packages/api/ @@ -29,11 +30,13 @@ COPY config ./config COPY scripts ./scripts COPY knexfile.ts ./ COPY packages/shared ./packages/shared +COPY packages/local-setup ./packages/local-setup COPY packages/core ./packages/core COPY packages/api ./packages/api # Build workspace packages in dependency order, then root. RUN cd packages/shared && npm run build \ + && cd ../local-setup && npm run build \ && cd ../core && npm run build \ && cd ../.. && npm run build @@ -79,6 +82,8 @@ COPY --from=builder /build/package*.json ./ COPY --from=builder /build/dist ./dist COPY --from=builder /build/packages/shared/package.json ./packages/shared/ COPY --from=builder /build/packages/shared/dist ./packages/shared/dist +COPY --from=builder /build/packages/local-setup/package.json ./packages/local-setup/ +COPY --from=builder /build/packages/local-setup/dist ./packages/local-setup/dist COPY --from=builder /build/packages/core/package.json ./packages/core/ COPY --from=builder /build/packages/core/dist ./packages/core/dist COPY --from=builder /build/packages/api/package.json ./packages/api/ diff --git a/docker/launcher/orchestrator.mjs b/docker/launcher/orchestrator.mjs index 311e458d8..e6e64b9fe 100644 --- a/docker/launcher/orchestrator.mjs +++ b/docker/launcher/orchestrator.mjs @@ -43,11 +43,11 @@ export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2'; export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev'; // Whether an instance id is a valid single DNS label for the proxy hostname -// (t-.propr.dev): 1–63 chars, ASCII letters/digits/hyphens only, no -// leading/trailing hyphen. Mirrors isValidProprInstanceId() in the shared pkg. +// (t-.propr.dev): 1–61 chars (leaving room for `t-`), ASCII +// letters/digits/hyphens only, no leading/trailing hyphen. export function isValidProprInstanceId(instanceId) { const id = (instanceId ?? '').trim(); - return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id); + return /^[a-z0-9]([a-z0-9-]{0,59}[a-z0-9])?$/i.test(id); } // Derive the per-instance public API/UI URL (https://t-.propr.dev) @@ -61,36 +61,40 @@ export function proprInstanceProxyUrl(instanceId) { return isValidProprInstanceId(id) ? `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined; } +export function canonicalProprProxyUrl(url) { + if (!url || url !== url.trim() || /[^\x20-\x7e]/.test(url)) return undefined; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '' + || parsed.port !== '' || parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '') return undefined; + const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; + if (!parsed.hostname.endsWith(suffix)) return undefined; + const label = parsed.hostname.slice(0, -suffix.length); + if (label.length > 63 || label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) return undefined; + const id = label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length); + if (!isValidProprInstanceId(id)) return undefined; + const canonical = `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`; + return url === canonical ? canonical : undefined; + } catch { + return undefined; + } +} + // Whether a URL is a hosted per-instance proxy URL (https://t-.propr.dev). // propr-routing only forwards /api/* and /socket.io/* on these hosts, so the // tunnel base URL must be one of them. Requires exactly one t- // label before the suffix (other propr.dev hosts and nested hosts are rejected) -// and a bare origin (a non-root path/query/fragment is rejected so +// and the exact lowercase ASCII bare origin (a slash/path/query/fragment is rejected so // proprTunnelEndpoints does not double up the /api prefix). Mirrors // isProprProxyUrl() in the shared pkg. export function isProprProxyUrl(url) { - if (!url) return false; - try { - const { protocol, hostname, pathname, search, hash } = new URL(url); - if (protocol !== 'https:') return false; - // Trailing slashes are tolerated; any real path segment/query/fragment - // is rejected so a base path can't double up the appended /api prefix. - if (/[^/]/.test(pathname) || search || hash) return false; - const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; - if (!hostname.endsWith(suffix)) return false; - const label = hostname.slice(0, -suffix.length); - if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) { - return false; - } - return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)); - } catch { - return false; - } + return typeof url === 'string' + && /^https:\/\/t-(?:[a-z0-9]|[a-z0-9][a-z0-9-]{0,59}[a-z0-9])\.propr\.dev$/.test(url); } function normalizeProprInstanceId(instanceId) { const id = (instanceId ?? '').trim(); - return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) + return id.toLowerCase().startsWith(PROPR_UI_PROXY_LABEL_PREFIX) ? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length) : id; } @@ -216,9 +220,14 @@ function envFileValueFrom(envFileLocal, name) { * `check`/`init` commands to inspect HOST_*_DIR settings without re-reading. */ export function readEnvFile(envFilePath) { + if (!envFilePath || !isReadableFile(envFilePath)) return {}; + return parseEnvFileContents(readFileSync(envFilePath, 'utf8')); +} + +/** Parse already-authorized env bytes without reopening their pathname. */ +export function parseEnvFileContents(contents) { const out = {}; - if (!envFilePath || !isReadableFile(envFilePath)) return out; - for (const rawLine of readFileSync(envFilePath, 'utf8').split(/\r?\n/)) { + for (const rawLine of contents.split(/\r?\n/)) { const parsed = parseEnvAssignment(rawLine); if (parsed) out[parsed.name] = parsed.value; } @@ -251,10 +260,15 @@ export function resolveConfig(env = process.env, overrides = {}) { // from the CLI/launcher process environment. Inspect that exact source so a // developer's shell NODE_ENV cannot accidentally describe (or alter) the // packaged container runtime. - const nodeEnv = readEnvFile(envFileLocal).NODE_ENV || undefined; + const authorizedEnvFileValues = overrides.envFileValues; + const nodeEnv = (authorizedEnvFileValues ?? readEnvFile(envFileLocal)).NODE_ENV || undefined; // value precedence: explicit override → process env → .env file - const get = (name) => env[name] !== undefined ? env[name] : envFileValueFrom(envFileLocal, name) || undefined; + const get = (name) => env[name] !== undefined + ? env[name] + : authorizedEnvFileValues + ? authorizedEnvFileValues[name] || undefined + : envFileValueFrom(envFileLocal, name) || undefined; const hostData = overrides.hostData ?? env.PROPR_DATA_DIR; const hostLogs = overrides.hostLogs ?? env.PROPR_LOGS_DIR; @@ -338,12 +352,10 @@ export function resolveConfig(env = process.env, overrides = {}) { const cloudflaredImage = get('PROPR_CLOUDFLARED_IMAGE') || manifest.images.cloudflared || DEFAULT_CLOUDFLARED_IMAGE; // Explicit URL wins; otherwise derive from the instance id's proxy hostname. // Falls back to undefined for local development (no instance id), where - // API_PUBLIC_URL / FRONTEND_URL keep their localhost defaults below. Trailing - // slashes are stripped once here so every consumer (API/worker/UI env, status - // output, endpoint rendering) sees one canonical form — the derived URL never - // has one, but an explicit PROPR_UI_PUBLIC_API_URL might. - const uiPublicApiUrl = - (get('PROPR_UI_PUBLIC_API_URL') || proprInstanceProxyUrl(proprInstanceId))?.replace(/\/+$/, '') || undefined; + // API_PUBLIC_URL / FRONTEND_URL keep their localhost defaults below. Preserve + // explicit raw spelling so validation cannot turn an alternate reserved + // Connect spelling into a trusted canonical endpoint. + const uiPublicApiUrl = get('PROPR_UI_PUBLIC_API_URL') || proprInstanceProxyUrl(proprInstanceId) || undefined; return Object.freeze({ stack, network, envFileLocal, envFileHost, nodeEnv, @@ -507,11 +519,13 @@ export function validateDockerBindPath(name, value, { containerPath = false } = const REMOTE_IMAGE_CHECK_TIMEOUT_MS = 5000; -export function docker(args, { capture = false, timeout } = {}) { +export function docker(args, { capture = false, timeout, env, maxBuffer } = {}) { const res = spawnSync('docker', args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: 'utf8', timeout, + env, + maxBuffer, }); if (res.status !== 0 && !capture) { const detail = res.error?.message || (res.signal ? `signal ${res.signal}` : `code ${res.status}`); @@ -1474,7 +1488,7 @@ export async function runMigrationPhaseAsync(cfg, { onLog, freshnessCache } = {} /** Async mirror of getStackStatus. */ export async function getStackStatusAsync(cfg) { - const res = await dockerAsync(STACK_STATUS_PS_ARGS); + const res = await dockerAsync(stackStatusPsArgs(cfg)); return parseStackStatus(cfg, res.stdout); } @@ -1565,16 +1579,76 @@ export function parseStackStatus(cfg, stdout) { return { stack: cfg.stack, network: cfg.network, running: anyRunning, services }; } -const STACK_STATUS_PS_ARGS = ['ps', '-a', '--format', '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}']; +const STACK_STATUS_MAX_BYTES = 64 * 1024; + +function stackStatusPsArgs(cfg) { + // `cfg.stack` has already passed the Docker-name validation before Connect + // reaches this boundary. Keep the label expression in one argv element so + // neither a shell nor Docker's fuzzy name matching can broaden discovery. + if (typeof cfg?.stack !== 'string' || cfg.stack.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(cfg.stack)) { + throw new Error('Docker stack status scope is invalid'); + } + return [ + 'ps', + '-a', + '--filter', + `label=propr.stack=${cfg.stack}`, + '--format', + '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}', + ]; +} + +/** + * Run and strictly validate one bounded Docker status inspection. The command + * result is retained so callers can distinguish an absent service (a successful + * empty inspection) from a missing binary, daemon error, timeout, signal, or + * truncated/malformed output. + */ +export function inspectStackStatus(cfg, { timeout, env } = {}) { + let args; + try { + args = stackStatusPsArgs(cfg); + } catch (error) { + return { result: { status: null, stdout: '', stderr: '', error } }; + } + const result = docker(args, { + capture: true, + timeout, + env, + maxBuffer: STACK_STATUS_MAX_BYTES, + }); + if (result.status !== 0 || result.error || result.signal || typeof result.stdout !== 'string') { + return { result }; + } + + const expectedNames = new Set(SERVICES.map((service) => `${cfg.stack}-${service}`)); + const seenExpectedNames = new Set(); + const validStates = new Set(['created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead']); + for (const line of result.stdout.split('\n')) { + if (line === '') continue; + const fields = line.endsWith('\r') ? line.slice(0, -1).split('\t') : line.split('\t'); + if (fields.length !== 4) return { result }; + const [name, state, status] = fields; + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) || !validStates.has(state) || status.length === 0) { + return { result }; + } + // The daemon-side label filter is a scope reduction, not an authority + // assertion. Every row returned for the target label must still be one + // of this stack's canonical service containers, exactly once. + if (!expectedNames.has(name) || seenExpectedNames.has(name)) return { result }; + seenExpectedNames.add(name); + } + return { result, status: parseStackStatus(cfg, result.stdout) }; +} /** Per-service state for the whole stack, discovered by canonical container name. */ -export function getStackStatus(cfg) { - const res = docker(STACK_STATUS_PS_ARGS, { capture: true }); +export function getStackStatus(cfg, { timeout } = {}) { + const res = docker(stackStatusPsArgs(cfg), { capture: true, timeout }); return parseStackStatus(cfg, res.stdout); } -export function getServiceState(cfg, service) { - return getStackStatus(cfg).services.find((s) => s.service === service); +export function getServiceState(cfg, service, opts) { + return getStackStatus(cfg, opts).services.find((s) => s.service === service); } // Best-effort GET /api/status behind a hard timeout. propr-routing diff --git a/docs/docs/concepts/security-overview.md b/docs/docs/concepts/security-overview.md index ea8bd9266..d64023dbb 100644 --- a/docs/docs/concepts/security-overview.md +++ b/docs/docs/concepts/security-overview.md @@ -30,7 +30,7 @@ The API and worker use the host Docker socket to launch task containers; the API - **Inbound: none required.** The default event intake is an outbound WebSocket to the routing service, so a stack behind NAT or a firewall works without exposing any port. The API (4000) and Web UI (5173) bind locally; expose them deliberately (reverse proxy, VPN, or the managed [hosted UI tunnel](../operations/deployment.md#hosted-ui-tunnel)). - **`direct_webhook` mode** (advanced) is the exception: it requires a public `POST /webhook` endpoint and a webhook secret. -- **Unauthenticated endpoints:** `GET /api/compatibility` is intentionally unauthenticated so the hosted UI can check version compatibility before login — the release version of your stack is readable pre-auth. Treat that as public information or keep the API off the public internet. +- **Unauthenticated endpoints:** `GET /api/compatibility` and `GET /api/desktop/discovery` intentionally expose only product/version compatibility and desktop-auth capabilities. The rate-limited desktop pairing start/poll endpoints use a high-entropy, body-only device secret and disclose an instance token only after browser-session approval. Treat version metadata as public information or keep the API off the public internet. - API access is protected by session auth (GitHub OAuth) and optional bearer-token auth for automation. - **Organizations with GitHub IP allow lists**: add your ProPR server's egress IP to the org allow list. The GitHub App deliberately declares no IP allow list of its own: every API call comes from your self-hosted stack at your own address, so inheriting an App-level list would block your own stack. diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md index f16973ddc..869049414 100644 --- a/docs/docs/features/propr-cli.md +++ b/docs/docs/features/propr-cli.md @@ -104,6 +104,7 @@ The hosted ProPR UI at `https://app.propr.dev` can drive a locally-running stack | `propr tunnel on` | Start the cloudflared sidecar; requires a configured token and a running stack (`--force` starts it ahead of the stack) | | `propr tunnel off` | Stop the sidecar; the token and env values are left untouched | | `propr tunnel verify` | Check the sidecar plus the public `/api/status` (expects OK/auth), `/` (expects 404), and `/socket.io/` (expects reachable) | +| `propr connect status --json --root ` | Emit the bounded secret-free desktop discovery contract and verify that the remote API origin and public stack identity match | Architecture, the full configuration, enablement semantics, verification, and troubleshooting live on the dedicated [Hosted UI Tunnel](../operations/hosted-ui-tunnel.md) page — including the two facts that catch operators most often: `PROPR_UI_TUNNEL_TOKEN` is a live Cloudflare credential to keep out of source control and logs, and enabling the tunnel on an already-running stack requires `propr start --restart` (or `propr tunnel setup --start`) before OAuth redirects and cookies use the hosted URLs. diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index e5674d91a..30d2ef989 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -133,7 +133,7 @@ Optional: expose a local stack's API to the hosted control plane at `https://app |---|---|---|---| | `PROPR_UI_TUNNEL_TOKEN` | Unset | Cloudflare Tunnel token; setting it enables the tunnel on the next `propr start` (unless you ran `propr tunnel off`). This is a **live credential** — anyone with it can route traffic through your tunnel. Keep it in `.env` only; never commit, log, or share it. | Tunnel mode. | | `PROPR_UI_TUNNEL_ENABLED` | Unset | `true`/`1` explicitly enables the tunnel. A token is still required — `propr check` fails without one. Redundant when a token is set. | Optional. | -| `PROPR_INSTANCE_ID` | Unset | This stack's instance id — a valid DNS label (letters, digits, hyphens; 1–63 chars). Derives the public URL `https://t-.propr.dev`. | Tunnel mode, unless an explicit URL is set. | +| `PROPR_INSTANCE_ID` | Unset | This stack's instance id — letters, digits, and hyphens; 1–61 characters so the full `t-` DNS label remains valid. Derives the public URL `https://t-.propr.dev`. | Tunnel mode, unless an explicit URL is set. | | `PROPR_UI_PUBLIC_API_URL` | Derived from `PROPR_INSTANCE_ID` | Explicit public API URL the hosted UI talks to; overrides the derived one. | Override only. | | `PROPR_CLOUDFLARED_IMAGE` | `cloudflare/cloudflared:2024.12.2` (pinned) | The cloudflared sidecar image. | Override only. | diff --git a/docs/docs/operations/desktop-pairing.md b/docs/docs/operations/desktop-pairing.md new file mode 100644 index 000000000..baafeac4c --- /dev/null +++ b/docs/docs/operations/desktop-pairing.md @@ -0,0 +1,140 @@ +# Desktop pairing protocol + +Packaged desktop clients authenticate to one ProPR instance with an opaque +instance token. They never receive or persist a GitHub access or refresh token. +Desktop authentication protocol version 2 is designed for the Electron main +process (or another trusted native process); renderer code must communicate with +it through a narrow IPC bridge and must not read the device secret or instance +token. + +## Discovery + +Before login, call `GET /api/desktop/discovery`. The discovery document retains +schema version 1 and advertises desktop authentication protocol version 2. It is +deliberately limited to the exact product, release/API/UI compatibility, +canonical managed endpoint, random public installation identity, and +authentication capabilities: + +```json +{ + "schemaVersion": 1, + "product": "ProPR", + "version": "0.8.15", + "apiCompatibility": "2026-06-27", + "uiCompatibility": "2026-06-27", + "canonicalEndpoint": "https://t-abc123.propr.dev", + "publicInstanceIdentity": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "desktopAuthentication": { + "protocolVersion": 2, + "browserPairing": true, + "instanceBearerTokens": true, + "socketIoBearerAuthentication": true + } +} +``` + +Consumers must parse the entire schema-v1 document and require desktop +authentication protocol version 2 before using any field. The version is +canonical SemVer; both compatibility values are canonical `YYYY-MM-DD` versions; +the identity is an exact lowercase UUIDv4; and the endpoint is either `null` +during restart/configuration or the bare canonical +`https://t-.propr.dev` origin. Every capability key is required and every +capability value is a JSON boolean. Missing, extra, duplicate, oversized, +coerced, malformed, or non-canonical fields are incompatible discovery, never +partial readiness. Native and shared-client consumers use the same bounded wire +parser. + +The public identity is not a credential. It is randomly created in the stack's +private durable `data/` directory and is shared by the host CLI and root-running +API container. The directory remains owned by the host caller with mode `0700`. +The single-link regular identity file may be owned by that host caller or by the +root API container account; it is never group/world writable, and a root-owned +file remains host-readable. Creation writes and fsyncs a private same-directory +temporary, publishes without replacing a concurrent winner, and fsyncs the +directory. Normal restarts, upgrades, and tunnel rotation preserve the value; +replacing the durable data directory creates a new identity. + +Discovery is rate limited per trusted network address. A `false` capability +means the deployment (for example, public demo mode) must not be paired. The +legacy `GET /api/compatibility` metadata is not a substitute for the schema-v1 +discovery and identity contract. + +## Pairing sequence + +1. The trusted desktop process repeats strict unauthenticated discovery at the + exact candidate origin. It then sends `POST /api/desktop/pairings` with the + client name and its main-owned profile/origin/scope/credential-generation + binding. `clientName` is printable text from 1 through 80 characters. +2. A `201` response contains `pairingId`, `deviceSecret`, `approvalUrl`, + `expiresAt`, and `interval` (seconds). Both identifiers have at least 128 bits + of entropy; the device secret has 256 bits. Store the secret only in trusted + process memory and open the exact `approvalUrl` in the system browser. Do not + append a redirect or origin supplied by the renderer. +3. The browser entry validates the unexpired request, initiates the instance's + normal GitHub login when necessary, and redirects to the fixed ProPR approval + page. The approval page shows the client name and requires an explicit click. + `POST /api/desktop/pairings/{pairingId}/approve` accepts only an authenticated + browser session and the exact configured `FRONTEND_URL` origin. GitHub bearer + and instance-token principals cannot approve a pairing. +4. No more often than `interval`, the trusted process sends + `POST /api/desktop/pairings/{pairingId}/poll` with + `{"deviceSecret":"..."}`. The secret is in the JSON body, never a URL or + header that an intermediary normally logs. A pending request returns `202` + with `{"status":"pending","interval":5}`. +5. The first valid poll after approval returns `200` with + `{"status":"complete","token":"propr_it_...","tokenType":"Bearer","expiresAt":null}`. + The polling grant is consumed in the same transaction that creates the token; + subsequent polls return `409 PAIRING_ALREADY_CONSUMED`. If the success response + is lost, begin a new pairing rather than retrying for the credential. + +Pairings expire after ten minutes. An unknown ID or wrong secret returns the +same `404 PAIRING_NOT_FOUND`; an expired request returns `410 PAIRING_EXPIRED`. +Start and poll routes have separate IP quotas. Clients must honor HTTP `429` and +`Retry-After` and must stop at `expiresAt`. + +## Using and storing the token + +Send the returned token as `Authorization: Bearer propr_it_...` on normal REST +requests. For Socket.IO, set that same header on the Engine.IO WebSocket +handshake (Electron/Node clients can use `extraHeaders`). The socket identity is +revalidated periodically, so token revocation, expiry, role changes, permission +changes, or whitelist removal disconnect an established client. + +Store the token in an operating-system credential facility such as macOS +Keychain, Windows Credential Manager, or Linux Secret Service. Never put it in +`localStorage`, IndexedDB, renderer state, a pairing URL, logs, crash reports, or +analytics. Keep the instance origin with the credential and refuse to send it to +another origin. Treat TLS certificate failures as terminal; HTTP is accepted +only for loopback development. Persist the discovery `publicInstanceIdentity` +with the encrypted credential and bind it atomically to the profile ID, +canonical origin, and credential generation. Before a stored token is used +after launch, reconnect, profile switch, or tunnel rotation, repeat +unauthenticated strict discovery at that exact origin. An absent, malformed, or +different identity produces no bearer-, cookie-, or socket-authenticated +request, durably detaches the old credential, and requires a new pairing +generation. Legacy credentials without this binding fail closed and are removed +locally during migration. + +The server stores SHA-256 token and device-secret hashes, never plaintext. Token +rows retain the owner GitHub ID/profile snapshot, creation and last-use times, +optional expiry, and revocation metadata. Authorization still resolves the +owner's current instance role and permissions on each request. Set +`PROPR_DESKTOP_TOKEN_TTL_DAYS` to an integer from 1 through 3650 to issue expiring +tokens; when unset, tokens remain valid until revoked. Expired pairing rows are +cleaned hourly after a short retention period used for stable client errors. + +## Token management + +Both routes require any accepted authentication method and operate only on the +authenticated user's tokens: + +- `GET /api/desktop/tokens` returns `{ "tokens": [...] }` with `id`, `name`, + `tokenHint`, `createdAt`, `lastUsedAt`, `expiresAt`, and `revokedAt`. It never + returns a hash or token. +- `DELETE /api/desktop/tokens/{tokenId}` returns `204` after revoking an active + owned token. Unknown, already-revoked, and other users' IDs all return + `404 TOKEN_NOT_FOUND`. + +Pairing start, approval, token issuance, and revocation write audit rows and +structured logs containing IDs and the display name only. Device secrets, +instance tokens, token hashes, and GitHub tokens are excluded. diff --git a/docs/docs/operations/hosted-ui-tunnel.md b/docs/docs/operations/hosted-ui-tunnel.md index f271b46d1..ae81fce1f 100644 --- a/docs/docs/operations/hosted-ui-tunnel.md +++ b/docs/docs/operations/hosted-ui-tunnel.md @@ -46,7 +46,7 @@ The hosted PWA's manifest, service worker, installation, notification permission ### Compatibility check -Before the hosted UI starts its normal auth/session checks, it calls the public `/api/compatibility` endpoint on the selected API origin. The endpoint returns the local stack version plus the API/UI compatibility contract. If the hosted UI cannot support that contract, it stops at a clear version-mismatch screen instead of running against incompatible endpoints or Socket.IO events. `/api/status` includes the same metadata for authenticated diagnostics. +Before the hosted UI starts its normal auth/session checks, it calls the public `/api/compatibility` endpoint on the selected API origin. Desktop discovery uses the separately bounded, rate-limited, cache-disabled `/api/desktop/discovery` response. That response adds only the canonical managed endpoint and the stack's random public installation identity to version/capability metadata; it contains no credential or account state. Desktop main preserves that identity through Connect confirmation and encrypted profile persistence, then revalidates it without credentials before stored REST or Socket.IO authentication. Tunnel endpoint or identity rotation therefore creates a fresh pairing generation; no prior-origin credential, socket, or cookie state is carried across. If the hosted UI cannot support the compatibility contract, it stops at a clear version-mismatch screen instead of running against incompatible endpoints or Socket.IO events. `/api/status` includes the same version metadata for authenticated diagnostics. Only a **definitive** mismatch (the API reports a contract the UI knows it is too old or too new for) hard-blocks. A v1 rollout exception applies when the metadata is simply *absent* — an older API that predates `/api/compatibility` (returns 404) or returns no contract: the UI logs a console warning and continues, so an otherwise-working stack is never trapped mid-upgrade. This soft-warning fallback is temporary; once publishing the compatibility contract is a baseline expectation, missing metadata is intended to become a hard block like any other mismatch. @@ -62,7 +62,7 @@ This writes the tunnel `.env` values for you (`PROPR_UI_TUNNEL_TOKEN`, `PROPR_IN ### Manual `.env` fallback -For older CLI versions or manual recovery, set the same values in the stack `.env`. Replace `abc123` with your instance id (a valid DNS label: letters, digits, hyphens; 1-63 chars): +For older CLI versions or manual recovery, set the same values in the stack `.env`. Replace `abc123` with your instance id (letters, digits, and hyphens; 1-61 chars so the complete `t-` DNS label stays within 63 characters): ```bash # --- Hosted UI tunnel (v1, optional) --- @@ -123,6 +123,20 @@ propr tunnel verify It exits non-zero if any check fails. `propr status` probes `/api/status` for tunnel reachability for the same reason — the root `/` and the legacy `/health` path are unrouted through the tunnel. +### Secret-free desktop discovery + +Desktop invokes an explicit stack root; the CLI never scans for installations: + +```bash +propr connect status --json --root /explicit/stack/root +``` + +Stdout is exactly one schema-versioned JSON document. It reports only the canonical endpoint, public installation identity, configured/enabled/sidecar/API readiness, restart requirement, compatibility/version, and bounded reason codes. `configured` means that a valid canonical endpoint exists; it deliberately says nothing about whether any credential is present. Diagnostics go to stderr. It never reports token presence or values, GitHub/account/repository identity, host details, environment contents, or filesystem paths. Exit codes are stable: `0` ready, `2` known not ready, `3` incompatible discovery/API, `4` invalid configuration/root, `5` probe timeout, and `1` internal failure. + +The public identity is generated randomly in the stack's durable `data/` boundary. It survives normal restart, image upgrade, and tunnel rotation. Replacing/reinitializing that durable stack data generates a new identity. A sidecar is not `apiReady` until the remote discovery response matches both the expected canonical origin and this identity; consequently, `propr tunnel on` without an API restart reports `restartRequired` instead of a false-ready endpoint. + +ProPR Connect permanently retires a deleted managed tunnel hostname and does not reassign it to another installation. Identity matching remains mandatory defense in depth against stale DNS, proxy configuration, restore mistakes, and any failure of that allocation guarantee. + ## Troubleshooting The most common failures, in the order to check them: diff --git a/docs/sidebars.ts b/docs/sidebars.ts index d379737bf..7c5079872 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -129,6 +129,7 @@ const sidebars: SidebarsConfig = { 'operations/propr-connect', 'operations/connect-dashboard', 'operations/hosted-ui-tunnel', + 'operations/desktop-pairing', 'operations/pwa-web-push', 'operations/configuration-reference', 'operations/metrics', diff --git a/package-lock.json b/package-lock.json index fbc069ef6..d52857bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.1.1", "@propr/core": "^0.8.15", + "@propr/local-setup": "^0.8.15", "better-sqlite3": "^11.7.0", "bullmq": "^5.81.3", "cors": "^2.8.5", @@ -71,6 +72,236 @@ "node": ">=22.12.0" } }, + "apps/desktop": { + "name": "@propr/desktop", + "version": "0.8.15", + "license": "Apache-2.0", + "dependencies": { + "@propr/cli": "*", + "@propr/client": "*", + "@propr/shared": "*" + }, + "devDependencies": { + "@electron-forge/cli": "8.0.0-alpha.10", + "@electron-forge/maker-deb": "8.0.0-alpha.10", + "@electron-forge/maker-rpm": "8.0.0-alpha.10", + "@electron-forge/maker-zip": "8.0.0-alpha.10", + "@electron-forge/plugin-vite": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron/fuses": "^2.1.3", + "@electron/windows-sign": "2.0.6", + "@types/node": "^22.10.0", + "@vitejs/plugin-react": "^4.6.0", + "electron": "^44.0.0", + "socket.io": "^4.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.3.5" + } + }, + "apps/desktop/node_modules/@electron-forge/cli": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-8.0.0-alpha.10.tgz", + "integrity": "sha512-3fkKH50xTVN1A+UhsX6BzwFfP7JVTadIrA3Cs4jpR7Yl/PChH4w/cyi99LNnZnVAypiywkOkqrQU9t+1SZy1YA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-cli?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "MIT", + "dependencies": { + "@electron-forge/core": "8.0.0-alpha.10", + "@electron-forge/core-utils": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron/get": "^5.0.0", + "commander": "^11.1.0", + "debug": "^4.3.1", + "listr2": "^7.0.2", + "semver": "^7.2.1" + }, + "bin": { + "electron-forge": "dist/electron-forge.js", + "electron-forge-vscode-nix": "script/vscode.sh", + "electron-forge-vscode-win": "script/vscode.cmd" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/core": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-8.0.0-alpha.10.tgz", + "integrity": "sha512-sg52Ay0vy9ShC7G4CL9fsfzcUC4yAI9HdP7D18tdmbPwZJ6DLqDLKT/pFw297V7IjX4AYlpsW/71yPEqadDm3w==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-core?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "MIT", + "dependencies": { + "@electron-forge/core-utils": "8.0.0-alpha.10", + "@electron-forge/maker-base": "8.0.0-alpha.10", + "@electron-forge/plugin-base": "8.0.0-alpha.10", + "@electron-forge/publisher-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron-forge/tracer": "8.0.0-alpha.10", + "@electron/get": "^5.0.0", + "@electron/packager": "^20.0.1", + "debug": "^4.3.1", + "graceful-fs": "^4.2.11", + "jiti": "^2.4.2", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/core-utils": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-8.0.0-alpha.10.tgz", + "integrity": "sha512-edL4xReqbWStPhdhgSEE55AXXLtJLxMRtHEghulmZlf4UaSfS86zwSBtqDwYcUB1cd9LpcEm3GKKek/awOJB0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "8.0.0-alpha.10", + "@electron/rebuild": "^4.0.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "graceful-fs": "^4.2.11", + "semver": "^7.2.1" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/maker-deb": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-8.0.0-alpha.10.tgz", + "integrity": "sha512-0uk9bCW+UsPSyIASvCRzhUJii0WRCWo2oQKGZGFelIEdfPo8ojriM2ip2zVQP21c2Q0sSiaky+Ehizsymtcd6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10" + }, + "engines": { + "node": ">= 22.12.0" + }, + "optionalDependencies": { + "electron-installer-debian": "^3.2.0" + } + }, + "apps/desktop/node_modules/@electron-forge/maker-rpm": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-rpm/-/maker-rpm-8.0.0-alpha.10.tgz", + "integrity": "sha512-jtKz2D2WM/8l8q3difzNdrRCK8oDm1xUXfRmP9et0a31imyLRoalSR/STDjHQ4HiXfWnDZzuw0BvekzVjgGlRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10" + }, + "engines": { + "node": ">= 22.12.0" + }, + "optionalDependencies": { + "electron-installer-redhat": "^3.2.0" + } + }, + "apps/desktop/node_modules/@electron-forge/maker-zip": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-8.0.0-alpha.10.tgz", + "integrity": "sha512-I3N9FI8xJW7f+Ld05f2hSSuukfI2Oh9vKN7HWSPmM8+7PqN4Dwigp7DRv/s3HPFwrMdayDJKm/2me5rvXh32DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/core-utils": "8.0.0-alpha.10", + "@electron-forge/maker-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "cross-zip": "^4.0.0" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/plugin-base": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-8.0.0-alpha.10.tgz", + "integrity": "sha512-AoL+VuuFVLgqeRzO0dLvrx4f2t1nMeHQ1YKj/EoqAQ6uU7D4HS2D4FNEXyxTQFVrNj4OqSte7U3sqGenc327XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "8.0.0-alpha.10" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/plugin-vite": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-vite/-/plugin-vite-8.0.0-alpha.10.tgz", + "integrity": "sha512-ctt+M1D1K5Or07oGWGUByLHfPJW91Qn1JKHWhJEkEZmrp6ggJrSrp7JqgBhNAqe5XtpEhhPCtDMaRfFmcSL+2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/core-utils": "8.0.0-alpha.10", + "@electron-forge/plugin-base": "8.0.0-alpha.10", + "@electron-forge/shared-types": "8.0.0-alpha.10", + "debug": "^4.3.1", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron-forge/publisher-base": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-8.0.0-alpha.10.tgz", + "integrity": "sha512-UjGRM13jVr1oq+HLayJAUiQcfxvs8LyTQYm5sazxlfG9LO9UJAI/2jbNic/OXYehr5xGrJSCMXDTm/cCy5LfZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "8.0.0-alpha.10" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "apps/desktop/node_modules/@electron/fuses": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.1.3.tgz", + "integrity": "sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==", + "dev": true, + "license": "MIT", + "bin": { + "electron-fuses": "dist/bin.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "apps/desktop/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -816,108 +1047,142 @@ "react": ">=16.8.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "node_modules/@electron-forge/maker-base": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-8.0.0-alpha.10.tgz", + "integrity": "sha512-aZ7YlU785r/1VPy0h1HHy1VEiufqMX0fd4tzHcAWwDfZguajfhnGioPfgCaEVKBWyAgV3v7Pge2FkL7YcRsxsQ==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@electron-forge/shared-types": "8.0.0-alpha.10", + "which": "^6.0.0" + }, + "engines": { + "node": ">= 22.12.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "cpu": [ - "x64" - ], + "node_modules/@electron-forge/maker-base/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@electron-forge/maker-base/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "isexe": "^4.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "bin": { + "node-which": "bin/which.js" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", + "node_modules/@electron-forge/shared-types": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-8.0.0-alpha.10.tgz", + "integrity": "sha512-JdwOXHXXjh1L1rgLcQJfyCX8cHgvognmuol/udDUIx9/JzMc+AZhNnsFN8JriRYunYaFrVLTHe0H8f8GQXO/LA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "8.0.0-alpha.10", + "@electron/packager": "^20.0.1", + "@electron/rebuild": "^4.0.1", + "listr2": "^7.0.2" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">= 22.12.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", + "node_modules/@electron-forge/tracer": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-8.0.0-alpha.10.tgz", + "integrity": "sha512-aoW9P+KoTtO0KQaISdJXi3sVB5k12P1kA6pK0NsgJTEbsB2i5O6c8zfor/U5eJUeb9GAVscAIHKShYuUycgZqg==", "dev": true, "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">= 22.12.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, - "license": "Apache-2.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10.12.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { + "node_modules/@electron/asar/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "node_modules/@electron/asar/node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -925,316 +1190,678 @@ "node": "*" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0" + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "node_modules/@electron/notarize": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", + "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" + "debug": "^4.4.0", + "promise-retry": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@electron/osx-sign": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-2.7.0.tgz", + "integrity": "sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g==", "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "debug": "^4.3.4", + "isbinaryfile": "^4.0.8", + "plist": "^3.0.5", + "semver": "^7.7.1" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.mjs", + "electron-osx-sign": "bin/electron-osx-sign.mjs" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 8.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@electron/packager": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-20.3.0.tgz", + "integrity": "sha512-3MvgJgy6YJ5ti0oGGBKrWKdYwpTaoRrhranMDgHSQ6i5t56yZV9IRDyoXTuqBp97LiKqnZeAMe2wTcF/9+fP5g==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/asar": "^4.0.1", + "@electron/get": "^5.0.0", + "@electron/notarize": "^3.1.0", + "@electron/osx-sign": "^2.2.0", + "@electron/universal": "^3.0.1", + "@electron/windows-sign": "^2.0.2", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.4.1", + "filenamify": "^6.0.0", + "galactus": "^2.0.2", + "graceful-fs": "^4.2.11", + "junk": "^4.0.1", + "plist": "^3.1.0", + "resedit": "^2.0.3", + "semver": "^7.7.2", + "yargs-parser": "^22.0.0" + }, + "bin": { + "electron-packager": "bin/electron-packager.mjs" }, "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 22.12.0" }, "funding": { - "url": "https://eslint.org/donate" + "url": "https://github.com/electron/packager?sponsor=1" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@electron/packager/node_modules/@electron/asar": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", + "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "glob": "^13.0.2", + "minimatch": "^10.0.1" + }, + "bin": { + "asar": "bin/asar.mjs" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=22.12.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@electron/packager/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "Apache-2.0", + "license": "BlueOak-1.0.0", "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" + "bin": { + "electron-rebuild": "lib/cli.js" }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@hono/node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", - "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", - "license": "MIT", "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" + "node": ">=22.12.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/types": "^0.15.0" + "semver": "^7.6.3" }, "engines": { - "node": ">=18.18.0" + "node": ">=22.12.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "node_modules/@electron/universal": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-3.0.6.tgz", + "integrity": "sha512-MonS1kfkZdSEkLZI0pdR/TCx8ecxwRSFm7sORfwIkDI9UaIbHnk4Mgeqq+Ob9qDQRV8LZ9+hHCmimpA9BRcNxw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "@electron/asar": "^4.0.0", + "debug": "^4.3.1", + "plist": "^3.1.0" }, "engines": { - "node": ">=18.18.0" + "node": ">=22.12.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "node_modules/@electron/universal/node_modules/@electron/asar": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", + "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "glob": "^13.0.2", + "minimatch": "^10.0.1" + }, + "bin": { + "asar": "bin/asar.mjs" + }, "engines": { - "node": ">=18.18.0" + "node": ">=22.12.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", + "node_modules/@electron/universal/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "Apache-2.0", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": ">=12.22" + "node": "18 || 20 || >=22" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", + "node_modules/@electron/windows-sign": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-2.0.6.tgz", + "integrity": "sha512-ESWgNkFsXFH06I5EB2uHv3hmZA3yF4j9GTB77W74x3b5KZNItxggNzuADw41HUy6mhnC2K+E2mT0Xgc4YKu3IQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.3.4", + "graceful-fs": "^4.2.11", + "postject": "^1.0.0-alpha.6" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.mjs" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", - "engines": { - "node": ">=18" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.1", "cpu": [ - "arm64" + "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=20.9.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://opencollective.com/eslint" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, "engines": { - "node": ">=20.9.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", - "cpu": [ + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "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/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ "arm64" ], "license": "LGPL-3.0-or-later", @@ -1702,6 +2329,19 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "dev": true, @@ -1723,10 +2363,33 @@ "debug": "^4.1.1" } }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "license": "MIT" - }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "dev": true, @@ -2203,84 +2866,362 @@ "resolved": "packages/cli", "link": true }, + "node_modules/@propr/client": { + "resolved": "packages/client", + "link": true + }, "node_modules/@propr/core": { "resolved": "packages/core", "link": true }, + "node_modules/@propr/desktop": { + "resolved": "apps/desktop", + "link": true + }, + "node_modules/@propr/local-setup": { + "resolved": "packages/local-setup", + "link": true + }, "node_modules/@propr/shared": { "resolved": "packages/shared", "link": true }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@repomix/strip-comments": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@repomix/strip-comments/-/strip-comments-2.4.2.tgz", + "integrity": "sha512-7a18ODb043eszMBr6mpVWz802xIRMzdmptarVxTtnMIW7ZQzba/v8jLp3kcHUHb76uRkyJRPpGSwdm7+8GmsEA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@repomix/tree-sitter-wasms": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@repomix/tree-sitter-wasms/-/tree-sitter-wasms-0.1.17.tgz", + "integrity": "sha512-tc3HnFqdMF1pXhIMzG3aTaBDpIiHK2tPfn3fwqA6P3WTbHa+1EuuTubbKshvmN7xCHP5Ojz0/VW4R+XvR88KOw==", + "license": "Unlicense" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@repomix/strip-comments": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@repomix/strip-comments/-/strip-comments-2.4.2.tgz", - "integrity": "sha512-7a18ODb043eszMBr6mpVWz802xIRMzdmptarVxTtnMIW7ZQzba/v8jLp3kcHUHb76uRkyJRPpGSwdm7+8GmsEA==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@repomix/tree-sitter-wasms": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@repomix/tree-sitter-wasms/-/tree-sitter-wasms-0.1.17.tgz", - "integrity": "sha512-tc3HnFqdMF1pXhIMzG3aTaBDpIiHK2tPfn3fwqA6P3WTbHa+1EuuTubbKshvmN7xCHP5Ojz0/VW4R+XvR88KOw==", - "license": "Unlicense" + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@rollup/rollup-android-arm-eabi": { + "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", - "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "openbsd" ] }, - "node_modules/@rollup/rollup-android-arm64": { + "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", - "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -2288,13 +3229,13 @@ "license": "MIT", "optional": true, "os": [ - "android" + "openharmony" ] }, - "node_modules/@rollup/rollup-darwin-arm64": { + "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", - "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -2302,41 +3243,41 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, - "node_modules/@rollup/rollup-darwin-x64": { + "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", - "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ - "x64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, - "node_modules/@rollup/rollup-freebsd-arm64": { + "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", - "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "win32" ] }, - "node_modules/@rollup/rollup-freebsd-x64": { + "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", - "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -2344,1452 +3285,1665 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "win32" ] }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", - "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", - "cpu": [ - "arm" - ], + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/@secretlint/core": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-13.0.4.tgz", + "integrity": "sha512-Wv49KcI5XX6xjLR1wxyjORA15PtMb5ar/M27ShimVudaSi6iAM04QCA5Ozx+uEahfHNefUUKbjKGpy/9pxuW7g==", + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "13.0.4", + "@secretlint/types": "13.0.4", + "debug": "^4.4.3", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-13.0.4.tgz", + "integrity": "sha512-T2hSyZmJrQbGAe+Vl9AGNlMnoB0MP6m2BLh7EH80QcesvNM2t0pCzdiBvQ/yCe76w6/gZNpHlrSVayUeT43qVw==", + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-13.0.4.tgz", + "integrity": "sha512-Nbcr7tvyKuRF4BKh7RQSCEOaif4gFPH/qjK2ajcoUDGL7HAY/C6PzcsmVR1Y0qQCRqrBuMuXn1onXE+wxNNNsw==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "13.0.4", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-13.0.4.tgz", + "integrity": "sha512-on/DivRDZEFzRD2pZJO0wkIL+AvEY+KOoZLPCWcz4pjZnJ4NzMuHQjvTJc9KY0yht+ugcYg9AmtOUzpEk31IWA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", - "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", - "cpu": [ - "arm" - ], + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", - "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", - "cpu": [ - "arm64" - ], + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", - "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", - "cpu": [ - "arm64" - ], + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", - "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", - "cpu": [ - "loong64" - ], + "node_modules/@types/babel__core": { + "version": "7.20.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", - "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", - "cpu": [ - "loong64" - ], + "node_modules/@types/babel__generator": { + "version": "7.27.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", - "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", - "cpu": [ - "ppc64" - ], + "node_modules/@types/babel__template": { + "version": "7.4.4", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", - "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", - "cpu": [ - "ppc64" - ], + "node_modules/@types/babel__traverse": { + "version": "7.28.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/types": "^7.28.2" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", - "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", - "cpu": [ - "riscv64" - ], - "dev": true, + "node_modules/@types/body-parser": { + "version": "1.19.6", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", - "cpu": [ - "riscv64" - ], + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", - "cpu": [ - "s390x" - ], - "dev": true, + "node_modules/@types/connect": { + "version": "3.4.38", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/cors": { + "version": "2.8.19", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@types/d3-color": "*" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "node_modules/@types/d3-path": { + "version": "3.1.1", + "license": "MIT" }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/d3-scale": { + "version": "4.0.9", "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@types/d3-time": "*" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/d3-shape": { + "version": "3.1.7", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/d3-path": "*" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@types/d3-time": { + "version": "3.0.4", + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/ms": "*" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" - ], + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, - "node_modules/@secretlint/core": { - "version": "13.0.4", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-13.0.4.tgz", - "integrity": "sha512-Wv49KcI5XX6xjLR1wxyjORA15PtMb5ar/M27ShimVudaSi6iAM04QCA5Ozx+uEahfHNefUUKbjKGpy/9pxuW7g==", + "node_modules/@types/estree-jsx": { + "version": "1.0.5", "license": "MIT", "dependencies": { - "@secretlint/profiler": "13.0.4", - "@secretlint/types": "13.0.4", - "debug": "^4.4.3", - "structured-source": "^4.0.0" - }, - "engines": { - "node": ">=22.0.0" + "@types/estree": "*" } }, - "node_modules/@secretlint/profiler": { - "version": "13.0.4", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-13.0.4.tgz", - "integrity": "sha512-T2hSyZmJrQbGAe+Vl9AGNlMnoB0MP6m2BLh7EH80QcesvNM2t0pCzdiBvQ/yCe76w6/gZNpHlrSVayUeT43qVw==", - "license": "MIT" + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } }, - "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "13.0.4", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-13.0.4.tgz", - "integrity": "sha512-Nbcr7tvyKuRF4BKh7RQSCEOaif4gFPH/qjK2ajcoUDGL7HAY/C6PzcsmVR1Y0qQCRqrBuMuXn1onXE+wxNNNsw==", + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "license": "MIT", - "engines": { - "node": ">=22.0.0" + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@secretlint/types": { - "version": "13.0.4", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-13.0.4.tgz", - "integrity": "sha512-on/DivRDZEFzRD2pZJO0wkIL+AvEY+KOoZLPCWcz4pjZnJ4NzMuHQjvTJc9KY0yht+ugcYg9AmtOUzpEk31IWA==", + "node_modules/@types/express-session": { + "version": "1.18.2", + "dev": true, "license": "MIT", - "engines": { - "node": ">=22.0.0" + "dependencies": { + "@types/express": "*" } }, - "node_modules/@simple-git/args-pathspec": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", - "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "node_modules/@types/fast-levenshtein": { + "version": "0.0.4", "license": "MIT" }, - "node_modules/@simple-git/argv-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", - "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "dev": true, "license": "MIT", "dependencies": { - "@simple-git/args-pathspec": "^1.0.3" + "@types/jsonfile": "*", + "@types/node": "*" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", + "node_modules/@types/hast": { + "version": "3.0.4", "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@types/unist": "*" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "license": "MIT" + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" + "@types/ms": "*", + "@types/node": "*" } }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, + "node_modules/@types/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" + "@types/unist": "*" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, + "node_modules/@types/ms": { + "version": "2.1.0", "license": "MIT" }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, + "node_modules/@types/multer": { + "version": "2.0.0", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "@types/express": "*" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "license": "MIT", - "peer": true + "dependencies": { + "undici-types": "~6.21.0" + } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", + "node_modules/@types/oauth": { + "version": "0.9.6", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@types/node": "*" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", + "node_modules/@types/parse-path": { + "version": "7.0.3", + "license": "MIT" + }, + "node_modules/@types/passport": { + "version": "1.0.17", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@types/express": "*" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", + "node_modules/@types/passport-github2": { + "version": "1.2.9", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", + "node_modules/@types/prismjs": { + "version": "1.26.5", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { - "@types/connect": "*", - "@types/node": "*" + "csstype": "^3.2.2" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@types/react-dom": { + "version": "19.2.3", "dev": true, "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@types/react": "*" } }, - "node_modules/@types/connect": { - "version": "3.4.38", + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@types/cors": { - "version": "2.8.19", + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", "dependencies": { + "@types/http-errors": "*", "@types/node": "*" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", + "node_modules/@types/turndown": { + "version": "5.0.6", + "dev": true, "license": "MIT" }, - "node_modules/@types/d3-color": { - "version": "3.1.3", + "node_modules/@types/unist": { + "version": "3.0.3", "license": "MIT" }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", "license": "MIT" }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", + "node_modules/@types/uuid": { + "version": "10.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/d3-color": "*" + "@types/node": "*" } }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "license": "MIT" + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/d3-time": "*" + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/d3-path": "*" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "license": "MIT" + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@types/debug": { - "version": "4.1.12", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/ms": "*" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", "license": "MIT", - "dependencies": { - "@types/estree": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, "license": "MIT", "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", - "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/express-session": { - "version": "1.18.2", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", "dev": true, "license": "MIT", "dependencies": { - "@types/express": "*" + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/fast-levenshtein": { - "version": "0.0.4", - "license": "MIT" + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", "dev": true, "license": "MIT", "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@types/hast": { - "version": "3.0.4", + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, "license": "MIT", "dependencies": { - "@types/unist": "*" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.21", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "license": "MIT", - "dependencies": { - "@types/unist": "*" + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/ms": { - "version": "2.1.0", - "license": "MIT" - }, - "node_modules/@types/multer": { - "version": "2.0.0", + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, "license": "MIT", "dependencies": { - "@types/express": "*" + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/oauth": { - "version": "0.9.6", + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/parse-path": { - "version": "7.0.3", - "license": "MIT" - }, - "node_modules/@types/passport": { - "version": "1.0.17", + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@types/express": "*" + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/passport-github2": { - "version": "1.2.9", + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "dev": true, "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" + "engines": { + "node": ">=14.6" } }, - "node_modules/@types/passport-oauth2": { - "version": "1.8.0", - "dev": true, + "node_modules/accepts": { + "version": "1.3.8", "license": "MIT", "dependencies": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/@types/prismjs": { - "version": "1.26.5", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "dependencies": { - "@types/react": "*" + "engines": { + "node": ">= 14" } }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "node_modules/ajv-formats": { + "version": "3.0.1", "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/@types/turndown": { - "version": "5.0.6", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "license": "MIT" - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-push": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", - "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", - "dev": true, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "@types/node": "*" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", - "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/type-utils": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.66.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", - "dev": true, + "node_modules/ansi-regex": { + "version": "6.2.2", "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", - "debug": "^4.4.3" + "color-convert": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "node_modules/any-promise": { + "version": "1.3.0", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">= 8" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", - "dev": true, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=8.6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", - "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=12" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=8.0.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "node_modules/author-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", + "integrity": "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0" - }, + "optional": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=0.8" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", - "dev": true, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.66.0", - "eslint-visitor-keys": "^5.0.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", + "node_modules/autoprefixer": { + "version": "10.4.23", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^10 || ^12 || >=14" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "postcss": "^8.1.0" } }, - "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", - "dev": true, + "node_modules/bail": { + "version": "2.0.2", "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", - "dev": true, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "vite": { - "optional": true + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } - } + ], + "license": "MIT" }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", - "dev": true, + "node_modules/base64id": { + "version": "2.0.0", "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^4.5.0 || >= 5.9" } }, - "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", - "dev": true, + "node_modules/base64url": { + "version": "3.0.1", "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.4", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } + "node_modules/before-after-hook": { + "version": "4.0.0", + "license": "Apache-2.0" }, - "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", - "dev": true, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" } }, - "node_modules/accepts": { - "version": "1.3.8", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" + "require-from-string": "^2.0.2" } }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, + "node_modules/binary-extensions": { + "version": "2.3.0", "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "node_modules/bindings": { + "version": "1.5.0", "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "file-uri-to-path": "1.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/bl": { + "version": "4.1.0", "license": "MIT", - "engines": { - "node": ">= 14" + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "engines": { + "node": ">= 0.8" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" + "node_modules/body-parser/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "environment": "^1.0.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", + "node_modules/body-parser/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, "engines": { - "node": ">=12" + "node": ">= 18" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/boolbase": { + "version": "1.0.0", "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/any-promise": { - "version": "1.3.0", + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "license": "ISC", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">= 8" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, - "node_modules/append-field": { - "version": "1.0.0", - "license": "MIT" + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause" }, - "node_modules/arg": { - "version": "5.0.2", - "dev": true, + "node_modules/buffer-from": { + "version": "1.1.2", "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "node_modules/bullmq": { + "version": "5.81.3", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", + "integrity": "sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.5", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", + "node_modules/busboy": { + "version": "1.6.0", "dependencies": { - "dequal": "^2.0.3" + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", "license": "MIT", "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/call-bound": { + "version": "1.0.4", "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8.0.0" + "node": ">=6" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 6" } }, - "node_modules/autoprefixer": { - "version": "10.4.23", + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=10" }, - "peerDependencies": { - "postcss": "^8.1.0" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/bail": { + "node_modules/character-entities": { "version": "2.0.2", "license": "MIT", "funding": { @@ -3797,1200 +4951,1221 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/character-entities-html4": { + "version": "2.1.0", "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/base64id": { - "version": "2.0.0", + "node_modules/character-entities-legacy": { + "version": "3.0.0", "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/base64url": { - "version": "3.0.1", + "node_modules/character-reference-invalid": { + "version": "2.0.1", "license": "MIT", - "engines": { - "node": ">=6.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "node_modules/cheerio": { + "version": "1.1.2", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "license": "Apache-2.0" - }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "hasInstallScript": true, "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "node_modules/cheerio-select": { + "version": "2.1.0", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "require-from-string": "^2.0.2" + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", + "node_modules/chokidar": { + "version": "3.6.0", "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, "engines": { - "node": ">=8" + "node": ">= 8.10.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "node_modules/bindings": { - "version": "1.5.0", - "license": "MIT", + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", "dependencies": { - "file-uri-to-path": "1.0.0" + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/bl": { - "version": "4.1.0", + "node_modules/chownr": { + "version": "1.1.4", + "license": "ISC" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "engines": { + "node": ">=6.0" } }, - "node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, "engines": { - "node": ">=18" + "node": ">=18.20 <19 || >=20.10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser/node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, "engines": { - "node": ">= 0.8" + "node": ">=22" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/body-parser/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/body-parser/node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">= 18" + "node": ">=20" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boolbase": { - "version": "1.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/boundary": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", - "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", - "license": "BSD-2-Clause" - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/clsx": { + "version": "2.1.1", "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "20 || >=22" + "node": ">=6" } }, - "node_modules/braces": { - "version": "3.0.3", + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "convert-to-spaces": "^2.0.1" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" + "color-name": "~1.1.4" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=7.0.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "license": "BSD-3-Clause" + "node_modules/commander": { + "version": "10.0.1", + "license": "MIT", + "engines": { + "node": ">=14" + } }, - "node_modules/buffer-from": { - "version": "1.1.2", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, - "node_modules/bullmq": { - "version": "5.81.3", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", - "integrity": "sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==", + "node_modules/concat-stream": { + "version": "2.0.0", + "engines": [ + "node >= 6.0" + ], "license": "MIT", "dependencies": { - "cron-parser": "4.9.0", - "ioredis": "5.11.1", - "msgpackr": "2.0.5", - "node-abort-controller": "3.1.1", - "semver": "7.8.5", - "tslib": "2.8.1" - }, + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/connect-redis": { + "version": "9.0.0", + "license": "MIT", "engines": { - "node": ">=12.22.0" + "node": ">=18" }, "peerDependencies": { - "redis": ">=5.0.0" - }, - "peerDependenciesMeta": { - "redis": { - "optional": true - } + "express-session": ">=1", + "redis": ">=5" } }, - "node_modules/busboy": { - "version": "1.6.0", - "dependencies": { - "streamsearch": "^1.1.0" + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", "engines": { - "node": ">=10.16.0" + "node": ">= 0.6" } }, - "node_modules/bytes": { - "version": "3.1.2", + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", + "node_modules/cookie": { + "version": "0.7.2", "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, - "node_modules/call-bound": { - "version": "1.0.4", + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "object-assign": "^4", + "vary": "^1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.10" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "node_modules/cron-parser": { + "version": "4.9.0", "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, "engines": { - "node": ">=6" + "node": ">=12.0.0" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": ">= 6" + "node": ">= 8" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "node_modules/cross-zip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cross-zip/-/cross-zip-4.0.1.tgz", + "integrity": "sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==", "dev": true, "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=12.10" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/css-select": { + "version": "5.2.2", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "license": "MIT", + "node_modules/css-what": { + "version": "6.2.2", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" }, - "node_modules/character-entities-legacy": { + "node_modules/cssesc": { "version": "3.0.0", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" } }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" }, - "node_modules/cheerio": { - "version": "1.1.2", - "dev": true, - "license": "MIT", + "node_modules/d3-array": { + "version": "3.2.4", + "license": "ISC", "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.0.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.12.0", - "whatwg-mimetype": "^4.0.0" + "internmap": "1 - 2" }, "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + "node": ">=12" } }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "node_modules/d3-color": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, + "node_modules/d3-ease": { + "version": "3.0.1", + "license": "BSD-3-Clause", "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">=12" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", + "node_modules/d3-format": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "d3-color": "1 - 3" }, "engines": { - "node": ">= 6" + "node": ">=12" } }, - "node_modules/chownr": { - "version": "1.1.4", - "license": "ISC" - }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", - "license": "MIT", + "node_modules/d3-path": { + "version": "3.1.0", + "license": "ISC", "engines": { - "node": ">=18.20 <19 || >=20.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", - "license": "MIT", + "node_modules/d3-scale": { + "version": "4.0.2", + "license": "ISC", "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/cli-truncate/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", + "node_modules/d3-shape": { + "version": "3.2.0", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", - "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "license": "ISC", "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" + "d3-array": "2 - 3" }, "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=12" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "license": "MIT", + "node_modules/d3-time-format": { + "version": "4.1.0", + "license": "ISC", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "d3-time": "1 - 3" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", + "node_modules/d3-timer": { + "version": "3.0.1", + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "license": "Apache-2.0", + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 12" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "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": { - "convert-to-spaces": "^2.0.1" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/data-urls/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", - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=20" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "license": "MIT" - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", + "node_modules/dateformat": { + "version": "4.6.3", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": "*" } }, - "node_modules/commander": { - "version": "10.0.1", + "node_modules/debug": { + "version": "4.4.3", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=14" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "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/concat-stream": { - "version": "2.0.0", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } + "node_modules/decimal.js-light": { + "version": "2.5.1", + "license": "MIT" }, - "node_modules/connect-redis": { - "version": "9.0.0", + "node_modules/decode-named-character-reference": { + "version": "1.2.0", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "character-entities": "^2.0.0" }, - "peerDependencies": { - "express-session": ">=1", - "redis": ">=5" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "node_modules/decompress-response": { + "version": "6.0.0", "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/content-type": { - "version": "1.0.5", + "node_modules/deep-extend": { + "version": "0.6.0", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=4.0.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", + "node_modules/deep-is": { + "version": "0.1.4", "dev": true, "license": "MIT" }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "license": "MIT", + "node_modules/denque": { + "version": "2.1.0", + "license": "Apache-2.0", "engines": { - "node": ">= 0.6" + "node": ">=0.10" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", + "node_modules/depd": { + "version": "2.0.0", "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, "engines": { - "node": ">= 0.10" + "node": ">= 0.8" } }, - "node_modules/cron-parser": { - "version": "4.9.0", + "node_modules/dequal": { + "version": "2.0.3", "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, "engines": { - "node": ">=12.0.0" + "node": ">=6" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/css-select": { - "version": "5.2.2", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/devlop": { + "version": "1.1.0", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "dequal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "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==", + "node_modules/didyoumean": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "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" - } + "peer": true }, - "node_modules/css-what": { - "version": "6.2.2", + "node_modules/dom-serializer": { + "version": "2.0.0", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "node_modules/domelementtype": { + "version": "2.3.0", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" }, - "node_modules/cssesc": { - "version": "3.0.0", + "node_modules/domhandler": { + "version": "5.0.3", "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" }, "engines": { - "node": ">=4" + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/csstype": { - "version": "3.2.3", - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "license": "ISC", + "node_modules/domutils": { + "version": "3.2.2", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "internmap": "1 - 2" + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" }, - "engines": { - "node": ">=12" + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/d3-color": { - "version": "3.1.0", - "license": "ISC", + "node_modules/dotenv": { + "version": "16.5.0", + "license": "BSD-2-Clause", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "license": "BSD-3-Clause", + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" } }, - "node_modules/d3-format": { - "version": "3.1.0", - "license": "ISC", - "engines": { - "node": ">=12" - } + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "license": "ISC", + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" + "safe-buffer": "^5.0.1" } }, - "node_modules/d3-path": { - "version": "3.1.0", - "license": "ISC", - "engines": { - "node": ">=12" - } + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" }, - "node_modules/d3-scale": { - "version": "4.0.2", - "license": "ISC", + "node_modules/electron": { + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">=12" + "node": ">= 22.12.0" } }, - "node_modules/d3-shape": { - "version": "3.2.0", - "license": "ISC", + "node_modules/electron-installer-common": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/electron-installer-common/-/electron-installer-common-0.10.4.tgz", + "integrity": "sha512-8gMNPXfAqUE5CfXg8RL0vXpLE9HAaPkgLXVoHE3BMUzogMWenf4LmwQ27BdCUrEhkjrKl+igs2IHJibclR3z3Q==", + "dev": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "d3-path": "^3.1.0" + "@electron/asar": "^3.2.5", + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "glob": "^7.1.4", + "lodash": "^4.17.15", + "parse-author": "^2.0.0", + "semver": "^7.1.1", + "tmp-promise": "^3.0.2" }, "engines": { - "node": ">=12" + "node": ">= 10.0.0" + }, + "funding": { + "url": "https://github.com/electron-userland/electron-installer-common?sponsor=1" + }, + "optionalDependencies": { + "@types/fs-extra": "^9.0.1" } }, - "node_modules/d3-time": { - "version": "3.1.0", - "license": "ISC", + "node_modules/electron-installer-common/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "optional": true, "dependencies": { - "d3-array": "2 - 3" + "cross-spawn": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">= 10" } }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "license": "ISC", + "node_modules/electron-installer-common/node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" + "@types/node": "*" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "license": "ISC", + "node_modules/electron-installer-common/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">=12" + "node": ">=10" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", + "node_modules/electron-installer-debian": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/electron-installer-debian/-/electron-installer-debian-3.2.0.tgz", + "integrity": "sha512-58ZrlJ1HQY80VucsEIG9tQ//HrTlG6sfofA3nRGr6TmkX661uJyu4cMPPh6kXW+aHdq/7+q25KyQhDrXvRL7jw==", + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.10.2", + "fs-extra": "^9.0.0", + "get-folder-size": "^2.0.1", + "lodash": "^4.17.4", + "word-wrap": "^1.2.3", + "yargs": "^16.0.2" + }, + "bin": { + "electron-installer-debian": "src/cli.js" + }, "engines": { - "node": ">= 12" + "node": ">= 10.0.0" } }, - "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==", + "node_modules/electron-installer-debian/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "optional": true, "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "cross-spawn": "^7.0.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">= 10" } }, - "node_modules/data-urls/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==", + "node_modules/electron-installer-debian/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">=20" + "node": ">=8" } }, - "node_modules/dateformat": { - "version": "4.6.3", - "license": "MIT", - "engines": { - "node": "*" + "node_modules/electron-installer-debian/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", + "node_modules/electron-installer-debian/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "ms": "^2.1.3" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=10" } }, - "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==", + "node_modules/electron-installer-debian/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT" - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "license": "MIT" - }, - "node_modules/decode-named-character-reference": { - "version": "1.2.0", "license": "MIT", + "optional": true, "dependencies": { - "character-entities": "^2.0.0" + "ansi-regex": "^5.0.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=8" } }, - "node_modules/decompress-response": { - "version": "6.0.0", + "node_modules/electron-installer-debian/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "mimic-response": "^3.1.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/deep-extend": { - "version": "0.6.0", + "node_modules/electron-installer-debian/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, "engines": { - "node": ">=4.0.0" + "node": ">=10" } }, - "node_modules/deep-is": { - "version": "0.1.4", + "node_modules/electron-installer-debian/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, - "license": "MIT" - }, - "node_modules/denque": { - "version": "2.1.0", - "license": "Apache-2.0", + "license": "ISC", + "optional": true, "engines": { - "node": ">=0.10" + "node": ">=10" } }, - "node_modules/depd": { - "version": "2.0.0", + "node_modules/electron-installer-redhat": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/electron-installer-redhat/-/electron-installer-redhat-3.4.0.tgz", + "integrity": "sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw==", + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.10.2", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "word-wrap": "^1.2.3", + "yargs": "^16.0.2" + }, + "bin": { + "electron-installer-redhat": "src/cli.js" + }, "engines": { - "node": ">= 0.8" + "node": ">= 10.0.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "license": "MIT", + "node_modules/electron-installer-redhat/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, "engines": { - "node": ">=6" + "node": ">= 10" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "license": "Apache-2.0", + "node_modules/electron-installer-redhat/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, "engines": { "node": ">=8" } }, - "node_modules/devlop": { - "version": "1.1.0", - "license": "MIT", + "node_modules/electron-installer-redhat/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "optional": true, "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dom-serializer": { - "version": "2.0.0", + "node_modules/electron-installer-redhat/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", + "node_modules/electron-installer-redhat/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, "dependencies": { - "domelementtype": "^2.3.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=8" } }, - "node_modules/domutils": { - "version": "3.2.2", + "node_modules/electron-installer-redhat/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dotenv": { - "version": "16.5.0", - "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", + "node_modules/electron-installer-redhat/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" + "node": ">=10" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "license": "MIT" + "node_modules/electron-installer-redhat/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } }, "node_modules/electron-to-chromium": { "version": "1.5.420", @@ -4999,6 +6174,31 @@ "dev": true, "license": "ISC" }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "license": "MIT", @@ -5088,6 +6288,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "license": "MIT", @@ -5098,6 +6308,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -5113,9 +6330,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -5958,6 +7175,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6343,6 +7567,35 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "license": "MIT", @@ -6408,6 +7661,19 @@ "dev": true, "license": "ISC" }, + "node_modules/flora-colossus": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-3.0.2.tgz", + "integrity": "sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/format": { "version": "0.2.2", "engines": { @@ -6493,6 +7759,14 @@ "node": ">=14.14" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6514,6 +7788,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/galactus": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/galactus/-/galactus-2.0.2.tgz", + "integrity": "sha512-HmKyTFGomdAchz4umx8MwBnrnfFmdpwiTyGA4ZOF7rya2Lmgbc9qate4yweInL+0gUBVImhaz12SBGpW3SY4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "flora-colossus": "^3.0.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/gar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz", + "integrity": "sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -6522,6 +7819,17 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -6534,6 +7842,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-folder-size": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-2.0.1.tgz", + "integrity": "sha512-+CEb+GDCM7tkOS2wdMKTn9vU7DgnKUTuDlehkNJKNSovdCOVxs14OfKCk4cvSaR3za4gj+OBdl9opPN9xrJ0zA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "gar": "^1.0.4", + "tiny-each-async": "2.0.3" + }, + "bin": { + "get-folder-size": "bin/get-folder-size" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -6622,6 +7945,29 @@ "version": "0.0.0", "license": "MIT" }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -6633,6 +7979,40 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "16.5.0", "dev": true, @@ -7031,6 +8411,19 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "license": "ISC" @@ -7112,21 +8505,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/ink/node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ink/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -7139,37 +8517,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink/node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ink/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -7724,6 +9071,19 @@ "npm": ">=6" } }, + "node_modules/junk": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", + "integrity": "sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jwa": { "version": "1.4.2", "license": "MIT", @@ -7800,57 +9160,148 @@ } } }, - "node_modules/knex/node_modules/colorette": { - "version": "2.0.19", - "license": "MIT" + "node_modules/knex/node_modules/colorette": { + "version": "2.0.19", + "license": "MIT" + }, + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz", + "integrity": "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^3.1.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^5.0.1", + "rfdc": "^1.3.0", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "node_modules/knex/node_modules/debug": { - "version": "4.3.4", + "node_modules/listr2/node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" }, "engines": { - "node": ">=6.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/knex/node_modules/ms": { - "version": "2.1.2", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, - "node_modules/levn": { - "version": "0.4.1", + "node_modules/listr2/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lilconfig": { - "version": "3.1.3", + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -7906,6 +9357,111 @@ "version": "4.1.1", "license": "MIT" }, + "node_modules/log-update": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", + "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^5.0.0", + "cli-cursor": "^4.0.0", + "slice-ansi": "^5.0.0", + "strip-ansi": "^7.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", + "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "license": "MIT", @@ -9045,6 +10601,16 @@ "version": "3.1.1", "license": "MIT" }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "funding": [ @@ -9078,6 +10644,31 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -9093,6 +10684,78 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -9216,6 +10879,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -9273,6 +10951,20 @@ "node": ">=6" } }, + "node_modules/parse-author": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-author/-/parse-author-2.0.0.tgz", + "integrity": "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "author-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "license": "MIT", @@ -9455,6 +11147,17 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "license": "MIT", @@ -9466,6 +11169,33 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -9486,6 +11216,21 @@ "node_modules/pause": { "version": "0.0.1" }, + "node_modules/pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/pg-connection-string": { "version": "2.6.2", "license": "MIT" @@ -9639,6 +11384,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -9792,6 +11552,32 @@ "dev": true, "license": "MIT" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "license": "MIT", @@ -9907,6 +11693,30 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/property-information": { "version": "7.1.0", "license": "MIT", @@ -10228,6 +12038,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, "node_modules/read-cache": { "version": "1.0.0", "dev": true, @@ -10637,6 +12460,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "license": "MIT", @@ -10644,6 +12478,24 @@ "node": ">=0.10.0" } }, + "node_modules/resedit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.3.tgz", + "integrity": "sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^1.0.1" + }, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/reselect": { "version": "5.1.1", "license": "MIT" @@ -10681,6 +12533,38 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "license": "MIT", @@ -10689,6 +12573,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -11137,6 +13028,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/socket.io": { "version": "4.8.3", "license": "MIT", @@ -11211,6 +13145,19 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "license": "MIT", @@ -11285,6 +13232,58 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "license": "MIT", @@ -11395,6 +13394,19 @@ "node": ">= 6" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11549,6 +13561,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/thenify": { "version": "3.3.1", "dev": true, @@ -11582,6 +13624,14 @@ "node": ">=8" } }, + "node_modules/tiny-each-async": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", + "integrity": "sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "license": "MIT" @@ -11668,6 +13718,28 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmp-promise/node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -12622,6 +14694,16 @@ "node": ">=16.0.0" } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -12635,6 +14717,17 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -12644,6 +14737,16 @@ "node": ">=18" } }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, @@ -12702,6 +14805,7 @@ "version": "0.8.15", "dependencies": { "@propr/core": "^0.8.15", + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "@types/multer": "^2.0.0", "bullmq": "^5.81.3", @@ -12812,6 +14916,7 @@ "name": "@propr/cli", "version": "0.8.15", "dependencies": { + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "commander": "^13.1.0", "dotenv": "^16.5.0", @@ -12839,6 +14944,22 @@ "node": ">=18" } }, + "packages/client": { + "name": "@propr/client", + "version": "0.8.15", + "dependencies": { + "@propr/shared": "^0.8.15", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, "packages/core": { "name": "@propr/core", "version": "0.8.15", @@ -12887,6 +15008,20 @@ "fastest-levenshtein": "^1.0.7" } }, + "packages/local-setup": { + "name": "@propr/local-setup", + "version": "0.8.15", + "dependencies": { + "@propr/shared": "^0.8.15" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + } + }, "packages/shared": { "name": "@propr/shared", "version": "0.8.15", @@ -12903,6 +15038,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@propr/client": "*", "@propr/shared": "*", "@types/lodash": "^4.17.21", "@types/react-syntax-highlighter": "^15.5.13", @@ -12919,8 +15055,7 @@ "react-textarea-autosize": "^8.5.9", "recharts": "^3.6.0", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.0", - "socket.io-client": "^4.7.5" + "remark-gfm": "^4.0.0" }, "devDependencies": { "@eslint/js": "^9.30.1", diff --git a/package.json b/package.json index 367bb996d..8e6a6e4b0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "propr-ui" ], "overrides": { + "@electron/packager": "20.3.0", + "@electron/rebuild": "4.2.0", "react": "19.2.7" }, "scripts": { @@ -17,14 +19,15 @@ "lint": "eslint src/", "typecheck": "tsc --noEmit", "test": "node --test", - "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/core && npm run build --workspace=packages/cli", + "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/client && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli", "test:server": "node scripts/run-test-suite.mjs", "test:full:prepared": "npm run test:server", "test:full": "npm run test:prepare && npm run test:full:prepared", "test:notifications:server": "node scripts/run-test-suite.mjs test/notificationSchema.test.ts test/notificationPreferenceMigration.test.ts packages/core/test/notificationService.test.ts packages/core/test/planNotificationActionsMigration.test.ts packages/core/test/pushSubscriptionExpiration.test.ts packages/api/test/notificationRoutes.test.ts packages/api/test/notificationManagementRoutes.test.ts packages/api/test/notificationProjectionService.test.ts packages/api/test/webPushDispatcher.test.ts", "test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx", "test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui", - "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", + "pretest:unit": "npm run build -w @propr/shared && npm run build -w @propr/local-setup", + "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/agentImagePreparation.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", "test:e2e": "npx tsx --test test/e2e.test.ts", "test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts", "test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test", @@ -68,6 +71,18 @@ "cli:pack": "node packages/cli/scripts/build-publish.mjs", "cli:publish": "node packages/cli/scripts/build-publish.mjs --publish", "deploy:hosted-ui": "npm run build -w propr-ui && npx wrangler deploy --config wrangler.hosted-ui.toml", + "desktop": "npm run dev -w @propr/desktop", + "desktop:dev": "npm run dev -w @propr/desktop", + "desktop:prepare": "npm run build -w @propr/shared && npm run build -w @propr/client", + "desktop:typecheck": "npm run typecheck -w @propr/desktop && npm run typecheck -w propr-ui", + "desktop:test": "npm run test -w @propr/desktop", + "desktop:package": "npm run package -w @propr/desktop", + "desktop:smoke": "npm run smoke:package -w @propr/desktop", + "desktop:smoke:inspect": "npm run smoke:inspect -w @propr/desktop", + "desktop:make": "npm run make -w @propr/desktop", + "audit:runtime": "npm audit --package-lock-only --omit=dev --audit-level=low", + "desktop:audit:packaging": "npm audit --package-lock-only --workspace=@propr/desktop --include=dev --audit-level=high", + "desktop:audit": "npm run audit:runtime && npm run desktop:audit:packaging", "start:prod": "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/.env:/app/.env:ro -v $PWD/data:/app/data -v $PWD/logs:/app/logs -v $PWD/repos:/app/repos propr/launcher:latest" }, "keywords": [], @@ -91,6 +106,7 @@ "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.1.1", "@propr/core": "^0.8.15", + "@propr/local-setup": "^0.8.15", "better-sqlite3": "^11.7.0", "bullmq": "^5.81.3", "cors": "^2.8.5", diff --git a/packages/api/README.md b/packages/api/README.md index 5dcb82b74..bc5c5c2e3 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -59,12 +59,19 @@ To run the API in development mode: ## API Endpoints -All API endpoints are protected by authentication: +All operational API endpoints are protected by authentication. Compatibility, +desktop discovery, and the bounded pairing bootstrap/poll routes are the +documented pre-authentication exceptions: - `GET /api/auth/github` - Initiate GitHub OAuth flow - `GET /api/auth/github/callback` - OAuth callback - `GET /api/auth/logout` - Logout user - `GET /api/auth/user` - Get sanitized current user info, instance role, and permissions +- `GET /api/desktop/discovery` - Public product/API compatibility and desktop-auth capabilities only +- `POST /api/desktop/pairings` - Start a short-lived browser pairing request +- `POST /api/desktop/pairings/:pairingId/poll` - Poll with the device secret in the JSON body +- `GET /api/desktop/tokens` - List the current user's safe instance-token metadata +- `DELETE /api/desktop/tokens/:tokenId` - Revoke one of the current user's instance tokens - `GET /api/catalog` - Get the sanitized enabled repository/agent catalog needed by member workflows - `GET /api/repositories/indexing-status` - Get indexing status projected to enabled catalog repository/branch entries - `GET /api/admin/members` - List explicit role assignments (administrator) diff --git a/packages/api/apiCacheControl.ts b/packages/api/apiCacheControl.ts new file mode 100644 index 000000000..ee223ea38 --- /dev/null +++ b/packages/api/apiCacheControl.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from 'express'; + +/** + * Attach the API discovery cache prohibition at the first `/api` boundary so + * limiters, route handlers, and error handlers all inherit the same headers. + */ +export const prohibitApiResponseCaching: RequestHandler = (_request, response, next) => { + response.set('Cache-Control', 'no-store, max-age=0'); + response.set('Pragma', 'no-cache'); + next(); +}; diff --git a/packages/api/auth.ts b/packages/api/auth.ts index 420a52e4c..104d10490 100644 --- a/packages/api/auth.ts +++ b/packages/api/auth.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- browser, bearer, and socket authentication share session state */ +/* eslint-disable max-lines -- browser, GitHub bearer, instance-token, Socket.IO, and preview auth share one policy boundary */ import passport from 'passport'; import { Strategy as GitHubStrategy, Profile } from 'passport-github2'; import session from 'express-session'; @@ -8,6 +8,7 @@ import { randomBytes } from 'node:crypto'; import type { Express, Request, Response, NextFunction, RequestHandler } from 'express'; import { validateSessionSecret } from '@propr/shared'; import { validateGitHubToken } from './authBearer.js'; +import { desktopAuthService, INSTANCE_TOKEN_PREFIX } from './desktopAuthService.js'; import { configureDemoMode, getDemoUser, isDemoMode } from './demoMode.js'; import { clearSessionForReauth, isGitHubTokenExpired, refreshGitHubTokenWithResult } from './authGithubTokens.js'; import { getValidatedRedirectTo, getDefaultRedirectUrl } from './authRedirect.js'; @@ -52,6 +53,7 @@ export interface SocketPrincipal { export interface SocketAuthenticationDependencies { validateToken: typeof validateGitHubToken; + validateInstanceToken?: typeof desktopAuthService.validateToken; isWhitelisted: typeof isUserWhitelisted; resolveInstanceAuthorization: typeof resolveInstanceAuthorization; refreshToken: typeof refreshGitHubTokenWithResult; @@ -59,6 +61,7 @@ export interface SocketAuthenticationDependencies { const defaultSocketAuthenticationDependencies: SocketAuthenticationDependencies = { validateToken: validateGitHubToken, + validateInstanceToken: token => desktopAuthService.validateToken(token), isWhitelisted: isUserWhitelisted, resolveInstanceAuthorization, refreshToken: refreshGitHubTokenWithResult, @@ -346,6 +349,8 @@ export function setupAuth(app: Express, demoModeAtStartup = isDemoMode()): Socke * HTTP API. Browser clients normally arrive with a Passport session cookie; * non-browser clients may provide the normal Authorization: Bearer header. */ +// Session refresh and two bearer credential classes intentionally fail closed here. +// eslint-disable-next-line complexity export async function authenticateSocketRequest( req: Request, dependencies: SocketAuthenticationDependencies = defaultSocketAuthenticationDependencies, @@ -372,20 +377,41 @@ export async function authenticateSocketRequest( throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed'); } + req.authenticationMethod = 'session'; return { user: req.user, authorization: await dependencies.resolveInstanceAuthorization(req.user), }; } - const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false'; const rawAuthHeader = req.headers.authorization; const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader; - if (bearerEnabled && authHeader?.startsWith('Bearer ')) { + if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7).trim(); if (!token) { throw new SocketAuthenticationError('INVALID_BEARER_TOKEN', 'Bearer token is empty'); } + if (token.startsWith(INSTANCE_TOKEN_PREFIX)) { + const identity = await (dependencies.validateInstanceToken + ? dependencies.validateInstanceToken(token) + : desktopAuthService.validateToken(token)); + if (!identity) { + throw new SocketAuthenticationError('INVALID_INSTANCE_TOKEN', 'Instance token is invalid'); + } + if (!dependencies.isWhitelisted(identity.user.username)) { + throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed'); + } + req.authenticationMethod = 'instance_token'; + req.instanceTokenId = identity.tokenId; + return { + user: identity.user, + authorization: await dependencies.resolveInstanceAuthorization(identity.user), + }; + } + const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false'; + if (!bearerEnabled) { + throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'Authentication required'); + } const user = await dependencies.validateToken(token); if (!user) { throw new SocketAuthenticationError('INVALID_BEARER_TOKEN', 'Bearer token is invalid'); @@ -393,6 +419,7 @@ export async function authenticateSocketRequest( if (!dependencies.isWhitelisted(user.username)) { throw new SocketAuthenticationError('USER_NOT_WHITELISTED', 'GitHub user is not allowed'); } + req.authenticationMethod = 'github_bearer'; return { user, authorization: await dependencies.resolveInstanceAuthorization(user), @@ -402,14 +429,20 @@ export async function authenticateSocketRequest( throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'Authentication required'); } -// Session, bearer, demo, and refresh outcomes are intentionally centralized. +// Keep REST precedence identical to Socket.IO: demo, session, instance token, GitHub bearer. // eslint-disable-next-line complexity -export async function ensureAuthenticated(req: Request, res: Response, next: NextFunction): Promise { +export async function ensureAuthenticated( + req: Request, + res: Response, + next: NextFunction, + validateInstanceToken: (token: string) => ReturnType = token => desktopAuthService.validateToken(token), +): Promise { if (isDemoMode()) { res.set('X-ProPR-Demo-Mode', 'true'); // Demo mode is deployment-wide: browser callers receive the synthetic read-only user. // Stale bearer headers are ignored so public demo visitors are treated consistently. (req as Request & { user: GitHubUser }).user = getDemoUser(); + req.authenticationMethod = 'demo'; return next(); } @@ -457,15 +490,42 @@ export async function ensureAuthenticated(req: Request, res: Response, next: Nex console.warn('Proactive GitHub token refresh was temporarily unavailable; continuing with the unexpired session token'); } } + req.authenticationMethod = 'session'; return next(); } - // Bearer token auth (CLI) - const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false'; + // Bearer token auth (desktop instance token or optional GitHub token for CLI) const authHeader = req.headers.authorization; - if (bearerEnabled && authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7).trim(); + + if (token.startsWith(INSTANCE_TOKEN_PREFIX)) { + try { + const identity = await validateInstanceToken(token); + if (!identity) { + res.status(401).json({ error: 'Unauthorized: invalid instance token', code: 'INVALID_INSTANCE_TOKEN' }); + return; + } + if (!isUserWhitelisted(identity.user.username)) { + res.status(403).json({ error: 'Forbidden', code: 'USER_NOT_WHITELISTED', message: 'Your GitHub account is not authorized for this ProPR instance. Ask an admin to add you to the user whitelist.' }); + return; + } + (req as Request & { user: GitHubUser }).user = identity.user; + req.authenticationMethod = 'instance_token'; + req.instanceTokenId = identity.tokenId; + return next(); + } catch { + res.status(401).json({ error: 'Unauthorized: instance token validation failed', code: 'INVALID_INSTANCE_TOKEN' }); + return; + } + } + + const bearerEnabled = process.env.ENABLE_BEARER_AUTH !== 'false'; + if (!bearerEnabled) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } try { const user = await validateGitHubToken(token); @@ -476,6 +536,7 @@ export async function ensureAuthenticated(req: Request, res: Response, next: Nex } // Populate req.user so downstream handlers work the same way (req as Request & { user: GitHubUser }).user = user; + req.authenticationMethod = 'github_bearer'; return next(); } res.status(401).json({ error: 'Unauthorized: invalid token' }); diff --git a/packages/api/authRedirect.ts b/packages/api/authRedirect.ts index 2679c2094..f473b6c49 100644 --- a/packages/api/authRedirect.ts +++ b/packages/api/authRedirect.ts @@ -1,4 +1,5 @@ import { isIP } from 'net'; +import { canonicalProprHttpUrlOrigin, isProprLoopbackHostname } from '@propr/shared'; import type { AllowedRedirectHost } from './authTypes.js'; function isValidHostname(hostname: string): boolean { @@ -59,8 +60,8 @@ function isAllowedRedirectHost(hostname: string): boolean { } function isLocalHttpRedirectHost(hostname: string): boolean { - const normalized = normalizeHostname(hostname); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; + const normalized = hostname.includes(':') ? `[${normalizeHostname(hostname)}]` : normalizeHostname(hostname); + return isProprLoopbackHostname(normalized); } // HTTPS is required for all non-local redirect targets by default. HTTP is only @@ -79,6 +80,7 @@ export function getValidatedRedirectTo(redirectTo: string | undefined): string | try { const url = new URL(redirectTo); const hostname = normalizeHostname(url.hostname); + if (canonicalProprHttpUrlOrigin(redirectTo, { allowInsecureHttp: allowHttp }) !== url.origin) return undefined; if (url.protocol === 'https:' && isAllowedRedirectHost(hostname)) return url.toString(); if (url.protocol === 'http:' && isAllowedRedirectHost(hostname) && (allowHttp || isLocalHttpRedirectHost(hostname))) return url.toString(); } catch { diff --git a/packages/api/authSession.ts b/packages/api/authSession.ts index 10d347fce..28c419458 100644 --- a/packages/api/authSession.ts +++ b/packages/api/authSession.ts @@ -1,5 +1,6 @@ import type session from 'express-session'; import type { Request, Response } from 'express'; +import { isProprLoopbackHostname, normalizeProprApiOrigin } from '@propr/shared'; import { getDefaultRedirectUrl } from './authRedirect.js'; import { isUserWhitelisted } from './userWhitelist.js'; @@ -16,9 +17,13 @@ export function getSessionCookieDomain(): string | undefined { export function shouldUseSecureSessionCookie(cookieDomain: string | undefined): boolean { try { if (process.env.API_PUBLIC_URL) { - const url = new URL(process.env.API_PUBLIC_URL); + const raw = process.env.API_PUBLIC_URL; + const url = new URL(raw); if (url.protocol === 'https:') return true; - if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return false; + if (normalizeProprApiOrigin(raw) !== url.origin) { + return process.env.NODE_ENV === 'production' || Boolean(cookieDomain); + } + if (url.protocol === 'http:' && isProprLoopbackHostname(url.hostname)) return false; } return process.env.NODE_ENV === 'production' || Boolean(cookieDomain); } catch { diff --git a/packages/api/connectAuth.ts b/packages/api/connectAuth.ts index 720e2d837..79ef22c70 100644 --- a/packages/api/connectAuth.ts +++ b/packages/api/connectAuth.ts @@ -1,5 +1,10 @@ import type { GitHubUser } from './authTypes.js'; -import { DEFAULT_PROPR_GH_RELAY_URL } from '@propr/shared'; +import { + DEFAULT_PROPR_GH_RELAY_URL, + canonicalProprHttpUrlOrigin, + isProprLoopbackHostname, + normalizeProprApiOrigin, +} from '@propr/shared'; export const DEFAULT_PROPR_CONNECT_ORIGIN = 'https://connect.propr.dev'; const CONNECT_REDEEM_TIMEOUT_MS = 20_000; @@ -35,7 +40,10 @@ export function buildConnectAuthorizationUrl(options: { installationId?: string; }): string { const origin = new URL(options.connectOrigin || DEFAULT_PROPR_CONNECT_ORIGIN); - if (origin.protocol !== 'https:' || origin.username || origin.password || origin.search || origin.hash) { + if (origin.protocol !== 'https:' + || origin.search + || origin.hash + || normalizeProprApiOrigin(options.connectOrigin || DEFAULT_PROPR_CONNECT_ORIGIN) !== origin.origin) { throw new Error('PROPR_CONNECT_URL must be a bare HTTPS origin'); } const url = new URL('/instance-login', origin); @@ -54,9 +62,12 @@ export async function redeemConnectAuthorizationCode(options: { fetchImpl?: typeof fetch; }): Promise { const fetchImpl = options.fetchImpl ?? fetch; - const relayBase = options.relayUrl.trim().replace(/\/+$/, ''); + const relayRaw = options.relayUrl.trim(); + const relayBase = relayRaw.replace(/\/+$/, ''); const endpoint = new URL(`${relayBase}/auth/instance-grants/redeem`); - if (endpoint.protocol !== 'https:' && endpoint.hostname !== 'localhost' && endpoint.hostname !== '127.0.0.1') { + const canonicalRelayOrigin = canonicalProprHttpUrlOrigin(relayRaw); + if (!canonicalRelayOrigin + || (endpoint.protocol === 'http:' && !isProprLoopbackHostname(endpoint.hostname))) { throw new Error('PROPR_GH_RELAY_URL must use HTTPS'); } @@ -143,7 +154,9 @@ function isHostedConnectPath(env: NodeJS.ProcessEnv): boolean { function normalizeServiceUrl(value: string | undefined): string | undefined { try { if (!value?.trim()) return undefined; - const url = new URL(value.trim()); + const raw = value.trim(); + const url = new URL(raw); + if (canonicalProprHttpUrlOrigin(raw) !== url.origin) return undefined; if (url.username || url.password || url.search || url.hash) return undefined; const path = url.pathname.replace(/\/+$/, ''); return `${url.origin}${path}`; @@ -155,11 +168,12 @@ function normalizeServiceUrl(value: string | undefined): string | undefined { function isSupportedLoopbackCallback(value: string | undefined): boolean { try { if (!value?.trim()) return false; - const url = new URL(value.trim()); - const hostname = url.hostname.toLowerCase(); + const raw = value.trim(); + const url = new URL(raw); return ( url.protocol === 'http:' && - (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') && + canonicalProprHttpUrlOrigin(raw) === url.origin && + isProprLoopbackHostname(url.hostname) && url.username === '' && url.password === '' && url.pathname === '/api/auth/github/callback' && diff --git a/packages/api/corsValidation.ts b/packages/api/corsValidation.ts index c18a5d18e..c34fa5570 100644 --- a/packages/api/corsValidation.ts +++ b/packages/api/corsValidation.ts @@ -3,10 +3,16 @@ // The hosted UI origin (FRONTEND_URL, e.g. https://app.propr.dev) is always // allowed. When COOKIE_DOMAIN is set, the base domain and any of its subdomains // are also allowed so PR preview environments that share sessions via -// cross-subdomain cookies can talk to the API. localhost/127.0.0.1 are allowed -// for local development. +// cross-subdomain cookies can talk to the API. localhost/127.0.0.1/[::1] are +// allowed for local development. import type { ErrorRequestHandler } from 'express'; +import { + DESKTOP_RENDERER_ORIGIN, + canonicalProprHttpUrlOrigin, + isProprLoopbackHostname, + normalizeProprApiOrigin, +} from '@propr/shared'; export type CorsOriginCallback = (err: Error | null, allow?: boolean) => void; export type CorsOriginValidator = (origin: string | undefined, callback: CorsOriginCallback) => void; @@ -37,7 +43,8 @@ export const corsRejectionHandler: ErrorRequestHandler = (error, _req, res, next export function createCorsOriginValidator(frontendUrl: string, cookieDomain: string | undefined): CorsOriginValidator { // Remove leading dot if present for hostname matching const baseDomain = cookieDomain?.startsWith('.') ? cookieDomain.slice(1) : cookieDomain; - const frontendOrigin = new URL(frontendUrl).origin; + const frontendOrigin = canonicalProprHttpUrlOrigin(frontendUrl, { allowInsecureHttp: true }); + if (!frontendOrigin) throw new Error('FRONTEND_URL must contain a canonical HTTP(S) URL'); return function validateCorsOrigin(origin: string | undefined, callback: CorsOriginCallback): void { // Allow requests with no origin (e.g., mobile apps, curl, etc.) @@ -45,8 +52,17 @@ export function createCorsOriginValidator(frontendUrl: string, cookieDomain: str callback(null, true); return; } + // Electron registers this as a standard, secure scheme, which gives the + // packaged renderer a stable serialized origin. Match that origin exactly; + // never accept the generic `null` value used by arbitrary opaque origins. + if (origin === DESKTOP_RENDERER_ORIGIN) { + callback(null, true); + return; + } try { - const url = new URL(origin); + const canonicalOrigin = normalizeProprApiOrigin(origin, { allowInsecureHttp: true }); + if (!canonicalOrigin) throw new CorsOriginError(); + const url = new URL(canonicalOrigin); // Allow the base domain and any subdomain. The previous inline validator // allowed both http and https here, and some non-tunnel PR-preview // deployments still use http://.. Keep that existing @@ -60,11 +76,11 @@ export function createCorsOriginValidator(frontendUrl: string, cookieDomain: str } else if (url.origin === frontendOrigin) { callback(null, true); } else if ( - (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + isProprLoopbackHostname(url.hostname) && (url.protocol === 'http:' || url.protocol === 'https:') ) { - // Allow localhost for development, but only over http/https so an unusual - // scheme (e.g. file:, chrome-extension:) on localhost is not trusted. + // Allow loopback hosts for development, but only over http/https so an + // unusual scheme (e.g. file:, chrome-extension:) is not trusted. callback(null, true); } else { callback(new CorsOriginError()); diff --git a/packages/api/desktopApiBoundary.ts b/packages/api/desktopApiBoundary.ts new file mode 100644 index 000000000..71a0cf69f --- /dev/null +++ b/packages/api/desktopApiBoundary.ts @@ -0,0 +1,39 @@ +import type { Express, RequestHandler } from 'express'; +import { ensureAuthenticated } from './auth.js'; +import { resolveAuthorization } from './authorization.js'; +import { + createDiscoveryRequestRateLimiter, + createPairingPollRateLimiter, + createPairingStartRateLimiter, +} from './requestRateLimits.js'; + +export interface DesktopApiBoundaryRoutes { + discovery: RequestHandler; + startPairing: RequestHandler; + pollPairing: RequestHandler; + activatePairing: RequestHandler; + cancelPairing: RequestHandler; + openPairingApproval: RequestHandler; + revokeCurrentToken: RequestHandler; +} + +/** + * Register the complete public desktop bootstrap boundary and then close it + * with the generic API authentication/authorization guard. Operational routes + * must be registered only after this function returns. + */ +export function registerDesktopApiBoundary( + app: Express, + routes: DesktopApiBoundaryRoutes, +): void { + app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), routes.discovery); + app.post('/api/desktop/pairings', createPairingStartRateLimiter(), routes.startPairing); + app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), routes.pollPairing); + app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), routes.activatePairing); + app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), routes.cancelPairing); + app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), routes.openPairingApproval); + // Token possession authorizes only this exact self-revocation route. It must + // precede generic auth so inactive tokens receive a stable terminal contract. + app.delete('/api/desktop/tokens/current', routes.revokeCurrentToken); + app.use('/api', ensureAuthenticated, resolveAuthorization); +} diff --git a/packages/api/desktopAuthService.ts b/packages/api/desktopAuthService.ts new file mode 100644 index 000000000..d804157bb --- /dev/null +++ b/packages/api/desktopAuthService.ts @@ -0,0 +1,788 @@ +/* eslint-disable max-lines -- pairing and token state transitions are kept together for transactional review */ +import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'; +import type { Knex } from 'knex'; +import { db } from '@propr/core'; +import { + canonicalProprHttpUrlOrigin, + canonicalProprProxyUrl, + isProprConnectReservedHostAttempt, + MAX_PROPR_API_BASE_URL_LENGTH, + normalizeProprApiOrigin, + parseProprConnectEndpoint, +} from '@propr/shared'; +import type { GitHubUser } from './authTypes.js'; + +const DEFAULT_PAIRING_TTL_MS = 10 * 60_000; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_PROVISIONAL_TTL_MS = 2 * 60_000; +const RETAIN_FINISHED_PAIRINGS_MS = 24 * 60 * 60_000; +export const INSTANCE_TOKEN_PREFIX = 'propr_it_'; +export const DESKTOP_INSTANCE_SCOPE = 'desktop-instance'; + +type PairingStatus = 'pending' | 'approved' | 'consumed' | 'cancelled'; + +interface PairingRow { + id: string; + device_secret_hash: string; + client_name: string; + status: PairingStatus; + requested_instance_id: string; + requested_origin: string; + requested_scope: string; + credential_generation: string; + provisional_token_id: string | null; + activation_ticket_hash: string | null; + activation_receipt: string | null; + activation_expires_at: string | null; + activated_at: string | null; + cancelled_at: string | null; + approved_by_user_id: string | null; + approved_by_username: string | null; + approved_by_display_name: string | null; + approved_by_email: string | null; + approved_by_avatar_url: string | null; + created_at: string; + expires_at: string; + approved_at: string | null; + consumed_at: string | null; +} + +interface TokenRow { + id: string; + token_hash: string; + token_hint: string; + name: string; + owner_github_user_id: string; + owner_github_username: string; + owner_display_name: string; + owner_email: string | null; + owner_avatar_url: string | null; + created_at: string; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + revoked_by_user_id: string | null; + activation_state: 'provisional' | 'active'; + pairing_id: string; + bound_instance_id: string; + bound_origin: string; + bound_scope: string; + credential_generation: string; +} + +export interface DesktopPairingBinding { + instanceId: string; + origin: string; + scope: typeof DESKTOP_INSTANCE_SCOPE; + credentialGeneration: string; +} + +export interface DesktopPairingStart { + pairingId: string; + deviceSecret: string; + approvalUrl: string; + expiresAt: string; + interval: number; +} + +export interface DesktopPairingApproval { + pairingId: string; + clientName: string; + status: PairingStatus; + createdAt: string; + expiresAt: string; +} + +export type DesktopPairingPoll = + | { status: 'pending'; interval: number } + | ({ + status: 'provisional'; + token: string; + tokenType: 'Bearer'; + activationTicket: string; + activationExpiresAt: string; + } & DesktopPairingBinding); + +export interface DesktopPairingActivation extends DesktopPairingBinding { + deviceSecret: string; + activationTicket: string; +} + +export interface DesktopPairingActivationReceipt { + status: 'active'; + receipt: string; + activatedAt: string; + expiresAt: string | null; +} + +export interface DesktopTokenSummary { + id: string; + name: string; + tokenHint: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; +} + +export interface InstanceTokenIdentity { + tokenId: string; + user: GitHubUser; +} + +export type PresentedTokenRevocation = + | { revoked: true } + | { revoked: false; code: 'TOKEN_NOT_FOUND' | 'INSTANCE_TOKEN_REVOKED' | 'INSTANCE_TOKEN_EXPIRED' }; + +export class DesktopAuthError extends Error { + constructor( + public readonly code: string, + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'DesktopAuthError'; + } +} + +export interface DesktopAuthServiceOptions { + database?: Knex; + now?: () => Date; + pairingTtlMs?: number; + tokenTtlMs?: number | null; + provisionalTtlMs?: number; + approvalBaseUrl?: string; + publicApiUrl?: string; +} + +function digest(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function opaqueValue(bytes = 32): string { + return randomBytes(bytes).toString('base64url'); +} + +function derivePairingValue(secret: string, purpose: string, row: PairingRow): string { + return createHmac('sha256', secret).update(JSON.stringify({ + purpose, + pairingId: row.id, + instanceId: row.requested_instance_id, + origin: row.requested_origin, + scope: row.requested_scope, + credentialGeneration: row.credential_generation, + })).digest('base64url'); +} + +function validClientName(value: unknown): string { + if (typeof value !== 'string') { + throw new DesktopAuthError('INVALID_CLIENT_NAME', 400, 'clientName must be a string'); + } + if ([...value].some(character => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 32 || codePoint === 127; + })) { + throw new DesktopAuthError('INVALID_CLIENT_NAME', 400, 'clientName must contain 1 to 80 printable characters'); + } + const normalized = value.trim().replace(/\s+/g, ' '); + if (normalized.length < 1 || normalized.length > 80) { + throw new DesktopAuthError('INVALID_CLIENT_NAME', 400, 'clientName must contain 1 to 80 printable characters'); + } + return normalized; +} + +function validPairingId(value: string): void { + if (!/^dpr_[A-Za-z0-9_-]{22}$/.test(value)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } +} + +function requireDeviceSecret(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } + return value; +} + +function validBinding(value: unknown): DesktopPairingBinding { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new DesktopAuthError('INVALID_PAIRING_BINDING', 400, 'Desktop pairing binding is invalid'); + } + const input = value as Record; + const origin = typeof input.origin === 'string' ? normalizeProprApiOrigin(input.origin) : null; + if (typeof input.instanceId !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(input.instanceId) + || origin === null || origin !== input.origin + || input.scope !== DESKTOP_INSTANCE_SCOPE + || typeof input.credentialGeneration !== 'string' + || !/^[A-Za-z0-9_-]{22}$/.test(input.credentialGeneration)) { + throw new DesktopAuthError('INVALID_PAIRING_BINDING', 400, 'Desktop pairing binding is invalid'); + } + return { + instanceId: input.instanceId, + origin, + scope: DESKTOP_INSTANCE_SCOPE, + credentialGeneration: input.credentialGeneration, + }; +} + +function rowBinding(row: PairingRow): DesktopPairingBinding { + return { + instanceId: row.requested_instance_id, + origin: row.requested_origin, + scope: DESKTOP_INSTANCE_SCOPE, + credentialGeneration: row.credential_generation, + }; +} + +function sameBinding(row: PairingRow, binding: DesktopPairingBinding): boolean { + return row.requested_instance_id === binding.instanceId + && row.requested_origin === binding.origin + && row.requested_scope === binding.scope + && row.credential_generation === binding.credentialGeneration; +} + +function frontendApprovalBase(configured?: string): URL { + const raw = configured ?? process.env.FRONTEND_URL; + if (!raw) throw new Error('FRONTEND_URL is required for desktop pairing'); + const url = new URL(raw); + if (canonicalProprHttpUrlOrigin(raw) !== url.origin) { + throw new Error('Desktop pairing approval requires HTTPS except on loopback hosts'); + } + if (url.username || url.password) throw new Error('FRONTEND_URL must not contain credentials'); + return url; +} + +interface PublicApiBase { + url: URL; + managedSelector: string | null; +} + +function invalidPublicApiConfiguration(): DesktopAuthError { + return new DesktopAuthError( + 'PAIRING_CONFIGURATION_INVALID', + 503, + 'Desktop pairing is unavailable because the public API URL is invalid', + ); +} + +function rawPublicApiHostname(raw: string): string { + const authority = raw.slice(raw.indexOf('://') + 3).split(/[/?#]/, 1)[0]?.split('@').pop()?.toLowerCase() ?? ''; + return authority.replace(/:\d+$/, '').replace(/\.$/, ''); +} + +function claimsManagedPublicApiNamespace(raw: string, url: URL): boolean { + const normalizedHostname = url.hostname.toLowerCase().replace(/\.$/, ''); + const managedLabelInProprNamespace = normalizedHostname.endsWith('.propr.dev') + && normalizedHostname.split('.').slice(0, -2).some(label => label.startsWith('t-')); + const rawHostnameLabels = rawPublicApiHostname(raw).split('.'); + const rawManagedLabelInProprNamespace = rawHostnameLabels[0]?.startsWith('t-') === true + && rawHostnameLabels.at(-2) === 'propr' + && rawHostnameLabels.at(-1) === 'dev'; + return managedLabelInProprNamespace || rawManagedLabelInProprNamespace; +} + +function validatePublicApiOrigin(raw: string, url: URL): void { + if (normalizeProprApiOrigin(raw) !== url.origin) { + throw new Error('Desktop pairing browser entry requires HTTPS except on loopback hosts'); + } + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error('API_PUBLIC_URL must be an origin without credentials, a path, query, or fragment'); + } +} + +function publicApiBase(configured?: string): PublicApiBase | null { + const raw = configured ?? process.env.API_PUBLIC_URL; + if (!raw) return null; + if (raw.length > MAX_PROPR_API_BASE_URL_LENGTH) throw invalidPublicApiConfiguration(); + const canonicalConnectEndpoint = parseProprConnectEndpoint(raw); + if (isProprConnectReservedHostAttempt(raw) && !canonicalConnectEndpoint) { + throw invalidPublicApiConfiguration(); + } + const url = new URL(raw); + validatePublicApiOrigin(raw, url); + const canonicalManagedUrl = canonicalProprProxyUrl(raw); + if (claimsManagedPublicApiNamespace(raw, url) && !canonicalManagedUrl) { + throw new Error('API_PUBLIC_URL uses a noncanonical reserved ProPR tunnel host'); + } + return { + url, + managedSelector: canonicalManagedUrl ? canonicalManagedUrl.slice('https://'.length) : null, + }; +} + +function tokenSummary(row: TokenRow): DesktopTokenSummary { + return { + id: row.id, + name: row.name, + tokenHint: row.token_hint, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }; +} + +function configuredTokenTtlMs(): number | null { + const configured = process.env.PROPR_DESKTOP_TOKEN_TTL_DAYS?.trim(); + if (!configured) return null; + const days = Number(configured); + if (!Number.isSafeInteger(days) || days <= 0 || days > 3650) { + throw new Error('PROPR_DESKTOP_TOKEN_TTL_DAYS must be an integer from 1 to 3650'); + } + return days * 24 * 60 * 60_000; +} + +export class DesktopAuthService { + private readonly database: Knex; + private readonly now: () => Date; + private readonly pairingTtlMs: number; + private readonly tokenTtlMs: number | null; + private readonly provisionalTtlMs: number; + private readonly approvalBaseUrl?: string; + private readonly publicApiUrl?: string; + + constructor(options: DesktopAuthServiceOptions = {}) { + this.database = options.database ?? db; + this.now = options.now ?? (() => new Date()); + this.pairingTtlMs = options.pairingTtlMs ?? DEFAULT_PAIRING_TTL_MS; + this.tokenTtlMs = options.tokenTtlMs === undefined ? configuredTokenTtlMs() : options.tokenTtlMs; + this.provisionalTtlMs = options.provisionalTtlMs ?? DEFAULT_PROVISIONAL_TTL_MS; + if (!Number.isSafeInteger(this.provisionalTtlMs) || this.provisionalTtlMs < 1_000 + || this.provisionalTtlMs > DEFAULT_PROVISIONAL_TTL_MS) { + throw new Error('Desktop provisional TTL must be from 1000 to 120000 milliseconds'); + } + this.approvalBaseUrl = options.approvalBaseUrl; + this.publicApiUrl = options.publicApiUrl; + } + + async startPairing(clientNameInput: unknown, bindingInput: unknown): Promise { + const clientName = validClientName(clientNameInput); + const binding = validBinding(bindingInput); + const pairingId = `dpr_${opaqueValue(16)}`; + const deviceSecret = opaqueValue(); + const createdAt = this.now(); + const expiresAt = new Date(createdAt.getTime() + this.pairingTtlMs); + const apiApprovalBase = publicApiBase(this.publicApiUrl); + const approvalUrl = apiApprovalBase?.url ?? this.getFrontendApprovalUrl(pairingId); + if (apiApprovalBase) { + approvalUrl.pathname = `${approvalUrl.pathname.replace(/\/$/, '')}/api/desktop/pairings/${pairingId}/browser`; + approvalUrl.search = ''; + approvalUrl.hash = ''; + } + + await this.database('desktop_pairing_requests').insert({ + id: pairingId, + device_secret_hash: digest(deviceSecret), + client_name: clientName, + status: 'pending', + requested_instance_id: binding.instanceId, + requested_origin: binding.origin, + requested_scope: binding.scope, + credential_generation: binding.credentialGeneration, + created_at: createdAt.toISOString(), + expires_at: expiresAt.toISOString(), + }); + await this.audit('pairing_started', { pairingId, clientName }); + + return { + pairingId, + deviceSecret, + approvalUrl: approvalUrl.toString(), + expiresAt: expiresAt.toISOString(), + interval: DEFAULT_POLL_INTERVAL_SECONDS, + }; + } + + getFrontendApprovalUrl(pairingId: string): URL { + validPairingId(pairingId); + const approvalUrl = frontendApprovalBase(this.approvalBaseUrl); + approvalUrl.pathname = `${approvalUrl.pathname.replace(/\/$/, '')}/desktop/pairing`; + approvalUrl.search = ''; + approvalUrl.hash = ''; + approvalUrl.searchParams.set('pairing_id', pairingId); + const apiBase = publicApiBase(this.publicApiUrl); + if (approvalUrl.origin === 'https://app.propr.dev' && apiBase?.managedSelector) { + approvalUrl.searchParams.set('tunnel', apiBase.managedSelector); + } + return approvalUrl; + } + + async getPairingForApproval(pairingId: string): Promise { + const row = await this.activePairing(pairingId); + return { + pairingId: row.id, + clientName: row.client_name, + status: row.status, + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + + async approvePairing(pairingId: string, user: GitHubUser): Promise { + validPairingId(pairingId); + const approvedAt = this.now().toISOString(); + const updated = await this.database('desktop_pairing_requests') + .where({ id: pairingId, status: 'pending' }) + .andWhere('expires_at', '>', approvedAt) + .update({ + status: 'approved', + approved_by_user_id: user.id, + approved_by_username: user.username, + approved_by_display_name: user.displayName || user.username, + approved_by_email: user.email, + approved_by_avatar_url: user.avatarUrl, + approved_at: approvedAt, + }); + if (updated !== 1) { + const current = await this.database('desktop_pairing_requests').where({ id: pairingId }).first(); + if (current?.status === 'approved' && current.approved_by_user_id === user.id && current.expires_at > approvedAt) { + return this.getPairingForApproval(pairingId); + } + if (current?.status === 'consumed') { + throw new DesktopAuthError('PAIRING_ALREADY_CONSUMED', 409, 'Pairing request was already used'); + } + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found or has expired'); + } + const result = await this.getPairingForApproval(pairingId); + await this.audit('pairing_approved', { + pairingId, + clientName: result.clientName, + actor: user, + }); + return result; + } + + async pollPairing(pairingId: string, secretInput: unknown): Promise { + validPairingId(pairingId); + const deviceSecret = requireDeviceSecret(secretInput); + const now = this.now(); + const nowIso = now.toISOString(); + + return this.database.transaction(async transaction => { + const row = await transaction('desktop_pairing_requests') + .where({ id: pairingId, device_secret_hash: digest(deviceSecret) }) + .forUpdate() + .first(); + if (!row) throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + if (row.expires_at <= nowIso) throw new DesktopAuthError('PAIRING_EXPIRED', 410, 'Pairing request has expired'); + if (row.status === 'pending') return { status: 'pending', interval: DEFAULT_POLL_INTERVAL_SECONDS }; + if (row.status === 'consumed') { + if (row.cancelled_at) throw new DesktopAuthError('PAIRING_CANCELLED', 410, 'Pairing request was cancelled'); + throw new DesktopAuthError('PAIRING_ALREADY_CONSUMED', 409, 'Pairing request was already used'); + } + if (row.status === 'cancelled') { + throw new DesktopAuthError('PAIRING_CANCELLED', 410, 'Pairing request was cancelled'); + } + if (!row.approved_by_user_id || !row.approved_by_username) { + throw new DesktopAuthError('PAIRING_INVALID_STATE', 409, 'Pairing request cannot be completed'); + } + + const token = `${INSTANCE_TOKEN_PREFIX}${derivePairingValue(deviceSecret, 'credential', row)}`; + const activationTicket = derivePairingValue(deviceSecret, 'activation-ticket', row); + let activationExpiresAt = row.activation_expires_at; + let tokenId = row.provisional_token_id; + if (!tokenId) { + tokenId = randomUUID(); + activationExpiresAt = new Date(Math.min( + Date.parse(row.expires_at), + now.getTime() + this.provisionalTtlMs, + )).toISOString(); + await transaction('instance_api_tokens').insert({ + id: tokenId, + token_hash: digest(token), + token_hint: token.slice(-8), + name: row.client_name, + owner_github_user_id: row.approved_by_user_id, + owner_github_username: row.approved_by_username, + owner_display_name: row.approved_by_display_name || row.approved_by_username, + owner_email: row.approved_by_email, + owner_avatar_url: row.approved_by_avatar_url, + created_at: nowIso, + expires_at: activationExpiresAt, + activation_state: 'provisional', + pairing_id: row.id, + bound_instance_id: row.requested_instance_id, + bound_origin: row.requested_origin, + bound_scope: row.requested_scope, + credential_generation: row.credential_generation, + }); + await transaction('desktop_pairing_requests').where({ id: row.id, status: 'approved' }).update({ + provisional_token_id: tokenId, + activation_ticket_hash: digest(activationTicket), + activation_expires_at: activationExpiresAt, + }); + await this.audit('token_provisioned', { + pairingId, + tokenId, + clientName: row.client_name, + actor: { id: row.approved_by_user_id, username: row.approved_by_username }, + }, transaction); + } else { + const existing = await transaction('instance_api_tokens').where({ id: tokenId }).first(); + if (!existing || existing.token_hash !== digest(token) + || row.activation_ticket_hash !== digest(activationTicket) + || !activationExpiresAt || activationExpiresAt <= nowIso) { + throw new DesktopAuthError('PAIRING_EXPIRED', 410, 'Pairing activation has expired'); + } + } + return { + status: 'provisional', + token, + tokenType: 'Bearer', + activationTicket, + activationExpiresAt: activationExpiresAt!, + ...rowBinding(row), + }; + }); + } + + async cancelPairing(pairingId: string, input: unknown): Promise<{ status: 'cancelled'; cancelledAt: string }> { + validPairingId(pairingId); + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } + const request = input as Record; + const deviceSecret = requireDeviceSecret(request.deviceSecret); + const binding = validBinding(request); + const activationTicket = typeof request.activationTicket === 'string' + && /^[A-Za-z0-9_-]{43}$/.test(request.activationTicket) + ? request.activationTicket + : null; + if (!activationTicket) throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + const nowIso = this.now().toISOString(); + return this.database.transaction(async transaction => { + const row = await transaction('desktop_pairing_requests') + .where({ id: pairingId, device_secret_hash: digest(deviceSecret) }) + .forUpdate() + .first(); + if (!row || !sameBinding(row, binding) || row.activation_ticket_hash !== digest(activationTicket)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } + if (row.cancelled_at) return { status: 'cancelled', cancelledAt: row.cancelled_at }; + if (!row.provisional_token_id) { + throw new DesktopAuthError('PAIRING_INVALID_STATE', 409, 'Pairing credential was not provisioned'); + } + await transaction('instance_api_tokens') + .where({ id: row.provisional_token_id }) + .whereNull('revoked_at') + .update({ revoked_at: nowIso, revoked_by_user_id: row.approved_by_user_id }); + await transaction('desktop_pairing_requests').where({ id: row.id }).update({ + status: 'consumed', + consumed_at: nowIso, + cancelled_at: nowIso, + }); + await this.audit('pairing_cancelled', { + pairingId, + tokenId: row.provisional_token_id, + clientName: row.client_name, + actor: row.approved_by_user_id && row.approved_by_username + ? { id: row.approved_by_user_id, username: row.approved_by_username } + : undefined, + }, transaction); + return { status: 'cancelled', cancelledAt: nowIso }; + }); + } + + async activatePairing(pairingId: string, input: unknown): Promise { + validPairingId(pairingId); + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } + const request = input as Record; + const deviceSecret = requireDeviceSecret(request.deviceSecret); + const binding = validBinding(request); + const activationTicket = typeof request.activationTicket === 'string' + && /^[A-Za-z0-9_-]{43}$/.test(request.activationTicket) + ? request.activationTicket + : null; + if (!activationTicket) throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + const now = this.now(); + const nowIso = now.toISOString(); + return this.database.transaction(async transaction => { + const row = await transaction('desktop_pairing_requests') + .where({ id: pairingId, device_secret_hash: digest(deviceSecret) }) + .forUpdate() + .first(); + if (!row || !sameBinding(row, binding) || row.activation_ticket_hash !== digest(activationTicket)) { + throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found'); + } + const tokenId = row.provisional_token_id; + if (!tokenId) throw new DesktopAuthError('PAIRING_INVALID_STATE', 409, 'Pairing credential was not provisioned'); + if (row.status === 'consumed') { + if (row.cancelled_at) throw new DesktopAuthError('PAIRING_CANCELLED', 410, 'Pairing request was cancelled'); + if (!row.activation_receipt || !row.activated_at) { + throw new DesktopAuthError('PAIRING_ALREADY_CONSUMED', 409, 'Pairing request was already used'); + } + const token = await transaction('instance_api_tokens').where({ id: tokenId }).first(); + if (!token || token.activation_state !== 'active') { + throw new DesktopAuthError('PAIRING_ALREADY_CONSUMED', 409, 'Pairing request was already used'); + } + return { + status: 'active', receipt: row.activation_receipt, activatedAt: row.activated_at, expiresAt: token.expires_at, + }; + } + if (row.status === 'cancelled') throw new DesktopAuthError('PAIRING_CANCELLED', 410, 'Pairing request was cancelled'); + if (row.status !== 'approved' || row.expires_at <= nowIso + || !row.activation_expires_at || row.activation_expires_at <= nowIso) { + throw new DesktopAuthError('PAIRING_EXPIRED', 410, 'Pairing activation has expired'); + } + const finalExpiresAt = this.tokenTtlMs === null + ? null + : new Date(now.getTime() + this.tokenTtlMs).toISOString(); + const activated = await transaction('instance_api_tokens') + .where({ id: tokenId, activation_state: 'provisional' }) + .whereNull('revoked_at') + .andWhere('expires_at', '>', nowIso) + .update({ activation_state: 'active', expires_at: finalExpiresAt }); + if (activated !== 1) throw new DesktopAuthError('PAIRING_EXPIRED', 410, 'Pairing activation has expired'); + const receipt = opaqueValue(16); + const consumed = await transaction('desktop_pairing_requests') + .where({ id: row.id, status: 'approved' }) + .update({ status: 'consumed', consumed_at: nowIso, activated_at: nowIso, activation_receipt: receipt }); + if (consumed !== 1) throw new DesktopAuthError('PAIRING_ALREADY_CONSUMED', 409, 'Pairing request was already used'); + await this.audit('token_activated', { + pairingId, tokenId, clientName: row.client_name, + actor: { id: row.approved_by_user_id!, username: row.approved_by_username! }, + }, transaction); + return { status: 'active', receipt, activatedAt: nowIso, expiresAt: finalExpiresAt }; + }); + } + + async validateToken(token: string): Promise { + if (!token.startsWith(INSTANCE_TOKEN_PREFIX) || token.length !== INSTANCE_TOKEN_PREFIX.length + 43) return null; + const nowIso = this.now().toISOString(); + const row = await this.database('instance_api_tokens') + .where({ token_hash: digest(token) }) + .andWhere({ activation_state: 'active' }) + .whereNull('revoked_at') + .andWhere(builder => builder.whereNull('expires_at').orWhere('expires_at', '>', nowIso)) + .first(); + if (!row) return null; + + await this.database('instance_api_tokens') + .where({ id: row.id }) + .whereNull('revoked_at') + .update({ last_used_at: nowIso }); + return { + tokenId: row.id, + user: { + id: row.owner_github_user_id, + login: row.owner_github_username, + username: row.owner_github_username, + displayName: row.owner_display_name, + email: row.owner_email, + avatarUrl: row.owner_avatar_url, + }, + }; + } + + async listTokens(ownerUserId: string): Promise { + const rows = await this.database('instance_api_tokens') + .where({ owner_github_user_id: ownerUserId }) + .andWhere({ activation_state: 'active' }) + .orderBy('created_at', 'desc'); + return rows.map(tokenSummary); + } + + async revokeToken(tokenId: string, actor: GitHubUser): Promise { + if (!/^[0-9a-f-]{36}$/i.test(tokenId)) { + throw new DesktopAuthError('TOKEN_NOT_FOUND', 404, 'Token was not found'); + } + const revokedAt = this.now().toISOString(); + const updated = await this.database('instance_api_tokens') + .where({ id: tokenId, owner_github_user_id: actor.id }) + .whereNull('revoked_at') + .update({ revoked_at: revokedAt, revoked_by_user_id: actor.id }); + if (updated !== 1) throw new DesktopAuthError('TOKEN_NOT_FOUND', 404, 'Active token was not found'); + await this.audit('token_revoked', { tokenId, actor }); + } + + async revokePresentedToken(token: string): Promise { + if (!token.startsWith(INSTANCE_TOKEN_PREFIX) + || token.length !== INSTANCE_TOKEN_PREFIX.length + 43) { + return { revoked: false, code: 'TOKEN_NOT_FOUND' }; + } + return this.database.transaction(async transaction => { + const row = await transaction('instance_api_tokens') + .where({ token_hash: digest(token) }) + .first(); + if (!row) return { revoked: false, code: 'TOKEN_NOT_FOUND' }; + if (row.revoked_at) return { revoked: false, code: 'INSTANCE_TOKEN_REVOKED' }; + const now = this.now(); + if (row.expires_at && Date.parse(row.expires_at) <= now.getTime()) { + return { revoked: false, code: 'INSTANCE_TOKEN_EXPIRED' }; + } + const actor: GitHubUser = { + id: row.owner_github_user_id, + login: row.owner_github_username, + username: row.owner_github_username, + displayName: row.owner_display_name, + email: row.owner_email, + avatarUrl: row.owner_avatar_url, + }; + const updated = await transaction('instance_api_tokens') + .where({ id: row.id }) + .whereNull('revoked_at') + .update({ revoked_at: now.toISOString(), revoked_by_user_id: actor.id }); + if (updated !== 1) return { revoked: false, code: 'INSTANCE_TOKEN_REVOKED' }; + await this.audit('token_revoked', { tokenId: row.id, actor }, transaction); + return { revoked: true }; + }); + } + + async cleanupPairings(): Promise { + const cutoff = new Date(this.now().getTime() - RETAIN_FINISHED_PAIRINGS_MS).toISOString(); + const nowIso = this.now().toISOString(); + return this.database.transaction(async transaction => { + await transaction('instance_api_tokens') + .where({ activation_state: 'provisional' }) + .andWhere('expires_at', '<=', nowIso) + .delete(); + const deleted = await transaction('desktop_pairing_requests') + .where('expires_at', '<', cutoff) + .delete(); + return typeof deleted === 'number' ? deleted : 0; + }); + } + + private async activePairing(pairingId: string): Promise { + validPairingId(pairingId); + const nowIso = this.now().toISOString(); + const row = await this.database('desktop_pairing_requests') + .where({ id: pairingId }) + .andWhere('expires_at', '>', nowIso) + .first(); + if (!row) throw new DesktopAuthError('PAIRING_NOT_FOUND', 404, 'Pairing request was not found or has expired'); + return row; + } + + private async audit( + action: string, + details: { + actor?: Pick; + pairingId?: string; + tokenId?: string; + clientName?: string; + }, + database: Knex | Knex.Transaction = this.database, + ): Promise { + await database('desktop_auth_audit').insert({ + action, + actor_github_user_id: details.actor?.id ?? null, + actor_github_username: details.actor?.username ?? null, + pairing_id: details.pairingId ?? null, + token_id: details.tokenId ?? null, + client_name: details.clientName ?? null, + created_at: this.now().toISOString(), + }); + console.info('[desktop-auth]', { + action, + actorUserId: details.actor?.id, + pairingId: details.pairingId, + tokenId: details.tokenId, + clientName: details.clientName, + }); + } +} + +export const desktopAuthService = new DesktopAuthService(); diff --git a/packages/api/expressUser.d.ts b/packages/api/expressUser.d.ts index 2f0d91243..57e36d598 100644 --- a/packages/api/expressUser.d.ts +++ b/packages/api/expressUser.d.ts @@ -7,6 +7,8 @@ declare global { interface User extends GitHubUser {} interface Request { authorization?: InstanceAuthorization; + authenticationMethod?: 'session' | 'github_bearer' | 'instance_token' | 'demo'; + instanceTokenId?: string; } } } diff --git a/packages/api/package.json b/packages/api/package.json index f6232ed4a..4e125b4bd 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@propr/core": "^0.8.15", + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "@types/multer": "^2.0.0", "bullmq": "^5.81.3", diff --git a/packages/api/publicInstanceIdentity.ts b/packages/api/publicInstanceIdentity.ts new file mode 100644 index 000000000..1c67a01bb --- /dev/null +++ b/packages/api/publicInstanceIdentity.ts @@ -0,0 +1,14 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { getOrCreatePublicInstanceIdentity as getOrCreateSharedIdentity } from '@propr/local-setup'; + +/** API access to the same validated, durable creation algorithm as the host CLI. */ +export async function getOrCreatePublicInstanceIdentity( + dataDir = process.env.DATA_DIR ?? join(process.cwd(), 'data'), + generate: () => string = randomUUID, +): Promise { + return await getOrCreateSharedIdentity(dataDir, { + generate, + role: 'root-container', + }); +} diff --git a/packages/api/requestRateLimits.ts b/packages/api/requestRateLimits.ts index 48f1cfe25..fdac4167c 100644 --- a/packages/api/requestRateLimits.ts +++ b/packages/api/requestRateLimits.ts @@ -15,12 +15,18 @@ interface RequestRateLimitPolicy { export interface RequestRateLimitPolicies { api: RequestRateLimitPolicy; auth: RequestRateLimitPolicy; + discovery: RequestRateLimitPolicy; + pairingStart: RequestRateLimitPolicy; + pairingPoll: RequestRateLimitPolicy; webhook: RequestRateLimitPolicy; } const DEFAULT_POLICIES: RequestRateLimitPolicies = { api: { identifier: 'api', limit: 600, windowMs: 60_000 }, auth: { identifier: 'auth', limit: 30, windowMs: 15 * 60_000 }, + discovery: { identifier: 'desktop-discovery', limit: 60, windowMs: 60_000 }, + pairingStart: { identifier: 'desktop-pairing-start', limit: 10, windowMs: 15 * 60_000 }, + pairingPoll: { identifier: 'desktop-pairing-poll', limit: 180, windowMs: 15 * 60_000 }, webhook: { identifier: 'webhook', limit: 300, windowMs: 60_000 }, }; @@ -101,6 +107,21 @@ export function resolveRequestRateLimitPolicies( limit: positiveInteger(environment, 'PROPR_AUTH_RATE_LIMIT_MAX', DEFAULT_POLICIES.auth.limit), windowMs: windowMilliseconds(environment, 'PROPR_AUTH_RATE_LIMIT_WINDOW_MS', DEFAULT_POLICIES.auth.windowMs), }, + discovery: { + identifier: 'desktop-discovery', + limit: positiveInteger(environment, 'PROPR_DISCOVERY_RATE_LIMIT_MAX', DEFAULT_POLICIES.discovery.limit), + windowMs: windowMilliseconds(environment, 'PROPR_DISCOVERY_RATE_LIMIT_WINDOW_MS', DEFAULT_POLICIES.discovery.windowMs), + }, + pairingStart: { + identifier: 'desktop-pairing-start', + limit: positiveInteger(environment, 'PROPR_PAIRING_START_RATE_LIMIT_MAX', DEFAULT_POLICIES.pairingStart.limit), + windowMs: windowMilliseconds(environment, 'PROPR_PAIRING_START_RATE_LIMIT_WINDOW_MS', DEFAULT_POLICIES.pairingStart.windowMs), + }, + pairingPoll: { + identifier: 'desktop-pairing-poll', + limit: positiveInteger(environment, 'PROPR_PAIRING_POLL_RATE_LIMIT_MAX', DEFAULT_POLICIES.pairingPoll.limit), + windowMs: windowMilliseconds(environment, 'PROPR_PAIRING_POLL_RATE_LIMIT_WINDOW_MS', DEFAULT_POLICIES.pairingPoll.windowMs), + }, webhook: { identifier: 'webhook', limit: positiveInteger(environment, 'PROPR_WEBHOOK_RATE_LIMIT_MAX', DEFAULT_POLICIES.webhook.limit), @@ -157,6 +178,24 @@ export function createAuthRequestRateLimiter( return createRequestRateLimiter(resolveRequestRateLimitPolicies(environment).auth); } +export function createDiscoveryRequestRateLimiter( + environment: RateLimitEnvironment = process.env, +): RateLimitRequestHandler { + return createRequestRateLimiter(resolveRequestRateLimitPolicies(environment).discovery); +} + +export function createPairingStartRateLimiter( + environment: RateLimitEnvironment = process.env, +): RateLimitRequestHandler { + return createRequestRateLimiter(resolveRequestRateLimitPolicies(environment).pairingStart); +} + +export function createPairingPollRateLimiter( + environment: RateLimitEnvironment = process.env, +): RateLimitRequestHandler { + return createRequestRateLimiter(resolveRequestRateLimitPolicies(environment).pairingPoll); +} + export function createWebhookRequestRateLimiter( environment: RateLimitEnvironment = process.env, ): RateLimitRequestHandler { diff --git a/packages/api/routes/desktopAuthRoutes.ts b/packages/api/routes/desktopAuthRoutes.ts new file mode 100644 index 000000000..0e07b7c57 --- /dev/null +++ b/packages/api/routes/desktopAuthRoutes.ts @@ -0,0 +1,215 @@ +import type { Request, RequestHandler, Response } from 'express'; +import { + DesktopAuthError, + DesktopAuthService, + desktopAuthService, +} from '../desktopAuthService.js'; +import { isUserWhitelisted } from '../userWhitelist.js'; +import { + DESKTOP_REVOCATION_BINDING_HEADER, + DESKTOP_TOKEN_REVOCATION_ENDPOINT, + DESKTOP_TOKEN_REVOCATION_SCHEMA, + DESKTOP_TOKEN_REVOCATION_VERSION, + canonicalProprHttpUrlOrigin, + normalizeProprApiOrigin, +} from '@propr/shared'; + +interface DesktopAuthRoutesOptions { + service?: DesktopAuthService; + frontendUrl?: string; +} + +function pathParameter(value: string | string[]): string { + return Array.isArray(value) ? value[0] ?? '' : value; +} + +function sendDesktopAuthError(error: unknown, res: Response): void { + if (error instanceof DesktopAuthError) { + res.status(error.status).json({ code: error.code, error: error.message }); + return; + } + console.error('[desktop-auth] Request failed:', error); + res.status(500).json({ code: 'DESKTOP_AUTH_FAILED', error: 'Desktop authentication request failed' }); +} + +export function isTrustedPairingApprovalOrigin(origin: string | undefined, frontendUrl: string | undefined): boolean { + if (!origin || !frontendUrl) return false; + const expected = canonicalProprHttpUrlOrigin(frontendUrl); + const supplied = normalizeProprApiOrigin(origin); + return expected !== null && supplied === expected; +} + +/** Pairing approval is intentionally session-only. */ +export function requireBrowserPairingSession(): RequestHandler { + return (req, res, next) => { + if (req.authenticationMethod !== 'session' || !req.isAuthenticated?.() || !req.user) { + res.status(403).json({ + code: 'BROWSER_SESSION_REQUIRED', + error: 'Pairing approval requires an authenticated browser session', + }); + return; + } + next(); + }; +} + +/** Mutating approval additionally requires the exact configured UI origin. */ +export function requirePairingApprovalOrigin(frontendUrl = process.env.FRONTEND_URL): RequestHandler { + return (req, res, next) => { + if (!isTrustedPairingApprovalOrigin(req.header('origin'), frontendUrl)) { + res.status(403).json({ code: 'UNTRUSTED_APPROVAL_ORIGIN', error: 'Pairing approval origin is not trusted' }); + return; + } + next(); + }; +} + +export function createDesktopAuthRoutes(options: DesktopAuthRoutesOptions = {}) { + const service = options.service ?? desktopAuthService; + const browserSessionGuard = requireBrowserPairingSession(); + const approvalOriginGuard = requirePairingApprovalOrigin(options.frontendUrl); + + async function startPairing(req: Request, res: Response): Promise { + try { + const body = req.body as Record | undefined; + const result = await service.startPairing(body?.clientName, body); + res.status(201).json(result); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function activatePairing(req: Request, res: Response): Promise { + try { + res.json(await service.activatePairing(pathParameter(req.params.pairingId), req.body)); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function cancelPairing(req: Request, res: Response): Promise { + try { + res.json(await service.cancelPairing(pathParameter(req.params.pairingId), req.body)); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function pollPairing(req: Request, res: Response): Promise { + try { + const result = await service.pollPairing( + pathParameter(req.params.pairingId), + (req.body as { deviceSecret?: unknown } | undefined)?.deviceSecret, + ); + res.status(result.status === 'pending' ? 202 : 200).json(result); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function getPairingApproval(req: Request, res: Response): Promise { + try { + res.json(await service.getPairingForApproval(pathParameter(req.params.pairingId))); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function openPairingApproval(req: Request, res: Response): Promise { + const pairingId = pathParameter(req.params.pairingId); + try { + await service.getPairingForApproval(pairingId); + const frontendUrl = service.getFrontendApprovalUrl(pairingId).toString(); + if (req.isAuthenticated?.() && req.user && isUserWhitelisted(req.user.username)) { + res.redirect(frontendUrl); + return; + } + res.redirect(`/api/auth/github?redirect_to=${encodeURIComponent(frontendUrl)}`); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function approvePairing(req: Request, res: Response): Promise { + if (!req.user) { + res.status(401).json({ code: 'AUTHENTICATION_REQUIRED', error: 'Authentication required' }); + return; + } + try { + res.json(await service.approvePairing(pathParameter(req.params.pairingId), req.user)); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function listTokens(req: Request, res: Response): Promise { + if (!req.user) { + res.status(401).json({ code: 'AUTHENTICATION_REQUIRED', error: 'Authentication required' }); + return; + } + try { + res.json({ tokens: await service.listTokens(req.user.id) }); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function revokeToken(req: Request, res: Response): Promise { + if (!req.user) { + res.status(401).json({ code: 'AUTHENTICATION_REQUIRED', error: 'Authentication required' }); + return; + } + try { + await service.revokeToken(pathParameter(req.params.tokenId), req.user); + res.status(204).end(); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + async function revokeCurrentToken(req: Request, res: Response): Promise { + const authorization = req.header('authorization'); + const credentialGeneration = req.header(DESKTOP_REVOCATION_BINDING_HEADER); + if (!authorization || !/^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(authorization) + || !credentialGeneration + || !/^[A-Za-z0-9_-]{22}$/.test(credentialGeneration)) { + res.status(403).json({ + code: 'INSTANCE_TOKEN_REQUIRED', + error: 'The current desktop token is required', + }); + return; + } + try { + const result = await service.revokePresentedToken(authorization.slice(7).trim()); + if (result.revoked) { + res.status(204).end(); + return; + } + res.status(result.code === 'TOKEN_NOT_FOUND' ? 404 : 401).json({ + schema: DESKTOP_TOKEN_REVOCATION_SCHEMA, + version: DESKTOP_TOKEN_REVOCATION_VERSION, + endpoint: DESKTOP_TOKEN_REVOCATION_ENDPOINT, + terminal: true, + code: result.code, + credentialGeneration, + }); + } catch (error) { + sendDesktopAuthError(error, res); + } + } + + return { + browserSessionGuard, + approvalOriginGuard, + startPairing, + pollPairing, + activatePairing, + cancelPairing, + getPairingApproval, + openPairingApproval, + approvePairing, + listTokens, + revokeCurrentToken, + revokeToken, + }; +} diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index 3d0ddfbcf..2c6bf3c99 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -29,4 +29,5 @@ export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js' export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createNotificationRoutes } from './notificationRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { createDesktopAuthRoutes } from './desktopAuthRoutes.js'; export { createVisualPreviewAuthRoutes } from './visualPreviewAuthRoutes.js'; diff --git a/packages/api/routes/statusRoutes.ts b/packages/api/routes/statusRoutes.ts index 5cb5532ae..2ee6c9820 100644 --- a/packages/api/routes/statusRoutes.ts +++ b/packages/api/routes/statusRoutes.ts @@ -3,6 +3,8 @@ import { Request, Response } from 'express'; import { RedisClientType } from 'redis'; import { isDemoMode } from '../demoMode.js'; import { + PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + canonicalProprProxyUrl, getProprCompatibilityMetadata, AGENT_DEFAULTS, resolveGithubAuthMode, @@ -22,6 +24,7 @@ import type { SyntheticAgentConfig } from '@propr/shared'; import path from 'node:path'; import os from 'node:os'; import { applyRoutingStatus, parseConnectAccountStatus, type RoutingState } from './connectAccountStatus.js'; +import { getOrCreatePublicInstanceIdentity } from '../publicInstanceIdentity.js'; interface StatusRoutesDeps { redisClient: RedisClientType; @@ -37,6 +40,7 @@ interface StatusRoutesDeps { snapshot: Record & { timestamp: string }, additionalAdministratorIds: readonly string[], ) => Promise; + getPublicInstanceIdentity?: () => string | Promise; } interface IndexingStatusQueue { @@ -68,7 +72,8 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { agentHealthTimeoutMs = 1500, now = Date.now, loadSummarizationRuntimeState: loadSummarizationRuntimeStateDep = loadSummarizationRuntimeState, - projectSystemSnapshot + projectSystemSnapshot, + getPublicInstanceIdentity: loadPublicInstanceIdentity = getOrCreatePublicInstanceIdentity, } = deps; // Unit/integration callers that replace the direct config loader predate // synthetic pools. Treat that fixture as an empty synthetic document unless @@ -78,12 +83,37 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { let agentStatusCache: { expiresAt: number; statuses: AgentStatus[] } | undefined; function getCompatibility(_req: Request, res: Response): void { - res.json(getProprCompatibilityMetadata()); + res.json(getProprCompatibilityMetadata(!isDemoMode())); + } + + async function getDesktopDiscovery(_req: Request, res: Response): Promise { + // This endpoint is intentionally unauthenticated. Keep it cache-safe and + // bounded, and never include environment/account/credential state. + res.set({ + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', + 'X-Content-Type-Options': 'nosniff', + }); + try { + res.json({ + schemaVersion: PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + product: 'ProPR', + canonicalEndpoint: canonicalProprProxyUrl(process.env.API_PUBLIC_URL) ?? null, + publicInstanceIdentity: await loadPublicInstanceIdentity(), + ...getProprCompatibilityMetadata(!isDemoMode()), + }); + } catch { + // Do not expose a persistence path or parse error through public discovery. + res.status(503).json({ + schemaVersion: PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + code: 'IDENTITY_UNAVAILABLE', + }); + } } async function getStatus(req: Request, res: Response): Promise { try { - const compatibility = getProprCompatibilityMetadata(); + const compatibility = getProprCompatibilityMetadata(!isDemoMode()); // In demo mode, return all-green status if (isDemoMode()) { res.json({ @@ -204,7 +234,7 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { } } - return { getCompatibility, getStatus }; + return { getCompatibility, getDesktopDiscovery, getStatus }; async function getCachedAgentStatuses(): Promise { const currentTime = now(); diff --git a/packages/api/server.ts b/packages/api/server.ts index 5840f9df1..33561d9e5 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -6,7 +6,7 @@ import { createClient, RedisClientType } from 'redis'; import { Queue } from 'bullmq'; import 'dotenv/config'; import { Redis, RedisOptions } from 'ioredis'; -import { authenticateSocketRequest, setupAuth, ensureAuthenticated } from './auth.js'; +import { authenticateSocketRequest, setupAuth } from './auth.js'; import { configureDemoMode, createDemoRedisClient, demoModeReadOnlyMiddleware } from './demoMode.js'; import { resolveGithubAuthMode, resolveGithubEventIntakeMode, validateIntakeModePrerequisites } from '@propr/shared'; import { initSocketService, closeSocketService } from './services/socketService.js'; @@ -33,6 +33,7 @@ import { createAdminRoutes, createVisualPreviewAuthRoutes, createInstanceCatalogRoutes, + createDesktopAuthRoutes, attachmentUpload } from './routes/index.js'; import { agentLoginSessionManager } from './services/agentLoginSessionManager.js'; @@ -61,9 +62,16 @@ import { stopTaskExecution } from './routes/dockerRoutes.js'; import { initializePushSubscriptionMaintenance } from './services/pushSubscriptionMaintenance.js'; import { NotificationProjectionService } from './services/notificationProjectionService.js'; import { WebPushDispatcher } from './services/webPushDispatcher.js'; -import { assertInstanceAdministratorConfigured, resolveAuthorization } from './authorization.js'; +import { assertInstanceAdministratorConfigured } from './authorization.js'; import { resolveApiListenHost } from './listenAddress.js'; -import { configureApiProxyTrust, createApiRequestRateLimiter, createWebhookRequestRateLimiter } from './requestRateLimits.js'; +import { + configureApiProxyTrust, + createApiRequestRateLimiter, + createDiscoveryRequestRateLimiter, + createWebhookRequestRateLimiter, +} from './requestRateLimits.js'; +import { desktopAuthService } from './desktopAuthService.js'; +import { prohibitApiResponseCaching } from './apiCacheControl.js'; import { startConfigReloadSubscription, type ConfigReloadSubscription } from './services/configReloadSubscription.js'; import { assertNoDuplicateRoutes, @@ -73,6 +81,7 @@ import { type RouteEntry } from './routeRegistry.js'; import { createTaskDeleteRouteEntries } from './taskDeleteRouteRegistry.js'; +import { registerDesktopApiBoundary } from './desktopApiBoundary.js'; import { startVisualPreviewOAuthRefreshScheduler, type VisualPreviewOAuthRefreshScheduler, @@ -146,6 +155,11 @@ const HOST = resolveApiListenHost(); configureApiProxyTrust(app); +// This is the earliest `/api` response boundary. Keep it before CORS and every +// global or route limiter so success, failure, and saturation responses cannot +// be cached by a browser or intermediary. +app.use('/api', prohibitApiResponseCaching); + if (!process.env.FRONTEND_URL) { console.error('FRONTEND_URL environment variable is required'); process.exit(1); @@ -172,14 +186,6 @@ app.use(corsRejectionHandler); app.use('/api', createApiRequestRateLimiter()); setupWebhookRoute(); -// Prevent caching of API responses to avoid stale CORS issues -app.use('/api', (_req, res, next) => { - res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - res.set('Pragma', 'no-cache'); - res.set('Expires', '0'); - next(); -}); - app.use(express.json({ limit: '1mb' })); // Register demo read-only protection before routes so future mutating /api routes, @@ -195,6 +201,7 @@ let configReloadSubscription: ConfigReloadSubscription | undefined; let notificationProjection: NotificationProjectionService | undefined; let webPushDispatcher: WebPushDispatcher | undefined; let webPushDispatcherConfigured = false; +let desktopPairingCleanupTimer: NodeJS.Timeout | undefined; let visualPreviewOAuthRefreshScheduler: VisualPreviewOAuthRefreshScheduler | undefined; function createDemoTaskQueue(): Queue { @@ -248,15 +255,25 @@ function setupRoutes(): void { ) => notificationProjection!.projectSystemSnapshot(snapshot, additionalAdministratorIds), }), }); - // INTENTIONALLY UNAUTHENTICATED: /api/compatibility is registered BEFORE the - // `ensureAuthenticated` guard below so the hosted UI can run its pre-auth - // version-gate before the user logs in. This is the one deliberate exception to - // "everything under /api/* requires auth" — do not move it after the guard, and - // keep its handler returning only non-sensitive build metadata (version + - // compatibility dates). All other /api routes registered after this line are - // authenticated. - app.get('/api/compatibility', statusRoutes.getCompatibility); - app.use('/api', ensureAuthenticated, resolveAuthorization); + const desktopAuthRoutes = createDesktopAuthRoutes(); + // INTENTIONALLY UNAUTHENTICATED: compatibility/discovery and the bounded + // pairing bootstrap, poll, and browser entry are registered before the guard. + // They return only compatibility/capability metadata or pairing state gated by + // a high-entropy secret; all operational routes below remain authenticated. + app.get('/api/compatibility', createDiscoveryRequestRateLimiter(), statusRoutes.getCompatibility); + registerDesktopApiBoundary(app, { + discovery: statusRoutes.getDesktopDiscovery, + startPairing: desktopAuthRoutes.startPairing, + pollPairing: desktopAuthRoutes.pollPairing, + activatePairing: desktopAuthRoutes.activatePairing, + cancelPairing: desktopAuthRoutes.cancelPairing, + openPairingApproval: desktopAuthRoutes.openPairingApproval, + revokeCurrentToken: desktopAuthRoutes.revokeCurrentToken, + }); + app.get('/api/desktop/pairings/:pairingId/approval', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.getPairingApproval); + app.post('/api/desktop/pairings/:pairingId/approve', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.approvalOriginGuard, desktopAuthRoutes.approvePairing); + app.get('/api/desktop/tokens', desktopAuthRoutes.listTokens); + app.delete('/api/desktop/tokens/:tokenId', desktopAuthRoutes.revokeToken); const taskRoutes = createTaskRoutes({ db, taskQueue }); const taskHistoryRoutes = createTaskHistoryRoutes({ redisClient, taskQueue, db }); const liveDetailsRoutes = createLiveDetailsRoutes({ redisClient, db }); @@ -446,6 +463,15 @@ async function start(): Promise { console.log('Demo mode: skipped startup config initialization; API config reads use the curated database directly'); } setupRoutes(); + if (!demoMode) { + await desktopAuthService.cleanupPairings(); + desktopPairingCleanupTimer = setInterval(() => { + void desktopAuthService.cleanupPairings().catch(error => { + console.warn('[desktop-auth] Pairing cleanup failed:', error); + }); + }, 60 * 60_000); + desktopPairingCleanupTimer.unref(); + } if (!demoMode) { const socketService = initSocketService(httpServer, validateCorsOrigin, { engineMiddleware: socketAuthMiddleware.engineMiddleware, @@ -494,6 +520,7 @@ async function start(): Promise { { name: 'agent login sessions', close: () => agentLoginSessionManager.close() }, { name: 'redis client', close: () => redisClient.quit() } ]; + if (desktopPairingCleanupTimer) clearInterval(desktopPairingCleanupTimer); if (!demoMode) { shutdownTasks.push( { name: 'Web Push dispatcher', close: () => webPushDispatcher?.close() ?? Promise.resolve() }, diff --git a/packages/api/services/socketAuthentication.ts b/packages/api/services/socketAuthentication.ts index b0c02141f..b11f60416 100644 --- a/packages/api/services/socketAuthentication.ts +++ b/packages/api/services/socketAuthentication.ts @@ -102,6 +102,8 @@ export function configureSocketAuthentication( io: SocketIOServer, options: SocketAuthenticationOptions, ): void { + const synthesizedAuthorizationRequests = new WeakSet(); + for (const middleware of options.engineMiddleware) { io.engine.use(( request: IncomingMessage, @@ -118,6 +120,15 @@ export function configureSocketAuthentication( io.use(async (socket, next) => { const request = socket.request as unknown as Request; + if (synthesizedAuthorizationRequests.delete(request)) { + delete request.headers.authorization; + } + const handshakeToken = (socket.handshake.auth as { token?: unknown } | undefined)?.token; + if (!request.headers.authorization && typeof handshakeToken === 'string' + && handshakeToken.trim() && !/[\r\n]/.test(handshakeToken)) { + request.headers.authorization = `Bearer ${handshakeToken.trim()}`; + synthesizedAuthorizationRequests.add(request); + } const usesPassportSession = Boolean(request.isAuthenticated?.() && request.user); try { const initialPrincipal = await options.authenticate(request); @@ -154,6 +165,7 @@ export function configureSocketAuthentication( `[SocketAuthentication] Disconnecting socket ${socket.id} after revalidation failed (${code})`, ); delete data.principal; + socket.emit('authentication:error', { code }); socket.disconnect(true); return false; } diff --git a/packages/api/test/connectAuth.test.ts b/packages/api/test/connectAuth.test.ts index a7c7fd44a..c9e6290f3 100644 --- a/packages/api/test/connectAuth.test.ts +++ b/packages/api/test/connectAuth.test.ts @@ -32,11 +32,27 @@ test('local relay mode uses Connect without a per-instance OAuth App', () => { }), 'connect'); }); -test('off-tunnel relay inference rejects callbacks outside the exact loopback allowlist', () => { +test('off-tunnel relay inference uses the shared canonical loopback rule', () => { + for (const callbackUrl of [ + 'http://api.dev.localhost:4000/api/auth/github/callback', + 'http://127.0.0.2:4000/api/auth/github/callback', + 'http://127.42.7.9:4000/api/auth/github/callback', + 'http://[::1]:4000/api/auth/github/callback', + ]) { + assert.equal(resolveBrowserAuthMode({ + PROPR_UI_TUNNEL_ENABLED: 'false', + PROPR_GH_RELAY_URL: 'https://webhook.propr.dev/v1', + PROPR_GH_RELAY_TOKEN: 'prt_secret', + GH_OAUTH_CALLBACK_URL: callbackUrl, + }), 'connect', callbackUrl); + } + for (const callbackUrl of [ 'https://api.example.com/api/auth/github/callback', 'https://localhost:4000/api/auth/github/callback', - 'http://127.0.0.2:4000/api/auth/github/callback', + 'http://127.1:4000/api/auth/github/callback', + 'http://0177.0.0.1:4000/api/auth/github/callback', + 'http://localhost.:4000/api/auth/github/callback', 'http://localhost:4000/not-the-auth-callback', ]) { assert.equal(resolveBrowserAuthMode({ @@ -93,6 +109,19 @@ test('Connect authorization URL carries the exact callback and CSRF state', () = assert.equal(url.searchParams.get('installation_id'), '123'); }); +test('Connect authorization URL rejects configured query strings and fragments', () => { + for (const connectOrigin of [ + 'https://connect.propr.dev?tenant=attacker', + 'https://connect.propr.dev#attacker', + ]) { + assert.throws(() => buildConnectAuthorizationUrl({ + connectOrigin, + callbackUrl: 'https://t-abc.propr.dev/api/auth/github/callback', + state: 'random-state', + }), /PROPR_CONNECT_URL must be a bare HTTPS origin/); + } +}); + test('redeems a Connect code server-to-server without exposing the relay token in the body', async () => { let relayRequest: Request | undefined; let githubRequest: Request | undefined; diff --git a/packages/api/test/corsValidation.test.ts b/packages/api/test/corsValidation.test.ts index f0b24c9e4..552e9a53b 100644 --- a/packages/api/test/corsValidation.test.ts +++ b/packages/api/test/corsValidation.test.ts @@ -1,9 +1,12 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { test } from 'node:test'; +import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; import cors from 'cors'; import express from 'express'; +import { Server as SocketIOServer } from 'socket.io'; import { corsRejectionHandler, createCorsOriginValidator } from '../corsValidation.js'; // Helper that runs the validator synchronously and reports whether the origin @@ -39,21 +42,39 @@ test('CORS allows requests with no origin', () => { assert.equal(isAllowed(validate, undefined), true); }); -test('CORS allows localhost for development', () => { +test('CORS allows only the exact packaged desktop renderer custom origin', () => { + const validate = createCorsOriginValidator('https://app.propr.dev', undefined); + + assert.equal(isAllowed(validate, DESKTOP_RENDERER_ORIGIN), true); + assert.equal(isAllowed(validate, `${DESKTOP_RENDERER_ORIGIN}.evil.example`), false); + assert.equal(isAllowed(validate, 'propr-app://other-renderer'), false); + assert.equal(isAllowed(validate, 'null'), false); +}); + +test('CORS allows HTTP(S) loopback origins for development', () => { const validate = createCorsOriginValidator('https://app.propr.dev', undefined); assert.equal(isAllowed(validate, 'http://localhost:5173'), true); + assert.equal(isAllowed(validate, 'http://api.dev.localhost:5173'), true); assert.equal(isAllowed(validate, 'http://127.0.0.1:5173'), true); + assert.equal(isAllowed(validate, 'http://127.42.7.9:5173'), true); + assert.equal(isAllowed(validate, 'http://[::1]:5173'), true); assert.equal(isAllowed(validate, 'https://localhost:5173'), true); + assert.equal(isAllowed(validate, 'https://[::1]:5173'), true); }); -test('CORS rejects non-http(s) localhost schemes', () => { - // Only http/https localhost origins are trusted; an unusual scheme that still - // parses with a localhost hostname must not be allowed. +test('CORS rejects unsafe schemes and non-loopback hosts', () => { + // Only http/https loopback origins are trusted; an unusual scheme that still + // parses with a loopback hostname must not be allowed. const validate = createCorsOriginValidator('https://app.propr.dev', undefined); assert.equal(isAllowed(validate, 'chrome-extension://localhost'), false); assert.equal(isAllowed(validate, 'file://localhost'), false); + assert.equal(isAllowed(validate, 'file://[::1]/tmp/propr'), false); + assert.equal(isAllowed(validate, 'http://[2001:db8::1]:5173'), false); + assert.equal(isAllowed(validate, 'http://127.1:5173'), false); + assert.equal(isAllowed(validate, 'http://0177.0.0.1:5173'), false); + assert.equal(isAllowed(validate, 'http://localhost.:5173'), false); }); test('CORS allows COOKIE_DOMAIN subdomains for preview environments', () => { @@ -132,6 +153,7 @@ for (const runtimeMode of ['development', 'production'] as const) { 'https://app.propr.dev', 'https://pr-17.preview.example.com', 'http://localhost:5173', + 'http://[::1]:5173', ]) { const response = await fetch(`${baseUrl}/api/protected`, { headers: { Origin: origin } }); assert.equal(response.status, 401, `expected ${origin} to reach authentication`); @@ -142,19 +164,52 @@ for (const runtimeMode of ['development', 'production'] as const) { assert.equal(noOrigin.status, 401); const compatibility = await fetch(`${baseUrl}/api/compatibility`, { - headers: { Origin: 'https://app.propr.dev' }, + headers: { Origin: DESKTOP_RENDERER_ORIGIN }, }); assert.equal(compatibility.status, 200); + assert.equal(compatibility.headers.get('access-control-allow-origin'), DESKTOP_RENDERER_ORIGIN); const allowedPreflight = await fetch(`${baseUrl}/api/protected`, { method: 'OPTIONS', headers: { - Origin: 'https://app.propr.dev', + Origin: DESKTOP_RENDERER_ORIGIN, 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'X-ProPR-Desktop-Transport-Scope, Content-Type', }, }); assert.equal(allowedPreflight.status, 204); - assert.equal(allowedPreflight.headers.get('access-control-allow-origin'), 'https://app.propr.dev'); + // This is the browser's real preflight shape: the requested desktop + // marker is named here, but the marker value itself is not sent on OPTIONS. + assert.equal(allowedPreflight.headers.get('access-control-allow-origin'), DESKTOP_RENDERER_ORIGIN); + assert.equal( + allowedPreflight.headers.get('access-control-allow-headers'), + 'X-ProPR-Desktop-Transport-Scope, Content-Type', + ); }); }); } + +test('Socket.IO applies the shared CORS validator to the packaged desktop renderer', async () => { + const server = createServer(); + const io = new SocketIOServer(server, { + cors: { + origin: createCorsOriginValidator('https://app.propr.dev', undefined), + credentials: true, + }, + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + + try { + const response = await fetch(`http://127.0.0.1:${port}/socket.io/?EIO=4&transport=polling`, { + headers: { Origin: DESKTOP_RENDERER_ORIGIN }, + }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), DESKTOP_RENDERER_ORIGIN); + assert.equal(response.headers.get('access-control-allow-credentials'), 'true'); + } finally { + await new Promise(resolve => io.close(() => resolve())); + } +}); diff --git a/packages/api/test/demoMode.test.ts b/packages/api/test/demoMode.test.ts index 11a265efd..a05f25523 100644 --- a/packages/api/test/demoMode.test.ts +++ b/packages/api/test/demoMode.test.ts @@ -16,6 +16,7 @@ import { createQueueRoutes } from '../routes/queueRoutes.js'; import { createStatusRoutes } from '../routes/statusRoutes.js'; import { normalizeRepoConfig } from '../routes/configRepoValidation.js'; import type { FlatRequest } from '../requestTypes.js'; +import { createRequestRateLimiter } from '../requestRateLimits.js'; const originalDemoMode = process.env.PROPR_DEMO_MODE; const originalFrontendUrl = process.env.FRONTEND_URL; @@ -170,7 +171,7 @@ test('demo Redis facade covers read-only route Redis usage', async () => { ); }); -test('demo Express GET routes work with the in-memory Redis facade', async () => { +test('demo Express routes work through the test-fixture limiter and remain read-only', async () => { process.env.PROPR_DEMO_MODE = 'true'; process.env.FRONTEND_URL = 'http://localhost:5173'; configureDemoMode(); @@ -185,7 +186,7 @@ test('demo Express GET routes work with the in-memory Redis facade', async () => getDelayedCount: async () => 0, } as never; const app = express(); - app.use(express.json()); + app.use('/api', createRequestRateLimiter({ identifier: 'demo-mode-test-fixture', limit: 100, windowMs: 60_000 }), express.json()); app.use('/api', demoModeReadOnlyMiddleware); setupAuth(app); app.use('/api', ensureAuthenticated); @@ -197,7 +198,7 @@ test('demo Express GET routes work with the in-memory Redis facade', async () => app.post('/api/activity', (_req, res) => res.json({ ok: true })); const statusResponse = await fetchFromApp(app, '/api/status'); - assert.equal(statusResponse.status, 200); + assert.deepEqual([statusResponse.status, statusResponse.headers.get('ratelimit')?.includes('"demo-mode-test-fixture"')], [200, true]); const statusBody = await statusResponse.json() as { redis: string; worker: string; workerCount: number }; assert.equal(statusBody.redis, 'connected'); assert.equal(statusBody.worker, 'running'); diff --git a/packages/api/test/desktopApiBoundary.test.ts b/packages/api/test/desktopApiBoundary.test.ts new file mode 100644 index 000000000..fe1dd2811 --- /dev/null +++ b/packages/api/test/desktopApiBoundary.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { after, describe, test } from 'node:test'; +import express, { type RequestHandler } from 'express'; +import { closeConnection } from '@propr/core'; +import { registerDesktopApiBoundary, type DesktopApiBoundaryRoutes } from '../desktopApiBoundary.js'; + +after(async () => closeConnection()); + +const reached = (name: string): RequestHandler => (_req, res) => { + res.status(204).set('X-ProPR-Route', name).end(); +}; + +const publicRoutes: DesktopApiBoundaryRoutes = { + discovery: reached('discovery'), + startPairing: reached('start'), + pollPairing: reached('poll'), + activatePairing: reached('activate'), + cancelPairing: reached('cancel'), + openPairingApproval: reached('browser'), + revokeCurrentToken: reached('revoke'), +}; + +const fetchFromApp = async ( + app: express.Express, + path: string, + init?: RequestInit, +): Promise => { + const server = app.listen(0, '127.0.0.1'); + try { + await new Promise(resolve => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return await fetch(`http://127.0.0.1:${port}${path}`, init); + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } +}; + +describe('assembled desktop API authentication boundary', () => { + test('keeps discovery and bounded pairing bootstrap ahead of the operational API guard', async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.isAuthenticated = () => false; + next(); + }); + registerDesktopApiBoundary(app, publicRoutes); + app.get('/api/status', (_req, res) => res.json({ operational: true })); + + for (const [method, path, expected] of [ + ['GET', '/api/desktop/discovery', 'discovery'], + ['POST', '/api/desktop/pairings', 'start'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/poll', 'poll'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/activate', 'activate'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/cancel', 'cancel'], + ['GET', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/browser', 'browser'], + ] as const) { + const response = await fetchFromApp(app, path, { method }); + assert.equal(response.status, 204, `${method} ${path}`); + assert.equal(response.headers.get('x-propr-route'), expected, `${method} ${path}`); + } + + const protectedResponse = await fetchFromApp(app, '/api/status'); + assert.equal(protectedResponse.status, 401); + assert.deepEqual(await protectedResponse.json(), { error: 'Unauthorized' }); + }); +}); diff --git a/packages/api/test/desktopAuth.connectAuthority.test.ts b/packages/api/test/desktopAuth.connectAuthority.test.ts new file mode 100644 index 000000000..2b861308a --- /dev/null +++ b/packages/api/test/desktopAuth.connectAuthority.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; +import knex, { type Knex } from 'knex'; +import { closeConnection } from '@propr/core'; +import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import { up as createDesktopAuthTables } from '../../core/src/db/migrations/20260829000000_create_desktop_auth.js'; +import { up as addTwoPhaseDesktopPairing } from '../../core/src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js'; +import { DesktopAuthError, DesktopAuthService } from '../desktopAuthService.js'; + +let database: Knex; +let now: Date; + +const pairingBinding = (origin = 'https://app.example.test') => ({ + instanceId: 'profile-a', + origin, + scope: 'desktop-instance' as const, + credentialGeneration: 'G'.repeat(22), +}); + +const startPairing = ( + target: DesktopAuthService, + name: string, + origin = 'https://app.example.test', +) => target.startPairing(name, pairingBinding(origin)); + +beforeEach(async () => { + database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createDesktopAuthTables(database); + await addTwoPhaseDesktopPairing(database); + now = new Date('2026-08-29T14:00:00.000Z'); +}); + +afterEach(async () => database.destroy()); +after(async () => closeConnection()); + +describe('desktop managed Connect pairing authority', () => { + test('uses the configured API browser entry and preserves only a managed hosted tunnel selector', async () => { + const hosted = new DesktopAuthService({ + database, + now: () => new Date(now), + approvalBaseUrl: 'https://app.propr.dev', + publicApiUrl: 'https://t-instance123.propr.dev', + }); + const pairing = await startPairing(hosted, 'Windows desktop', 'https://t-instance123.propr.dev'); + + assert.equal( + pairing.approvalUrl, + `https://t-instance123.propr.dev/api/desktop/pairings/${pairing.pairingId}/browser`, + ); + assert.equal( + hosted.getFrontendApprovalUrl(pairing.pairingId).toString(), + `https://app.propr.dev/desktop/pairing?pairing_id=${pairing.pairingId}&tunnel=t-instance123.propr.dev`, + ); + const selfManaged = new DesktopAuthService({ + database, + approvalBaseUrl: 'https://app.propr.dev', + publicApiUrl: 'https://t-tenant.propr.dev.example.com', + }); + assert.equal( + selfManaged.getFrontendApprovalUrl(pairing.pairingId).toString(), + `https://app.propr.dev/desktop/pairing?pairing_id=${pairing.pairingId}`, + ); + }); + + test('does not place a Connect selector in hosted approval URLs for lookalike API hosts', async () => { + const lookalike = new DesktopAuthService({ + database, + now: () => new Date(now), + approvalBaseUrl: 'https://app.propr.dev', + publicApiUrl: 'https://t-instance123.propr.dev.example.com', + }); + const pairing = await startPairing(lookalike, 'Lookalike test'); + + assert.equal( + lookalike.getFrontendApprovalUrl(pairing.pairingId).toString(), + `https://app.propr.dev/desktop/pairing?pairing_id=${pairing.pairingId}`, + ); + }); + + test('rejects noncanonical reserved API_PUBLIC_URL spellings before starting pairing', async () => { + for (const publicApiUrl of [ + ' https://t-instance123.propr.dev', + 'https://t-instance123.propr.dev ', + 'https://t-instance123.propr.dev/', + 'https://t-instance123.propr.dev//', + 'HTTPS://t-instance123.propr.dev', + 'https://T-instance123.propr.dev', + 'https://user:secret@t-instance123.propr.dev', + 'https://t-instance123.propr.dev:443', + 'https://t-instance123.propr.dev?query=secret', + 'https://t-instance123.propr.dev#fragment', + 'https://t-%69nstance123.propr.dev', + 'https://t-%zz.propr.dev', + 'https://x.t-instance123.propr.dev', + 'https://nested.t-instance123.propr.dev', + 'https://t-instance123.propr.dev.', + 'https://t-instance123.extra.propr.dev', + 'https://extra.t-instance123.propr.dev', + 'https://t-аbc.propr.dev', + `https://t-instance123.propr.dev${' '.repeat(2049)}`, + ]) { + const invalidConnect = new DesktopAuthService({ + database, + now: () => new Date(now), + approvalBaseUrl: 'https://app.propr.dev', + publicApiUrl, + }); + + await assert.rejects( + startPairing(invalidConnect, 'Invalid Connect test'), + (error: unknown) => error instanceof DesktopAuthError + && error.code === 'PAIRING_CONFIGURATION_INVALID' + && error.status === 503 + && error.message === 'Desktop pairing is unavailable because the public API URL is invalid', + ); + } + + assert.equal(await database('desktop_pairing_requests').count<{ count: number }>('* as count').first() + .then(result => Number(result?.count)), 0); + }); + + test('pairing rejects mixed-case managed tunnel DNS before URL normalization', () => { + const pairingId = 'dpr_' + 'A'.repeat(22); + const hosted = new DesktopAuthService({ + database, + approvalBaseUrl: 'https://app.propr.dev', + publicApiUrl: 'https://T-Instance123.ProPR.dev', + }); + assert.throws(() => hosted.getFrontendApprovalUrl(pairingId), (error: unknown) => + error instanceof DesktopAuthError + && error.code === 'PAIRING_CONFIGURATION_INVALID' + && !error.message.includes('T-Instance123')); + }); + + test('matches the shared canonical origin parity table for the public REST and Socket origin', async () => { + let index = 0; + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + const candidate = new DesktopAuthService({ + database, + approvalBaseUrl: 'https://app.example.test', + publicApiUrl: input, + }); + const start = startPairing(candidate, `Parity ${index++}`, expected ?? 'https://invalid.example.test'); + if (expected === null) await assert.rejects(start, undefined, name); + else assert.equal(new URL((await start).approvalUrl).origin, expected, name); + } + }); +}); diff --git a/packages/api/test/desktopAuth.test.ts b/packages/api/test/desktopAuth.test.ts new file mode 100644 index 000000000..4b1bef161 --- /dev/null +++ b/packages/api/test/desktopAuth.test.ts @@ -0,0 +1,355 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; +import type { NextFunction, Request, Response } from 'express'; +import knex, { type Knex } from 'knex'; +import { closeConnection } from '@propr/core'; +import { up as createDesktopAuthTables } from '../../core/src/db/migrations/20260829000000_create_desktop_auth.js'; +import { up as addTwoPhaseDesktopPairing } from '../../core/src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js'; +import { + DesktopAuthError, + DesktopAuthService, + INSTANCE_TOKEN_PREFIX, +} from '../desktopAuthService.js'; +import { + createDesktopAuthRoutes, +} from '../routes/desktopAuthRoutes.js'; +import type { GitHubUser } from '../authTypes.js'; +import { ensureAuthenticated } from '../auth.js'; + +const owner: GitHubUser = { + id: '101', + login: 'desktop-owner', + username: 'desktop-owner', + displayName: 'Desktop Owner', + email: 'owner@example.test', + avatarUrl: 'https://avatars.example.test/101', + accessToken: 'github-secret-that-must-not-be-stored', +}; + +let database: Knex; +let now: Date; +let service: DesktopAuthService; +const pairingBinding = (origin = 'https://app.example.test') => ({ + instanceId: 'profile-a', + origin, + scope: 'desktop-instance' as const, + credentialGeneration: 'G'.repeat(22), +}); +const startPairing = ( + target: DesktopAuthService, + name: string, + origin = 'https://app.example.test', +) => target.startPairing(name, pairingBinding(origin)); + +beforeEach(async () => { + database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await createDesktopAuthTables(database); + await addTwoPhaseDesktopPairing(database); + now = new Date('2026-08-29T14:00:00.000Z'); + service = new DesktopAuthService({ + database, + now: () => new Date(now), + approvalBaseUrl: 'https://app.example.test/base/', + }); +}); + +afterEach(async () => database.destroy()); +after(async () => closeConnection()); + +describe('desktop browser pairing', () => { + test('stores only a device-secret hash and builds a fixed trusted approval URL', async () => { + const pairing = await startPairing(service, ' Work Laptop '); + const row = await database('desktop_pairing_requests').where({ id: pairing.pairingId }).first(); + const audit = await database('desktop_auth_audit').first(); + + assert.match(pairing.pairingId, /^dpr_[A-Za-z0-9_-]{22}$/); + assert.match(pairing.deviceSecret, /^[A-Za-z0-9_-]{43}$/); + assert.equal(pairing.approvalUrl, `https://app.example.test/base/desktop/pairing?pairing_id=${pairing.pairingId}`); + assert.equal(pairing.approvalUrl.includes(pairing.deviceSecret), false); + assert.equal(row.client_name, 'Work Laptop'); + assert.equal(row.requested_origin, 'https://app.example.test'); + assert.notEqual(row.device_secret_hash, pairing.deviceSecret); + assert.equal(JSON.stringify(row).includes(pairing.deviceSecret), false); + assert.equal(JSON.stringify(audit).includes(pairing.deviceSecret), false); + }); + + test('provisions one unusable credential, then activates it exactly once without storing plaintext', async () => { + const binding = pairingBinding(); + const pairing = await startPairing(service, 'MacBook Pro'); + assert.deepEqual(await service.pollPairing(pairing.pairingId, pairing.deviceSecret), { + status: 'pending', + interval: 5, + }); + await service.approvePairing(pairing.pairingId, owner); + + const completed = await service.pollPairing(pairing.pairingId, pairing.deviceSecret); + assert.equal(completed.status, 'provisional'); + if (completed.status !== 'provisional') return; + assert.match(completed.token, new RegExp(`^${INSTANCE_TOKEN_PREFIX}[A-Za-z0-9_-]{43}$`)); + assert.equal(await service.validateToken(completed.token), null); + assert.deepEqual(await service.pollPairing(pairing.pairingId, pairing.deviceSecret), completed); + + const activation = { + ...binding, + deviceSecret: pairing.deviceSecret, + activationTicket: completed.activationTicket, + }; + const receipt = await service.activatePairing(pairing.pairingId, activation); + assert.deepEqual(await service.activatePairing(pairing.pairingId, activation), receipt); + + const tokenRow = await database('instance_api_tokens').first(); + const pairingRow = await database('desktop_pairing_requests').first(); + const databaseDump = JSON.stringify({ tokenRow, pairingRow }); + assert.equal(databaseDump.includes(completed.token), false); + assert.equal(databaseDump.includes(pairing.deviceSecret), false); + assert.equal(databaseDump.includes(owner.accessToken!), false); + assert.equal(tokenRow.owner_github_user_id, owner.id); + assert.equal(pairingRow.status, 'consumed'); + + await assert.rejects( + service.pollPairing(pairing.pairingId, pairing.deviceSecret), + (error: unknown) => error instanceof DesktopAuthError && error.code === 'PAIRING_ALREADY_CONSUMED', + ); + + const identity = await service.validateToken(completed.token); + assert.equal(identity?.user.id, owner.id); + assert.equal(identity?.user.accessToken, undefined); + assert.equal((await database('instance_api_tokens').first()).last_used_at, now.toISOString()); + }); + + test('rejects the wrong secret without revealing pairing state', async () => { + const pairing = await startPairing(service, 'Linux workstation'); + await service.approvePairing(pairing.pairingId, owner); + + await assert.rejects( + service.pollPairing(pairing.pairingId, 'A'.repeat(43)), + (error: unknown) => error instanceof DesktopAuthError + && error.code === 'PAIRING_NOT_FOUND' + && error.status === 404, + ); + assert.equal((await database('desktop_pairing_requests').first()).status, 'approved'); + }); + + test('binds activation and cancellation exactly and keeps cancellation idempotent', async () => { + const binding = pairingBinding(); + const pairing = await startPairing(service, 'Cancelled desktop'); + await service.approvePairing(pairing.pairingId, owner); + const provisional = await service.pollPairing(pairing.pairingId, pairing.deviceSecret); + assert.equal(provisional.status, 'provisional'); + if (provisional.status !== 'provisional') return; + const exact = { + ...binding, + deviceSecret: pairing.deviceSecret, + activationTicket: provisional.activationTicket, + }; + await assert.rejects( + service.activatePairing(pairing.pairingId, { ...exact, instanceId: 'wrong-profile' }), + (error: unknown) => error instanceof DesktopAuthError && error.code === 'PAIRING_NOT_FOUND', + ); + assert.equal(await service.validateToken(provisional.token), null); + + const cancelled = await service.cancelPairing(pairing.pairingId, exact); + assert.deepEqual(await service.cancelPairing(pairing.pairingId, exact), cancelled); + await assert.rejects( + service.activatePairing(pairing.pairingId, exact), + (error: unknown) => error instanceof DesktopAuthError && error.code === 'PAIRING_CANCELLED', + ); + assert.equal(await service.validateToken(provisional.token), null); + }); + + test('reuses one provisional across a database restart and cleans it after fixed expiry', async () => { + const expiring = new DesktopAuthService({ + database, + now: () => new Date(now), + provisionalTtlMs: 1_000, + approvalBaseUrl: 'https://app.example.test', + }); + const pairing = await startPairing(expiring, 'Restarted desktop'); + await expiring.approvePairing(pairing.pairingId, owner); + const first = await expiring.pollPairing(pairing.pairingId, pairing.deviceSecret); + const restarted = new DesktopAuthService({ + database, + now: () => new Date(now), + provisionalTtlMs: 1_000, + approvalBaseUrl: 'https://app.example.test', + }); + assert.deepEqual(await restarted.pollPairing(pairing.pairingId, pairing.deviceSecret), first); + assert.equal(await database('instance_api_tokens').where({ activation_state: 'provisional' }).count({ count: '*' }).first() + .then(row => Number(row?.count)), 1); + now = new Date(now.getTime() + 1_001); + await restarted.cleanupPairings(); + assert.equal(await database('instance_api_tokens').count({ count: '*' }).first().then(row => Number(row?.count)), 0); + }); + + test('expires unapproved pairings and cleans retained expired records', async () => { + const expiringService = new DesktopAuthService({ + database, + now: () => new Date(now), + pairingTtlMs: 1_000, + approvalBaseUrl: 'https://app.example.test', + }); + const pairing = await startPairing(expiringService, 'Old laptop'); + now = new Date(now.getTime() + 1_001); + + await assert.rejects( + expiringService.pollPairing(pairing.pairingId, pairing.deviceSecret), + (error: unknown) => error instanceof DesktopAuthError && error.code === 'PAIRING_EXPIRED', + ); + assert.equal(await expiringService.cleanupPairings(), 0, 'recent expired rows remain briefly for stable errors'); + now = new Date(now.getTime() + 24 * 60 * 60_000); + assert.equal(await expiringService.cleanupPairings(), 1); + }); + + test('rejects unsafe names and non-HTTPS approval origins', async () => { + await assert.rejects(startPairing(service, 'bad\nname'), /printable characters/); + await assert.rejects(startPairing(service, 'x'.repeat(81)), /1 to 80/); + const insecure = new DesktopAuthService({ database, approvalBaseUrl: 'http://remote.example.test' }); + await assert.rejects(startPairing(insecure, 'Laptop'), /requires HTTPS/); + }); + +}); + +describe('instance token ownership and revocation', () => { + async function issueToken(): Promise<{ token: string; tokenId: string }> { + const binding = pairingBinding(); + const pairing = await startPairing(service, 'Desktop app'); + await service.approvePairing(pairing.pairingId, owner); + const completed = await service.pollPairing(pairing.pairingId, pairing.deviceSecret); + assert.equal(completed.status, 'provisional'); + if (completed.status !== 'provisional') throw new Error('token was not issued'); + await service.activatePairing(pairing.pairingId, { + ...binding, + deviceSecret: pairing.deviceSecret, + activationTicket: completed.activationTicket, + }); + const tokenId = (await service.listTokens(owner.id))[0].id; + return { token: completed.token, tokenId }; + } + + test('lists safe metadata only and limits revocation to the owner', async () => { + const { token, tokenId } = await issueToken(); + const listed = await service.listTokens(owner.id); + + assert.equal(listed.length, 1); + assert.equal(JSON.stringify(listed).includes(token), false); + assert.deepEqual(await service.listTokens('someone-else'), []); + await assert.rejects( + service.revokeToken(tokenId, { ...owner, id: 'someone-else' }), + (error: unknown) => error instanceof DesktopAuthError && error.code === 'TOKEN_NOT_FOUND', + ); + assert.notEqual(await service.validateToken(token), null); + + await service.revokeToken(tokenId, owner); + assert.equal(await service.validateToken(token), null); + assert.notEqual((await service.listTokens(owner.id))[0].revokedAt, null); + }); + + test('honors optional token expiry', async () => { + service = new DesktopAuthService({ + database, + now: () => new Date(now), + tokenTtlMs: 1_000, + approvalBaseUrl: 'https://app.example.test', + }); + const { token } = await issueToken(); + now = new Date(now.getTime() + 1_001); + assert.equal(await service.validateToken(token), null); + }); + + test('lets a desktop revoke only the instance token authenticating its request', async () => { + const { token, tokenId } = await issueToken(); + const routes = createDesktopAuthRoutes({ service, frontendUrl: 'https://app.example.test' }); + let statusCode = 200; + let ended = false; + const response = { + status(value: number) { statusCode = value; return response; }, + json() { return response; }, + end() { ended = true; return response; }, + } as unknown as Response; + + await routes.revokeCurrentToken({ + user: owner, + authenticationMethod: 'instance_token', + instanceTokenId: tokenId, + header(name: string) { + if (name.toLowerCase() === 'authorization') return `Bearer ${token}`; + if (name.toLowerCase() === 'x-propr-desktop-revocation-binding') return 'A'.repeat(22); + return undefined; + }, + } as unknown as Request, response); + + assert.equal(statusCode, 204); + assert.equal(ended, true); + assert.equal(await service.validateToken(token), null); + }); + + test('returns the versioned endpoint-bound terminal contract on repeated self-revocation', async () => { + const { token } = await issueToken(); + const routes = createDesktopAuthRoutes({ service, frontendUrl: 'https://app.example.test' }); + const binding = 'G'.repeat(22); + const request = { + header(name: string) { + if (name.toLowerCase() === 'authorization') return `Bearer ${token}`; + if (name.toLowerCase() === 'x-propr-desktop-revocation-binding') return binding; + return undefined; + }, + } as unknown as Request; + const replies: Array<{ status: number; body?: unknown }> = []; + const makeResponse = () => { + const reply: { status: number; body?: unknown } = { status: 200 }; + replies.push(reply); + const response = { + status(value: number) { reply.status = value; return response; }, + json(value: unknown) { reply.body = value; return response; }, + end() { return response; }, + } as unknown as Response; + return response; + }; + + await routes.revokeCurrentToken(request, makeResponse()); + await routes.revokeCurrentToken(request, makeResponse()); + assert.deepEqual(replies, [ + { status: 204 }, + { + status: 401, + body: { + schema: 'propr.desktop-token-revocation', + version: 1, + endpoint: '/api/desktop/tokens/current', + terminal: true, + code: 'INSTANCE_TOKEN_REVOKED', + credentialGeneration: binding, + }, + }, + ]); + }); + + test('REST authentication accepts instance tokens while optional GitHub bearer auth is disabled', async () => { + const original = process.env.ENABLE_BEARER_AUTH; + process.env.ENABLE_BEARER_AUTH = 'false'; + const request = { + headers: { authorization: `Bearer ${INSTANCE_TOKEN_PREFIX}${'A'.repeat(43)}` }, + isAuthenticated: () => false, + } as unknown as Request; + let nextCalls = 0; + const response = {} as Response; + try { + await ensureAuthenticated(request, response, (() => { nextCalls++; }) as NextFunction, async () => ({ + tokenId: 'token-1', + user: owner, + })); + } finally { + if (original === undefined) delete process.env.ENABLE_BEARER_AUTH; + else process.env.ENABLE_BEARER_AUTH = original; + } + + assert.equal(nextCalls, 1); + assert.equal(request.authenticationMethod, 'instance_token'); + assert.equal(request.instanceTokenId, 'token-1'); + assert.equal(request.user?.id, owner.id); + }); +}); diff --git a/packages/api/test/desktopAuthRoutes.test.ts b/packages/api/test/desktopAuthRoutes.test.ts new file mode 100644 index 000000000..9eede097d --- /dev/null +++ b/packages/api/test/desktopAuthRoutes.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { after, describe, test } from 'node:test'; +import type { NextFunction, Request, Response } from 'express'; +import { closeConnection } from '@propr/core'; +import { + isTrustedPairingApprovalOrigin, + requireBrowserPairingSession, +} from '../routes/desktopAuthRoutes.js'; +import type { GitHubUser } from '../authTypes.js'; + +const owner: GitHubUser = { + id: '101', + login: 'desktop-owner', + username: 'desktop-owner', + displayName: 'Desktop Owner', + email: 'owner@example.test', + avatarUrl: 'https://avatars.example.test/101', + accessToken: 'github-secret-that-must-not-be-stored', +}; + +after(async () => closeConnection()); + +describe('pairing approval request protection', () => { + test('accepts only the exact HTTPS frontend origin', () => { + assert.equal(isTrustedPairingApprovalOrigin('https://app.example.test', 'https://app.example.test/path'), true); + assert.equal(isTrustedPairingApprovalOrigin('https://preview.app.example.test', 'https://app.example.test'), false); + assert.equal(isTrustedPairingApprovalOrigin('http://app.example.test', 'https://app.example.test'), false); + assert.equal(isTrustedPairingApprovalOrigin('http://127.1:3000', 'http://127.0.0.1:3000'), false); + assert.equal(isTrustedPairingApprovalOrigin('http://local%68ost:3000', 'http://localhost:3000'), false); + assert.equal(isTrustedPairingApprovalOrigin(undefined, 'https://app.example.test'), false); + }); + + test('requires a browser session even when another authentication method supplied the user', () => { + const guard = requireBrowserPairingSession(); + const calls: Array<{ status?: number; body?: unknown }> = []; + const response = { + status(value: number) { calls.push({ status: value }); return response; }, + json(value: unknown) { calls[calls.length - 1].body = value; return response; }, + } as unknown as Response; + let nextCalls = 0; + const next = (() => { nextCalls++; }) as NextFunction; + + guard({ + authenticationMethod: 'instance_token', + user: owner, + isAuthenticated: () => false, + header: () => 'https://app.example.test', + } as unknown as Request, response, next); + assert.equal(calls[0].status, 403); + + guard({ + authenticationMethod: 'session', + user: owner, + isAuthenticated: () => true, + header: () => 'https://app.example.test', + } as unknown as Request, response, next); + assert.equal(nextCalls, 1); + }); +}); diff --git a/packages/api/test/notificationManagementRoutes.test.ts b/packages/api/test/notificationManagementRoutes.test.ts index dbbc1e8af..06fbb0e21 100644 --- a/packages/api/test/notificationManagementRoutes.test.ts +++ b/packages/api/test/notificationManagementRoutes.test.ts @@ -66,9 +66,13 @@ function recorder(): { response: Response; status: () => number; body: () => unk function vapidPair(): { publicKey: string; privateKey: string } { const ecdh = createECDH('prime256v1'); ecdh.generateKeys(); + // Node omits leading zero bytes, while VAPID private keys are fixed-width scalars. + const privateKey = Buffer.alloc(32); + const generatedPrivateKey = ecdh.getPrivateKey(); + generatedPrivateKey.copy(privateKey, privateKey.length - generatedPrivateKey.length); return { publicKey: ecdh.getPublicKey(undefined, 'uncompressed').toString('base64url'), - privateKey: ecdh.getPrivateKey().toString('base64url') + privateKey: privateKey.toString('base64url') }; } diff --git a/packages/api/test/requestRateLimits.test.ts b/packages/api/test/requestRateLimits.test.ts index e9a3ca6e0..6799757ce 100644 --- a/packages/api/test/requestRateLimits.test.ts +++ b/packages/api/test/requestRateLimits.test.ts @@ -5,9 +5,12 @@ import express from 'express'; import session from 'express-session'; import { configureApiProxyTrust, + createApiRequestRateLimiter, + createDiscoveryRequestRateLimiter, createRequestRateLimiter, resolveRequestRateLimitPolicies, } from '../requestRateLimits.js'; +import { prohibitApiResponseCaching } from '../apiCacheControl.js'; interface TestAppOptions { proxyEnvironment?: Record; @@ -77,6 +80,56 @@ test('returns a standard 429 response after the configured quota', async () => { }); }); +test('the real global API limiter keeps no-store headers when saturated', async () => { + const app = express(); + app.use('/api', prohibitApiResponseCaching); + app.use('/api', createApiRequestRateLimiter({ + PROPR_API_RATE_LIMIT_MAX: '1', + PROPR_API_RATE_LIMIT_WINDOW_MS: '60000', + })); + app.get('/api/resource', (_request, response) => response.json({ ok: true })); + const server = await listenTestApp(app); + openServers.push(server.close); + + const success = await fetch(`${server.origin}/api/resource`); + const limited = await fetch(`${server.origin}/api/resource`); + assert.equal(success.status, 200); + assert.equal(limited.status, 429); + for (const response of [success, limited]) { + assert.equal(response.headers.get('cache-control'), 'no-store, max-age=0'); + assert.equal(response.headers.get('pragma'), 'no-cache'); + } +}); + +test('route limiting, 503, and errors inherit the earliest API no-store boundary', async () => { + const app = express(); + app.use('/api', prohibitApiResponseCaching); + app.get('/api/discovery', createDiscoveryRequestRateLimiter({ + PROPR_DISCOVERY_RATE_LIMIT_MAX: '1', + PROPR_DISCOVERY_RATE_LIMIT_WINDOW_MS: '60000', + }), (_request, response) => response.json({ ok: true })); + app.get('/api/unavailable', (_request, response) => response.status(503).json({ unavailable: true })); + app.get('/api/error', () => { throw new Error('private failure'); }); + app.use((_error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => { + void _next; + response.status(500).json({ error: 'Internal server error' }); + }); + const server = await listenTestApp(app); + openServers.push(server.close); + + const responses = [ + await fetch(`${server.origin}/api/discovery`), + await fetch(`${server.origin}/api/discovery`), + await fetch(`${server.origin}/api/unavailable`), + await fetch(`${server.origin}/api/error`), + ]; + assert.deepEqual(responses.map(response => response.status), [200, 429, 503, 500]); + for (const response of responses) { + assert.equal(response.headers.get('cache-control'), 'no-store, max-age=0'); + assert.equal(response.headers.get('pragma'), 'no-cache'); + } +}); + test('does not charge CORS preflight requests against the quota', async () => { const app = await startTestApp(1); openServers.push(app.close); @@ -208,6 +261,9 @@ test('resolves secure defaults and explicit positive-integer overrides', () => { const defaults = resolveRequestRateLimitPolicies({}); assert.deepEqual(defaults.api, { identifier: 'api', limit: 600, windowMs: 60_000 }); assert.deepEqual(defaults.auth, { identifier: 'auth', limit: 30, windowMs: 900_000 }); + assert.deepEqual(defaults.discovery, { identifier: 'desktop-discovery', limit: 60, windowMs: 60_000 }); + assert.deepEqual(defaults.pairingStart, { identifier: 'desktop-pairing-start', limit: 10, windowMs: 900_000 }); + assert.deepEqual(defaults.pairingPoll, { identifier: 'desktop-pairing-poll', limit: 180, windowMs: 900_000 }); assert.deepEqual(defaults.webhook, { identifier: 'webhook', limit: 300, windowMs: 60_000 }); const configured = resolveRequestRateLimitPolicies({ diff --git a/packages/api/test/sessionCookie.test.ts b/packages/api/test/sessionCookie.test.ts index 5f3d3c085..c12c9950d 100644 --- a/packages/api/test/sessionCookie.test.ts +++ b/packages/api/test/sessionCookie.test.ts @@ -43,6 +43,23 @@ test('secure session cookie follows API_PUBLIC_URL protocol for HTTPS and localh process.env.API_PUBLIC_URL = 'http://[::1]:4000'; assert.equal(shouldUseSecureSessionCookie('.example.com'), false); + + process.env.API_PUBLIC_URL = 'http://api.dev.localhost:4000'; + assert.equal(shouldUseSecureSessionCookie('.example.com'), false); + + process.env.API_PUBLIC_URL = 'http://127.42.7.9:4000'; + assert.equal(shouldUseSecureSessionCookie('.example.com'), false); + + process.env.API_PUBLIC_URL = 'http://127.1:4000'; + assert.equal(shouldUseSecureSessionCookie('.example.com'), true); +}); + +test('noncanonical HTTPS public URL keeps the session cookie secure in development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.COOKIE_DOMAIN; + process.env.API_PUBLIC_URL = 'https://api.example.test/path'; + + assert.equal(shouldUseSecureSessionCookie(undefined), true); }); test('secure session cookie does not downgrade for non-localhost HTTP public URL', () => { diff --git a/packages/api/test/socketAuthentication.test.ts b/packages/api/test/socketAuthentication.test.ts index d6e66fedc..d93da195e 100644 --- a/packages/api/test/socketAuthentication.test.ts +++ b/packages/api/test/socketAuthentication.test.ts @@ -8,6 +8,7 @@ import { io as createSocketClient, type Socket as ClientSocket } from 'socket.io import { closeConnection } from '@propr/core'; import { INDEXING_UPDATE, type IndexingUpdatePayload } from '@propr/shared'; import type { GitHubUser } from '../authTypes.js'; +import { INSTANCE_TOKEN_PREFIX } from '../desktopAuthService.js'; import { authenticateSocketRequest, SocketAuthenticationError, @@ -117,6 +118,18 @@ describe('Socket.IO authentication', () => { assert.equal(result.authorization.role, 'admin'); }); + test('accepts an instance token without enabling optional GitHub bearer auth', async () => { + process.env.ENABLE_BEARER_AUTH = 'false'; + const result = await authenticateSocketRequest( + request({ headers: { authorization: `Bearer ${INSTANCE_TOKEN_PREFIX}${'A'.repeat(43)}` } }), + dependencies({ + validateInstanceToken: async () => ({ tokenId: 'token-1', user: user({ id: '77' }) }), + }), + ); + + assert.equal(result.user.id, '77'); + }); + test('rejects a session user removed from the whitelist', async () => { const sessionUser = user({ username: 'removed' }); await assert.rejects( @@ -159,7 +172,7 @@ describe('Socket.IO authentication', () => { ); }); - test('runs Engine.IO middleware before the mandatory identity gate', async () => { + test('runs Engine.IO middleware and maps browser Socket.IO auth into the shared bearer gate', async () => { const httpServer = createServer(); const io = new SocketIOServer(httpServer, { transports: ['websocket'] }); const markerMiddleware: RequestHandler = (req, _res, next) => { @@ -183,7 +196,7 @@ describe('Socket.IO authentication', () => { const port = (httpServer.address() as AddressInfo).port; const client = createSocketClient(`http://127.0.0.1:${port}`, { transports: ['websocket'], - extraHeaders: { Authorization: 'Bearer test-token' }, + auth: { token: 'test-token' }, reconnection: false, }); @@ -197,6 +210,112 @@ describe('Socket.IO authentication', () => { } }); + test('refreshes synthesized bearer auth on namespace reconnects over the same Engine.IO connection', async () => { + const httpServer = createServer(); + const io = new SocketIOServer(httpServer, { transports: ['websocket'] }); + const seenAuthorization: Array = []; + configureSocketAuthentication(io, { + engineMiddleware: [], + authenticate: async req => { + const authorization = req.headers.authorization; + seenAuthorization.push(authorization); + if (authorization === 'Bearer initial-token') return principal(user({ id: '1' })); + if (authorization === 'Bearer replacement-token') return principal(user({ id: '2' })); + throw new SocketAuthenticationError('AUTHENTICATION_REQUIRED', 'missing bearer'); + }, + }); + let serverSocket: ServerSocket | undefined; + io.on('connection', socket => { + serverSocket = socket; + }); + io.of('/anchor').on('connection', () => undefined); + await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve)); + const port = (httpServer.address() as AddressInfo).port; + const client = createSocketClient(`http://127.0.0.1:${port}`, { + transports: ['websocket'], + auth: { token: 'initial-token' }, + autoConnect: false, + reconnection: false, + }); + const anchor = client.io.socket('/anchor'); + + try { + client.connect(); + anchor.connect(); + await waitFor( + () => client.connected && anchor.connected, + 'Initial namespaces did not connect', + ); + const engineId = client.io.engine?.id; + assert(engineId); + + const initialServerSocket = serverSocket; + assert(initialServerSocket); + const initiallyDisconnected = new Promise(resolve => { + initialServerSocket.once('disconnect', () => resolve()); + }); + client.disconnect(); + await initiallyDisconnected; + client.auth = { token: 'replacement-token' }; + const reconnected = waitForConnect(client); + client.connect(); + await reconnected; + assert.equal(client.io.engine?.id, engineId); + + const replacementServerSocket = serverSocket; + assert(replacementServerSocket); + const replacementDisconnected = new Promise(resolve => { + replacementServerSocket.once('disconnect', () => resolve()); + }); + client.disconnect(); + await replacementDisconnected; + client.auth = {}; + const rejected = waitForConnectError(client); + client.connect(); + const error = await rejected; + assert.equal(error.data?.code, 'AUTHENTICATION_REQUIRED'); + assert.equal(client.io.engine?.id, engineId); + assert.deepEqual(seenAuthorization, [ + 'Bearer initial-token', + 'Bearer replacement-token', + undefined, + ]); + } finally { + client.disconnect(); + anchor.disconnect(); + await io.close(); + await new Promise(resolve => httpServer.close(() => resolve())); + } + }); + + test('preserves transport-level Authorization instead of Socket.IO auth', async () => { + const httpServer = createServer(); + const io = new SocketIOServer(httpServer, { transports: ['websocket'] }); + configureSocketAuthentication(io, { + engineMiddleware: [], + authenticate: async req => { + assert.equal(req.headers.authorization, 'Bearer transport-token'); + return principal(); + }, + }); + await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve)); + const port = (httpServer.address() as AddressInfo).port; + const client = createSocketClient(`http://127.0.0.1:${port}`, { + transports: ['websocket'], + extraHeaders: { Authorization: 'Bearer transport-token' }, + auth: { token: 'socket-token' }, + reconnection: false, + }); + + try { + await waitForConnect(client); + } finally { + client.disconnect(); + await io.close(); + await new Promise(resolve => httpServer.close(() => resolve())); + } + }); + test('surfaces a stable authentication error code to rejected clients', async () => { const httpServer = createServer(); const io = new SocketIOServer(httpServer, { transports: ['websocket'] }); diff --git a/packages/api/test/statusRoutes.test.ts b/packages/api/test/statusRoutes.test.ts index 1675b1597..b1c37a15b 100644 --- a/packages/api/test/statusRoutes.test.ts +++ b/packages/api/test/statusRoutes.test.ts @@ -4,7 +4,12 @@ import { after, afterEach, test } from 'node:test'; import type { Request, Response as ExpressResponse } from 'express'; import type { Agent, AgentConfig } from '@propr/core'; import type { RedisClientType } from 'redis'; -import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY, PROPR_VERSION } from '@propr/shared'; +import { + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, + PROPR_VERSION, + parseProprDesktopDiscovery, +} from '@propr/shared'; import type { SyntheticAgentConfig } from '@propr/shared'; type StatusRoutesDeps = { @@ -26,6 +31,7 @@ type StatusRoutesDeps = { snapshot: Record & { timestamp: string }, additionalAdministratorIds: readonly string[], ) => Promise; + getPublicInstanceIdentity?: () => string; }; type StatusAgentRegistry = { @@ -59,15 +65,22 @@ const MANAGED_ENV_VARS = [ 'PROPR_GH_RELAY_TOKEN', 'GITHUB_EVENT_INTAKE_MODE', 'ENABLE_GITHUB_WEBHOOKS', + 'API_PUBLIC_URL', ] as const; const originalEnv: Record = Object.fromEntries( MANAGED_ENV_VARS.map((key) => [key, process.env[key]]), ); -function createJsonResponse(): { response: ExpressResponse; status: () => number; body: () => Record } { +function createJsonResponse(): { + response: ExpressResponse; + status: () => number; + body: () => Record; + headers: () => Record; +} { let statusCode = 200; let payload: Record = {}; + let responseHeaders: Record = {}; const response = { status(code: number) { statusCode = code; @@ -76,9 +89,18 @@ function createJsonResponse(): { response: ExpressResponse; status: () => number json(body: Record) { payload = body; return response; - } + }, + set(headers: Record) { + responseHeaders = { ...responseHeaders, ...headers }; + return response; + }, } as unknown as ExpressResponse; - return { response, status: () => statusCode, body: () => payload }; + return { + response, + status: () => statusCode, + body: () => payload, + headers: () => responseHeaders, + }; } function createRedisClient() { @@ -207,9 +229,66 @@ test('/api/compatibility returns public version contract metadata', async () => version: PROPR_VERSION, apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, }); }); +test('/api/desktop/discovery returns the bounded public identity and runtime origin', async () => { + configureStatusEnv(); + process.env.API_PUBLIC_URL = 'https://t-abc123.propr.dev'; + const { response, body, headers } = createJsonResponse(); + const routes = await createRoutes({ + redisClient: createRedisClient() as never, + getPublicInstanceIdentity: () => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }); + + await routes.getDesktopDiscovery({} as Request, response); + + assert.deepEqual(body(), { + schemaVersion: 1, + product: 'ProPR', + canonicalEndpoint: 'https://t-abc123.propr.dev', + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + version: PROPR_VERSION, + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }); + assert.equal(headers()['Cache-Control'], 'no-store, max-age=0'); + assert.equal(JSON.stringify(body()).includes('SENTINEL'), false); + assert.deepEqual(parseProprDesktopDiscovery(body()), body()); +}); + +test('/api/desktop/discovery redacts identity persistence failures', async () => { + configureStatusEnv(); + process.env.API_PUBLIC_URL = 'https://t-abc123.propr.dev'; + const { response, status, body, headers } = createJsonResponse(); + const routes = await createRoutes({ + redisClient: createRedisClient() as never, + getPublicInstanceIdentity: () => { + throw new Error('/private/path includes connector-token-SENTINEL'); + }, + }); + + await routes.getDesktopDiscovery({} as Request, response); + + assert.equal(status(), 503); + assert.deepEqual(body(), { schemaVersion: 1, code: 'IDENTITY_UNAVAILABLE' }); + assert.equal(headers()['Cache-Control'], 'no-store, max-age=0'); + assert.equal(headers().Pragma, 'no-cache'); + assert.equal(JSON.stringify(body()).includes('SENTINEL'), false); +}); + test('/api/status returns default Claude fallback when no agents are configured', async () => { const body = await readStatus(); diff --git a/packages/api/test/webPushDispatcher.test.ts b/packages/api/test/webPushDispatcher.test.ts index f14121021..41cd78f34 100644 --- a/packages/api/test/webPushDispatcher.test.ts +++ b/packages/api/test/webPushDispatcher.test.ts @@ -13,6 +13,39 @@ import { NotificationService } from '../../core/src/services/notificationService import { WebPushDispatcher } from '../services/webPushDispatcher.js'; const success: SendResult = { statusCode: 201, body: '', headers: {} }; +const HISTORICAL_FIXTURE_TIME = Date.parse('2020-01-01T00:00:00.000Z'); +const DISPATCH_FIXTURE_TIME = HISTORICAL_FIXTURE_TIME + 60_000; +const ISO_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%fZ'; + +interface TestSqliteConnection extends BetterSqliteConnection { + function( + name: string, + options: { varargs: true }, + callback: (...values: unknown[]) => string | null, + ): void; +} + +function historicalFixtureTime(): Date { + return new Date(HISTORICAL_FIXTURE_TIME); +} + +function dispatchFixtureTime(): Date { + return new Date(DISPATCH_FIXTURE_TIME); +} + +function fixtureStrftime(format: unknown, value: unknown, ...modifiers: unknown[]): string | null { + if (format !== ISO_TIMESTAMP_FORMAT) return null; + let timestamp = value === 'now' + ? DISPATCH_FIXTURE_TIME + : Date.parse(String(value)); + if (!Number.isFinite(timestamp)) return null; + for (const modifier of modifiers) { + const seconds = /^([+-]\d+(?:\.\d+)?) seconds$/.exec(String(modifier)); + if (!seconds) return null; + timestamp += Number(seconds[1]) * 1_000; + } + return new Date(timestamp).toISOString(); +} function createDatabase(): Knex { return knex({ @@ -21,9 +54,11 @@ function createDatabase(): Knex { useNullAsDefault: true, pool: { afterCreate( - connection: BetterSqliteConnection, - done: (error: Error | null, connection: BetterSqliteConnection) => void, + connection: TestSqliteConnection, + done: (error: Error | null, connection: TestSqliteConnection) => void, ) { + // Keep SQLite claim/lease checks on the dispatcher's fixed fixture clock. + connection.function('strftime', { varargs: true }, fixtureStrftime); connection.pragma('foreign_keys = ON'); connection.pragma('recursive_triggers = ON'); done(null, connection); @@ -33,7 +68,7 @@ function createDatabase(): Knex { } function vapidConfiguration() { - // Keep the fixture exactly 32 bytes; getPrivateKey() can omit leading zeroes. + // Keep the fixture full-width because getPrivateKey() can omit leading zero bytes. const privateKey = Buffer.alloc(32); privateKey[31] = 1; const ecdh = createECDH('prime256v1'); @@ -64,7 +99,7 @@ beforeEach(async () => { await addAdvertisedActions(database); notifications = new NotificationService({ database, - now: () => new Date(Date.now() - 5_000), + now: historicalFixtureTime, }); }); @@ -122,6 +157,7 @@ function dispatcher(sender: { apiBaseUrl: 'https://api.example.com', leaseMs: 5_000, requestTimeoutMs: 1_000, + now: dispatchFixtureTime, ...overrides, }); } @@ -257,7 +293,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { }); test('does not claim work during quiet hours', async () => { - const now = new Date(); + const now = dispatchFixtureTime(); const start = `${String(now.getUTCHours()).padStart(2, '0')}:${String(now.getUTCMinutes()).padStart(2, '0')}`; const endDate = new Date(now.getTime() + 60_000); const end = `${String(endDate.getUTCHours()).padStart(2, '0')}:${String(endDate.getUTCMinutes()).padStart(2, '0')}`; @@ -273,7 +309,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { }); test('paginates past a quiet-hour prefix larger than the scan window', async () => { - const fixtureBaseTime = Date.now() - 30_000; + const fixtureBaseTime = HISTORICAL_FIXTURE_TIME; let fixtureTick = 0; const fixtureService = new NotificationService({ database, @@ -288,7 +324,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { quietUsers.push(queued.userId); } const eligible = await queuedEvent({ service: fixtureService }); - const dispatchAt = new Date(); + const dispatchAt = dispatchFixtureTime(); const currentMinute = dispatchAt.getUTCHours() * 60 + dispatchAt.getUTCMinutes(); const formatMinute = (minute: number) => { const normalized = (minute + 24 * 60) % (24 * 60); @@ -410,7 +446,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { assert.ok(address !== null && typeof address !== 'string'); const localNotifications = new NotificationService({ database, - now: () => new Date(Date.now() - 5_000), + now: historicalFixtureTime, allowInsecureLocalhost: true, }); await queuedEvent({ @@ -424,6 +460,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { apiBaseUrl: 'http://127.0.0.1:4000', leaseMs: 5_000, requestTimeoutMs: 1_000, + now: dispatchFixtureTime, }); assert.equal(await worker.runOnce(), 1); @@ -449,7 +486,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { process.env.API_PUBLIC_URL = 'http://localhost:4000'; const localNotifications = new NotificationService({ database, - now: () => new Date(Date.now() - 5_000), + now: historicalFixtureTime, allowInsecureLocalhost: true, }); await queuedEvent({ @@ -500,7 +537,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { await createNotificationSchema(database); await addPreferenceApis(database); await addAdvertisedActions(database); - notifications = new NotificationService({ database, now: () => new Date(Date.now() - 5_000) }); + notifications = new NotificationService({ database, now: historicalFixtureTime }); await queuedEvent(); const exhausted = dispatcher({ sendNotification: async () => Promise.reject({ statusCode: 503, body: 'SECRET' }), @@ -540,7 +577,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { test('renews the current claim to cover the request timeout and safety margin', async () => { await queuedEvent(); - const baseTime = Date.now() - 4_000; + const baseTime = DISPATCH_FIXTURE_TIME - 4_000; let nowCalls = 0; let lastNow = baseTime; const requestTimeoutMs = 4_999; @@ -566,7 +603,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { test('skips network I/O when the claim expires during delivery preparation', async () => { await queuedEvent(); - const baseTime = Date.now() - 1_000; + const baseTime = DISPATCH_FIXTURE_TIME - 1_000; const leaseMs = 30_000; let nowCalls = 0; let sends = 0; @@ -575,7 +612,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { }, { leaseMs, requestTimeoutMs: leaseMs - 1, - // Keep the initial claim ahead of SQLite's real clock, then expire it before renewal. + // Keep the initial claim ahead of SQLite's fixture clock, then expire it before renewal. now: () => new Date(baseTime + (nowCalls++ >= 3 ? leaseMs + 1_000 : 0)), }); diff --git a/packages/cli/README.md b/packages/cli/README.md index 4ae41d4ed..e5dd1217f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -97,8 +97,14 @@ Useful follow-up commands: propr tunnel verify # check cloudflared + /api/status, /, /socket.io/ propr tunnel off # stop only the sidecar; token/env values stay in .env propr tunnel on # restart the sidecar later +propr connect status --json --root /path/to/stack # secret-free desktop discovery ``` +`connect status` requires an explicit caller-owned stack root and never scans the +filesystem. Its JSON stdout contains no tokens, account/repository/host identity, +environment values, or paths. Exit codes are 0 ready, 2 known not ready, 3 +incompatible, 4 invalid configuration/root, 5 timeout, and 1 internal failure. + `propr tunnel off` intentionally leaves the Connect-written `.env` values in place. If you are switching the same stack back to a local or custom self-hosted UI, remove or replace `PROPR_UI_PUBLIC_API_URL`, `API_PUBLIC_URL`, diff --git a/packages/cli/native/README.md b/packages/cli/native/README.md index ec45d45c1..99213ab3e 100644 --- a/packages/cli/native/README.md +++ b/packages/cli/native/README.md @@ -1,21 +1,20 @@ -# Native directory operations +# Native directory and Darwin ACL operations `directory-operations.c` is the complete source for the small N-API helper used by the Agent Skill installer on macOS and for atomic sibling moves on Linux. It -exposes only audited, dirfd-relative POSIX operations. Sibling moves use -`renameatx_np(..., RENAME_EXCL)` on Darwin and -`renameat2(..., RENAME_NOREPLACE)` on Linux. The CLI ships prebuilt N-API -binaries for arm64 and x64, so installing or running `propr` never invokes +exposes only audited, dirfd-relative POSIX operations. The CLI ships prebuilt +N-API binaries for arm64 and x64, so installing or running `propr` never invokes Python, a compiler, `node-gyp`, or another host build tool. -The runtime loader selects the artifact by `process.platform` and -`process.arch`, verifies its hard-coded SHA-256 digest before loading it, and -fails closed if the architecture is unsupported, the artifact is absent, or -its bytes do not match. N-API 8 keeps the artifacts compatible with all Node -versions supported by this package (Node 22 and newer). +`darwin-authority-broker.c` is the macOS Connect ACL diagnostic helper. It +receives the caller's already-held object as inherited fd 3 and uses `fstat`, +`acl_extended_fd_np`, `acl_get_fd_np`, and `acl_to_text` on that same descriptor. +It emits one bounded versioned document, and the CLI verifies the packaged +binary's SHA-256 before running it from a private staged path. -The checked-in binaries are built from this source with hidden symbols and -runtime lookup for Node's N-API and operating-system symbols. Release CI runs -the real lifecycle and detached-parent race proof on native Linux and arm64 -macOS hosts. Linux continues to use its traversable `/proc/self/fd` -implementation for operations other than the atomic move. +Windows Connect status deliberately has no native helper in this package. It +retains descriptor, reparse-point, replacement, and identity checks, but fails +closed with `invalidConfig` and `ACL_DIAGNOSTIC_UNAVAILABLE` when Node cannot +safely obtain a same-handle DACL diagnostic. Windows operations that would need +DACL mutation or privileged launch authority return `WINDOWS_AUTHORITY_REQUIRED` +until #1997 lands. diff --git a/packages/cli/native/darwin-authority-broker.c b/packages/cli/native/darwin-authority-broker.c new file mode 100644 index 000000000..f6c9c7b77 --- /dev/null +++ b/packages/cli/native/darwin-authority-broker.c @@ -0,0 +1,120 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#define PROPR_AUTHORITY_FD 3 +#define PROPR_MAX_ACL_TEXT 24576 +#define PROPR_MAX_JSON 32768 + +static int append_bytes(char *output, size_t *length, const char *value, size_t value_length) { + if (value_length > PROPR_MAX_JSON - *length) return -1; + memcpy(output + *length, value, value_length); + *length += value_length; + return 0; +} + +static int append_json_string(char *output, size_t *length, const char *value, size_t value_length) { + static const char hex[] = "0123456789abcdef"; + if (append_bytes(output, length, "\"", 1) != 0) return -1; + for (size_t index = 0; index < value_length; index += 1) { + unsigned char byte = (unsigned char)value[index]; + if (byte == '"' || byte == '\\') { + char escaped[2] = {'\\', (char)byte}; + if (append_bytes(output, length, escaped, sizeof(escaped)) != 0) return -1; + } else if (byte == '\n') { + if (append_bytes(output, length, "\\n", 2) != 0) return -1; + } else if (byte == '\r') { + if (append_bytes(output, length, "\\r", 2) != 0) return -1; + } else if (byte == '\t') { + if (append_bytes(output, length, "\\t", 2) != 0) return -1; + } else if (byte < 0x20) { + char escaped[6] = {'\\', 'u', '0', '0', hex[byte >> 4], hex[byte & 15]}; + if (append_bytes(output, length, escaped, sizeof(escaped)) != 0) return -1; + } else if (append_bytes(output, length, (const char *)&value[index], 1) != 0) { + return -1; + } + } + return append_bytes(output, length, "\"", 1); +} + +static int same_identity(const struct stat *left, const struct stat *right) { + return left->st_dev == right->st_dev && left->st_ino == right->st_ino; +} + +int main(void) { + struct stat before; + struct stat after; + if (fstat(PROPR_AUTHORITY_FD, &before) != 0) return 10; + + /* Apple's descriptor implementation reports an absent FILESEC_ACL property + as NULL/ENOENT. Every other NULL/errno pair is a real allocation, + descriptor, filesystem, or inspection failure and remains fatal. */ + errno = 0; + acl_t acl = NULL; + char *allocated_acl_text = NULL; + const char *acl_text = "!#acl 1\n"; + ssize_t acl_length = 8; + acl = acl_get_fd_np(PROPR_AUTHORITY_FD, ACL_TYPE_EXTENDED); + if (acl == NULL) { + if (errno != ENOENT) return 11; + } else { + allocated_acl_text = acl_to_text(acl, &acl_length); + if (allocated_acl_text == NULL) { + acl_free(acl); + return 12; + } + acl_text = allocated_acl_text; + } + if (acl_length < 0 || acl_length > PROPR_MAX_ACL_TEXT || + memchr(acl_text, '\0', (size_t)acl_length) != NULL) { + if (allocated_acl_text != NULL) acl_free(allocated_acl_text); + if (acl != NULL) acl_free(acl); + return 13; + } + if (fstat(PROPR_AUTHORITY_FD, &after) != 0 || !same_identity(&before, &after)) { + if (allocated_acl_text != NULL) acl_free(allocated_acl_text); + if (acl != NULL) acl_free(acl); + return 14; + } + + char device[32]; + char file[32]; + int device_length = snprintf(device, sizeof(device), "%llu", (unsigned long long)(uint64_t)before.st_dev); + int file_length = snprintf(file, sizeof(file), "%llu", (unsigned long long)(uint64_t)before.st_ino); + if (device_length <= 0 || (size_t)device_length >= sizeof(device) || + file_length <= 0 || (size_t)file_length >= sizeof(file)) { + if (allocated_acl_text != NULL) acl_free(allocated_acl_text); + if (acl != NULL) acl_free(acl); + return 15; + } + + char output[PROPR_MAX_JSON]; + size_t length = 0; + if (append_bytes(output, &length, "{\"version\":1,\"device\":", 22) != 0 || + append_json_string(output, &length, device, (size_t)device_length) != 0 || + append_bytes(output, &length, ",\"file\":", 8) != 0 || + append_json_string(output, &length, file, (size_t)file_length) != 0 || + append_bytes(output, &length, ",\"acl\":", 7) != 0 || + append_json_string(output, &length, acl_text, (size_t)acl_length) != 0 || + append_bytes(output, &length, "}\n", 2) != 0) { + if (allocated_acl_text != NULL) acl_free(allocated_acl_text); + if (acl != NULL) acl_free(acl); + return 16; + } + if (allocated_acl_text != NULL) acl_free(allocated_acl_text); + if (acl != NULL) acl_free(acl); + + size_t written = 0; + while (written < length) { + ssize_t count = write(STDOUT_FILENO, output + written, length - written); + if (count <= 0) return 17; + written += (size_t)count; + } + return 0; +} diff --git a/packages/cli/native/directory-operations.c b/packages/cli/native/directory-operations.c index 530e57881..47ba96d1f 100644 --- a/packages/cli/native/directory-operations.c +++ b/packages/cli/native/directory-operations.c @@ -83,8 +83,19 @@ static napi_value open_at(napi_env env, napi_callback_info info) { } char path[4096]; if (!path_argument(env, arguments[1], path, sizeof(path))) return NULL; - int result = openat(int32_argument(env, arguments[0]), path, int32_argument(env, arguments[2]), - (mode_t)uint32_argument(env, arguments[3])); + int result; +#if defined(__linux__) && defined(__aarch64__) + /* + * The arm64 prebuild is cross-compiled. Invoke the fixed Linux syscall ABI + * instead of crossing the libc variadic openat boundary from that artifact. + */ + result = (int)syscall(SYS_openat, int32_argument(env, arguments[0]), path, + int32_argument(env, arguments[2]), + (mode_t)uint32_argument(env, arguments[3])); +#else + result = openat(int32_argument(env, arguments[0]), path, int32_argument(env, arguments[2]), + (mode_t)uint32_argument(env, arguments[3])); +#endif if (result == -1) return throw_errno(env, "openat"); napi_value value; napi_create_int32(env, result, &value); @@ -196,7 +207,15 @@ static napi_value lstat_at(napi_env env, napi_callback_info info) { char path[4096]; if (!path_argument(env, arguments[1], path, sizeof(path))) return NULL; struct stat status; - if (fstatat(int32_argument(env, arguments[0]), path, &status, AT_SYMLINK_NOFOLLOW) == -1) { + int syscall_result; +#if defined(__linux__) && defined(__aarch64__) + /* Avoid the cross-toolchain libc stat-version wrapper on Linux arm64. */ + syscall_result = (int)syscall(SYS_newfstatat, int32_argument(env, arguments[0]), path, + &status, AT_SYMLINK_NOFOLLOW); +#else + syscall_result = fstatat(int32_argument(env, arguments[0]), path, &status, AT_SYMLINK_NOFOLLOW); +#endif + if (syscall_result == -1) { return throw_errno(env, "fstatat"); } diff --git a/packages/cli/native/prebuilds/darwin-arm64/connect-authority-broker b/packages/cli/native/prebuilds/darwin-arm64/connect-authority-broker new file mode 100755 index 000000000..c06b288ff Binary files /dev/null and b/packages/cli/native/prebuilds/darwin-arm64/connect-authority-broker differ diff --git a/packages/cli/native/prebuilds/darwin-x64/connect-authority-broker b/packages/cli/native/prebuilds/darwin-x64/connect-authority-broker new file mode 100755 index 000000000..25e796f5e Binary files /dev/null and b/packages/cli/native/prebuilds/darwin-x64/connect-authority-broker differ diff --git a/packages/cli/native/prebuilds/linux-arm64/directory-operations.node b/packages/cli/native/prebuilds/linux-arm64/directory-operations.node index cc476e5f0..dc8f096d3 100755 Binary files a/packages/cli/native/prebuilds/linux-arm64/directory-operations.node and b/packages/cli/native/prebuilds/linux-arm64/directory-operations.node differ diff --git a/packages/cli/package.json b/packages/cli/package.json index b6b90fcde..44f058bc7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./desktop-discovery": { + "types": "./dist/desktopDiscovery.d.ts", + "import": "./dist/desktopDiscovery.js" + } + }, "bin": { "propr": "./dist/index.js" }, @@ -21,6 +31,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json" }, "dependencies": { + "@propr/local-setup": "^0.8.15", "@propr/shared": "^0.8.15", "commander": "^13.1.0", "dotenv": "^16.5.0", diff --git a/packages/cli/scripts/build-publish.mjs b/packages/cli/scripts/build-publish.mjs index 89dacadf2..4ee9e765f 100644 --- a/packages/cli/scripts/build-publish.mjs +++ b/packages/cli/scripts/build-publish.mjs @@ -2,11 +2,11 @@ // Build a standalone, publishable npm package for the CLI. // // The in-repo package is the scoped workspace package `@propr/cli`, which depends -// on the workspace package `@propr/shared`. Neither scoped package is published to -// npm, so we ship the CLI under the unscoped public name `propr-cli` with -// `@propr/shared` *vendored* into `dist/vendor/shared/` (it is dependency-free) and -// the two `@propr/shared` imports rewritten to a relative path. The result has no -// scoped dependencies and installs cleanly from the public registry. +// on the workspace packages `@propr/shared` and `@propr/local-setup`. These scoped +// packages are not published to npm, so we ship the CLI under the unscoped public +// name `propr-cli` with both packages vendored into `dist/vendor/` and their imports +// rewritten to relative paths. The result has no scoped dependencies and installs +// cleanly from the public registry. // // Usage: // node scripts/build-publish.mjs # build the staging package + npm pack --dry-run @@ -35,9 +35,9 @@ const here = dirname(fileURLToPath(import.meta.url)); const cliDir = resolve(here, ".."); const repoRoot = resolve(cliDir, "..", ".."); const sharedDir = join(repoRoot, "packages", "shared"); +const localSetupDir = join(repoRoot, "packages", "local-setup"); const stageDir = join(repoRoot, "dist-publish", "propr-cli"); const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2024.12.2"; - const run = (cmd, cmdArgs, cwd = repoRoot) => execFileSync(cmd, cmdArgs, { cwd, stdio: "inherit" }); @@ -75,6 +75,11 @@ const buildLauncherManifest = (version) => { // 1. Build the workspace packages we depend on. run("npm", ["run", "build", "-w", "@propr/shared"]); +run("npm", ["run", "build", "-w", "@propr/local-setup"]); +// TypeScript does not remove outputs for deleted source files. Start the +// publishable CLI build from an empty output directory so retired authority +// implementations cannot survive as stale package-controlled executables or JS. +rmSync(join(cliDir, "dist"), { recursive: true, force: true }); run("npm", ["run", "build", "-w", "@propr/cli"]); // 2. Stage the CLI dist + README. @@ -89,7 +94,7 @@ for (const requiredSkillFile of ["SKILL.md", join("agents", "openai.yaml")]) { const nativeArtifacts = { "darwin-arm64": "88f07c0c7a4371f4fb227a4691009d09517de582ba49297d28d03ac94e586615", "darwin-x64": "62183c0f4083cb8c98e09e2d2c688f8f81703e12b0f22320c335b51e927eaf53", - "linux-arm64": "29b28b76ed8781f2567897ad9ba576798bbb669937048218e0416601788e0f1c", + "linux-arm64": "916679f413251c4b23c51167987a874bbbdd9d96991882bfac9093e0ea5fa051", "linux-x64": "7199378f1c7b443a05c596eae7c66f9a77cc01b4a493c07748df0df1083950f6", }; for (const [platformArch, expected] of Object.entries(nativeArtifacts)) { @@ -98,17 +103,37 @@ for (const [platformArch, expected] of Object.entries(nativeArtifacts)) { const actual = createHash("sha256").update(readFileSync(artifact)).digest("hex"); if (actual !== expected) throw new Error(`${platformArch} directory-operations artifact failed integrity verification`); } -for (const auditedFile of ["directory-operations.c", "README.md"]) { +const authorityArtifacts = { + "darwin-arm64/connect-authority-broker": "75fda2624bf093555e726b968401321fef61ea7ae0479f4c1892be0dfc6554c0", + "darwin-x64/connect-authority-broker": "e5a49be0db85655b9ff1d0614de9d61defd41a0a1b2eff8f11571407f10d809b", +}; +for (const [relativeArtifact, expected] of Object.entries(authorityArtifacts)) { + const artifact = join(stageDir, "dist", "native", "prebuilds", relativeArtifact); + if (!existsSync(artifact)) throw new Error(`Native authority broker is missing: ${artifact}`); + const actual = createHash("sha256").update(readFileSync(artifact)).digest("hex"); + if (actual !== expected) throw new Error(`${relativeArtifact} failed integrity verification`); +} +for (const auditedFile of [ + "directory-operations.c", + "darwin-authority-broker.c", + "README.md", +]) { const bundled = join(stageDir, "dist", "native", auditedFile); if (!existsSync(bundled)) throw new Error(`Audited native helper file is missing: ${bundled}`); } -// 3. Vendor shared's compiled JS (dependency-free) into dist/vendor/shared. -const vendorDir = join(stageDir, "dist", "vendor", "shared"); -mkdirSync(vendorDir, { recursive: true }); -for (const file of readdirSync(join(sharedDir, "dist"))) { - if (file.endsWith(".js")) { - cpSync(join(sharedDir, "dist", file), join(vendorDir, file)); +// 3. Vendor the compiled workspace packages into dist/vendor. +const vendorRoot = join(stageDir, "dist", "vendor"); +const vendorPackages = [ + { source: sharedDir, destination: join(vendorRoot, "shared") }, + { source: localSetupDir, destination: join(vendorRoot, "local-setup") }, +]; +for (const { source, destination } of vendorPackages) { + mkdirSync(destination, { recursive: true }); + for (const file of readdirSync(join(source, "dist"))) { + if (file.endsWith(".js")) { + cpSync(join(source, "dist", file), join(destination, file)); + } } } @@ -122,23 +147,29 @@ const stripMaps = (dir) => { }; stripMaps(join(stageDir, "dist")); -// 5. Rewrite the `@propr/shared` import specifier to the vendored relative path. -const rewriteSharedImports = (dir) => { +// 5. Rewrite private workspace imports to their vendored relative paths. +const vendoredImports = new Map([ + ["@propr/shared", join(vendorRoot, "shared", "index.js")], + ["@propr/local-setup", join(vendorRoot, "local-setup", "index.js")], +]); +const rewriteVendoredImports = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { - rewriteSharedImports(full); + rewriteVendoredImports(full); } else if (entry.name.endsWith(".js")) { - const src = readFileSync(full, "utf8"); - if (src.includes('"@propr/shared"')) { - let sharedPath = relative(dirname(full), join(vendorDir, "index.js")).split(sep).join("/"); - if (!sharedPath.startsWith(".")) sharedPath = `./${sharedPath}`; - writeFileSync(full, src.replaceAll('"@propr/shared"', `"${sharedPath}"`)); + let src = readFileSync(full, "utf8"); + for (const [specifier, target] of vendoredImports) { + if (!src.includes(`"${specifier}"`)) continue; + let vendorPath = relative(dirname(full), target).split(sep).join("/"); + if (!vendorPath.startsWith(".")) vendorPath = `./${vendorPath}`; + src = src.replaceAll(`"${specifier}"`, `"${vendorPath}"`); } + writeFileSync(full, src); } } }; -rewriteSharedImports(join(stageDir, "dist")); +rewriteVendoredImports(join(stageDir, "dist")); // 6. Write the unscoped package.json (no scoped deps, no build scripts). const cliPkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf8")); diff --git a/packages/cli/src/agentSkill.test.ts b/packages/cli/src/agentSkill.test.ts index cee98e01d..6c834251e 100644 --- a/packages/cli/src/agentSkill.test.ts +++ b/packages/cli/src/agentSkill.test.ts @@ -37,12 +37,16 @@ import { import { DARWIN_DIRECTORY_OPERATION_SHA256, LINUX_DIRECTORY_OPERATION_SHA256, + assertNativeDirectoryEntry, directoryDescriptorAccess, + openAuthorityDirectoryNoFollow, + setNativeDirectoryOpenTestHook, verifyDirectoryOperationArtifact, } from "./utils/directoryDescriptor.js"; const roots: string[] = []; afterEach(() => { + setNativeDirectoryOpenTestHook(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -126,6 +130,218 @@ test("native Linux x64 helper loads, stats, and atomically refuses replacement", } }); +test("native descriptor smoke failures expose only fixed substeps and categories", { + skip: process.platform !== "linux" || process.arch !== "x64" + ? "requires the real Linux x64 addon" + : false, +}, () => { + const root = temporaryRoot(); + const file = join(root, "entry"); + writeFileSync(file, "entry\n"); + + const directoryDiagnostics: unknown[] = []; + assert.throws(() => assertNativeDirectoryEntry( + join(root, "absent"), + "entry", + "file", + (phase, code, failure) => directoryDiagnostics.push({ phase, code, ...failure }), + )); + assert.deepEqual(directoryDiagnostics.at(-1), { + phase: "descriptor-operation", + code: "FAILED", + substep: "directory-open", + category: "missing-entry", + }); + + const missingDiagnostics: unknown[] = []; + assert.throws(() => assertNativeDirectoryEntry( + root, + "missing", + "file", + (phase, code, failure) => missingDiagnostics.push({ phase, code, ...failure }), + )); + assert.deepEqual(missingDiagnostics.at(-1), { + phase: "descriptor-operation", + code: "FAILED", + substep: "addon-open", + category: "missing-entry", + }); + + const typeDiagnostics: unknown[] = []; + assert.throws(() => assertNativeDirectoryEntry( + root, + "entry", + "directory", + (phase, code, failure) => typeDiagnostics.push({ phase, code, ...failure }), + )); + assert.deepEqual(typeDiagnostics.at(-1), { + phase: "descriptor-operation", + code: "FAILED", + substep: "fstat-type", + category: "type-mismatch", + }); +}); + +test("Linux EINVAL directory open fallback retains the native descriptor-relative entry proof", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const root = temporaryRoot(); + writeFileSync(join(root, "config.json"), "{}\n"); + setNativeDirectoryOpenTestHook(phase => { + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + }, true); + + assert.doesNotThrow(() => assertNativeDirectoryEntry(root, "config.json", "file")); +}); + +test("Linux consecutive EINVAL directory opens reach the read-only pinned fallback", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const root = temporaryRoot(); + writeFileSync(join(root, "config.json"), "{}\n"); + let readOnlyFallbacks = 0; + setNativeDirectoryOpenTestHook(phase => { + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + if (phase === "before-directory-fallback-open") { + throw Object.assign(new Error("injected directory-open failure"), { code: "EINVAL" }); + } + if (phase === "before-readonly-fallback-open") readOnlyFallbacks += 1; + }, true); + + assert.doesNotThrow(() => assertNativeDirectoryEntry(root, "config.json", "file")); + assert.equal(readOnlyFallbacks, 1); +}); + +test("Linux EINVAL directory open fallback rejects named-directory replacement", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const parent = temporaryRoot(); + const root = join(parent, "config"); + const detached = join(parent, "detached"); + mkdirSync(root); + writeFileSync(join(root, "config.json"), "{}\n"); + setNativeDirectoryOpenTestHook(phase => { + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + if (phase === "before-directory-fallback-open") { + throw Object.assign(new Error("injected directory-open failure"), { code: "EINVAL" }); + } + if (phase === "after-fallback-open") { + renameSync(root, detached); + mkdirSync(root); + writeFileSync(join(root, "config.json"), "{}\n"); + } + }, true); + + assert.throws( + () => assertNativeDirectoryEntry(root, "config.json", "file"), + /entry changed during descriptor fallback/, + ); +}); + +test("Linux EINVAL directory open fallback rejects a symlink substituted after open", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const parent = temporaryRoot(); + const root = join(parent, "config"); + const detached = join(parent, "detached"); + mkdirSync(root); + writeFileSync(join(root, "config.json"), "{}\n"); + setNativeDirectoryOpenTestHook(phase => { + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + if (phase === "before-directory-fallback-open") { + throw Object.assign(new Error("injected directory-open failure"), { code: "EINVAL" }); + } + if (phase === "after-fallback-open") { + renameSync(root, detached); + symlinkSync(detached, root, "dir"); + } + }, true); + + assert.throws( + () => assertNativeDirectoryEntry(root, "config.json", "file"), + /entry changed during descriptor fallback/, + ); +}); + +test("native directory open does not accept non-EINVAL errors through the fallback", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const root = temporaryRoot(); + writeFileSync(join(root, "config.json"), "{}\n"); + let phases = 0; + setNativeDirectoryOpenTestHook(phase => { + phases += 1; + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected denied open"), { code: "EACCES" }); + } + }, true); + + assert.throws(() => assertNativeDirectoryEntry(root, "config.json", "file"), /injected denied open/); + assert.equal(phases, 1); +}); + +test("non-EINVAL directory fallback failures never reach the read-only fallback", { + skip: process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64") + ? "requires a real Linux kernel and packaged Linux addon" + : false, +}, () => { + const root = temporaryRoot(); + writeFileSync(join(root, "config.json"), "{}\n"); + let readOnlyFallbackObserved = false; + setNativeDirectoryOpenTestHook(phase => { + if (phase === "before-primary-open") { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + if (phase === "before-directory-fallback-open") { + throw Object.assign(new Error("injected denied directory open"), { code: "EACCES" }); + } + if (phase === "before-readonly-fallback-open") readOnlyFallbackObserved = true; + }, true); + + assert.throws( + () => assertNativeDirectoryEntry(root, "config.json", "file"), + /injected denied directory open/, + ); + assert.equal(readOnlyFallbackObserved, false); +}); + +test("read-only fallback rejects a non-directory descriptor", { + skip: process.platform !== "linux" ? "requires Linux directory open flags" : false, +}, () => { + const root = temporaryRoot(); + const file = join(root, "config.json"); + writeFileSync(file, "{}\n"); + setNativeDirectoryOpenTestHook(() => undefined, true); + + assert.throws(() => openAuthorityDirectoryNoFollow(root, flags => { + if (flags === (constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)) { + throw Object.assign(new Error("injected strict-open failure"), { code: "EINVAL" }); + } + if (flags === (constants.O_RDONLY | constants.O_DIRECTORY)) { + throw Object.assign(new Error("injected directory-open failure"), { code: "EINVAL" }); + } + return openSync(file, flags); + }), /entry changed during descriptor fallback/); +}); + test("native Darwin child uses inherited fd 3 without changing either cwd", { skip: process.platform !== "darwin" ? "requires a real Darwin kernel and packaged Darwin addon" : false, }, () => { diff --git a/packages/cli/src/commands/connectCommand.test.ts b/packages/cli/src/commands/connectCommand.test.ts new file mode 100644 index 000000000..2420b7d5e --- /dev/null +++ b/packages/cli/src/commands/connectCommand.test.ts @@ -0,0 +1,401 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseProprDesktopDiscovery } from "@propr/shared"; +import { + CONNECT_STATUS_EXIT, + probeConnectDiscovery, + readBoundedBody, + resolveConnectStatus, + unavailableRootAuthorityStatus, +} from "./connectCommand.js"; +import type { OrchestratorConfig } from "../orchestrator/types.js"; + +const IDENTITY = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const ENDPOINT = "https://t-abc123.propr.dev"; + +function cfg(overrides: Partial = {}): OrchestratorConfig { + return { + uiPublicApiUrl: ENDPOINT, + proprInstanceId: "abc123", + uiTunnelEnabled: true, + ...overrides, + } as OrchestratorConfig; +} + +function discovery(overrides: Record = {}): Record { + return { + schemaVersion: 1, + product: "ProPR", + canonicalEndpoint: ENDPOINT, + publicInstanceIdentity: IDENTITY, + version: "0.8.15", + apiCompatibility: "2026-06-27", + uiCompatibility: "2026-06-27", + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + ...overrides, + }; +} + +function jsonFetch(body = discovery()): typeof fetch { + return async () => new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("Connect status exposes stable exit semantics", () => { + assert.deepEqual(CONNECT_STATUS_EXIT, { + ready: 0, + internalFailure: 1, + notReady: 0, + incompatible: 2, + invalidConfig: 1, + timeout: 0, + }); +}); + +test("unavailable root authority fails closed before API readiness", () => { + const status = unavailableRootAuthorityStatus(); + assert.equal(status.status, "invalidConfig"); + assert.equal(status.apiReady, false); + assert.equal(status.configured, false); + assert.equal(status.publicInstanceIdentity, null); + assert.deepEqual(status.reasonCodes, ["ACL_DIAGNOSTIC_UNAVAILABLE"]); +}); + +test("missing, disabled, and stopped tunnel states do not probe", async () => { + let probes = 0; + const fetchImpl = (async () => { + probes += 1; + throw new Error("must not probe"); + }) as typeof fetch; + + const missing = await resolveConnectStatus({ + cfg: cfg({ uiPublicApiUrl: undefined, proprInstanceId: undefined, uiTunnelEnabled: false }), + sidecarRunning: false, + publicInstanceIdentity: IDENTITY, + fetchImpl, + }); + assert.equal(missing.status, "notReady"); + assert.deepEqual(missing.reasonCodes, ["NOT_CONFIGURED", "TUNNEL_DISABLED"]); + + const disabled = await resolveConnectStatus({ + cfg: cfg({ uiTunnelEnabled: false }), sidecarRunning: false, publicInstanceIdentity: IDENTITY, fetchImpl, + }); + assert.deepEqual(disabled.reasonCodes, ["TUNNEL_DISABLED"]); + + const stopped = await resolveConnectStatus({ + cfg: cfg(), sidecarRunning: false, publicInstanceIdentity: IDENTITY, fetchImpl, + }); + assert.deepEqual(stopped.reasonCodes, ["SIDECAR_NOT_RUNNING"]); + assert.equal(probes, 0); +}); + +test("ready requires matching canonical origin, identity, and compatibility", async () => { + const status = await resolveConnectStatus({ + cfg: cfg(), sidecarRunning: true, publicInstanceIdentity: IDENTITY, fetchImpl: jsonFetch(), + }); + assert.equal(status.status, "ready"); + assert.equal(status.apiReady, true); + assert.equal(status.restartRequired, false); + assert.equal(status.compatibility, "2026-06-27"); + assert.equal(status.version, "0.8.15"); + assert.deepEqual(status.reasonCodes, []); +}); + +test("same API identity with stale incompatible runtime origin requires restart", async () => { + const status = await resolveConnectStatus({ + cfg: cfg(), + sidecarRunning: true, + publicInstanceIdentity: IDENTITY, + fetchImpl: jsonFetch(discovery({ + canonicalEndpoint: null, + apiCompatibility: "2025-01-01", + desktopAuthentication: { + protocolVersion: 2, + browserPairing: false, + instanceBearerTokens: false, + socketIoBearerAuthentication: false, + }, + })), + }); + assert.equal(status.status, "notReady"); + assert.equal(status.apiReady, false); + assert.equal(status.restartRequired, true); + assert.deepEqual(status.reasonCodes, ["ENDPOINT_MISMATCH", "RESTART_REQUIRED"]); +}); + +test("a reassigned or stale endpoint cannot pass an identity mismatch", async () => { + const status = await resolveConnectStatus({ + cfg: cfg(), + sidecarRunning: true, + publicInstanceIdentity: IDENTITY, + fetchImpl: jsonFetch(discovery({ + canonicalEndpoint: null, + publicInstanceIdentity: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + apiCompatibility: "2025-01-01", + desktopAuthentication: { + protocolVersion: 2, + browserPairing: false, + instanceBearerTokens: false, + socketIoBearerAuthentication: false, + }, + })), + }); + assert.equal(status.status, "notReady"); + assert.equal(status.apiReady, false); + assert.equal(status.restartRequired, false); + assert.deepEqual(status.reasonCodes, ["IDENTITY_MISMATCH"]); +}); + +test("old discovery compatibility has an incompatible result", async () => { + const status = await resolveConnectStatus({ + cfg: cfg(), + sidecarRunning: true, + publicInstanceIdentity: IDENTITY, + fetchImpl: jsonFetch(discovery({ apiCompatibility: "2025-01-01" })), + }); + assert.equal(status.status, "incompatible"); + assert.deepEqual(status.reasonCodes, ["API_INCOMPATIBLE"]); +}); + +test("ready requires every desktop authentication capability", async () => { + for (const capability of [ + "browserPairing", + "instanceBearerTokens", + "socketIoBearerAuthentication", + ] as const) { + const status = await resolveConnectStatus({ + cfg: cfg(), + sidecarRunning: true, + publicInstanceIdentity: IDENTITY, + fetchImpl: jsonFetch(discovery({ + desktopAuthentication: { + protocolVersion: 2, + browserPairing: capability !== "browserPairing", + instanceBearerTokens: capability !== "instanceBearerTokens", + socketIoBearerAuthentication: capability !== "socketIoBearerAuthentication", + }, + })), + }); + + assert.equal(status.status, "incompatible", capability); + assert.equal(status.apiReady, false, capability); + assert.deepEqual(status.reasonCodes, ["DESKTOP_AUTHENTICATION_UNSUPPORTED"], capability); + } +}); + +test("probe distinguishes timeout, non-JSON, and capped output", async () => { + const never = (() => new Promise(() => undefined)) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, never, 10), { kind: "timeout" }); + + const nonJson = (async () => new Response("no", { + headers: { "content-type": "text/html" }, + })) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, nonJson, 100), { kind: "invalid" }); + + const oversized = (async () => new Response("{}", { + headers: { + "content-type": "application/json", + "content-length": "9000", + }, + })) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, oversized, 100), { kind: "tooLarge" }); +}); + +test("the shared discovery parser requires every exact canonical field and capability", () => { + const parsed = parseProprDesktopDiscovery(discovery()); + assert.ok(parsed); + assert.equal(parsed.desktopAuthentication.protocolVersion, 2); + const topLevelKeys = Object.keys(discovery()); + for (const key of topLevelKeys) { + const candidate = discovery(); + delete candidate[key]; + assert.equal(parseProprDesktopDiscovery(candidate), null, `missing ${key}`); + } + for (const key of [ + "protocolVersion", + "browserPairing", + "instanceBearerTokens", + "socketIoBearerAuthentication", + ]) { + const candidate = discovery(); + const capabilities = { ...(candidate.desktopAuthentication as Record) }; + delete capabilities[key]; + candidate.desktopAuthentication = capabilities; + assert.equal(parseProprDesktopDiscovery(candidate), null, `missing desktopAuthentication.${key}`); + } + + for (const invalid of [ + discovery({ extra: true }), + discovery({ version: "v0.8.15" }), + discovery({ version: "00.8.15" }), + discovery({ version: "0.8" }), + discovery({ apiCompatibility: "2026-6-27" }), + discovery({ apiCompatibility: "2026-02-30" }), + discovery({ uiCompatibility: "" }), + discovery({ canonicalEndpoint: `${ENDPOINT}/` }), + discovery({ publicInstanceIdentity: IDENTITY.toUpperCase() }), + discovery({ desktopAuthentication: { + protocolVersion: 2, + browserPairing: 1, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + } }), + discovery({ desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + omittedCapabilityReplacement: true, + } }), + ]) assert.equal(parseProprDesktopDiscovery(invalid), null); + + assert.equal(parseProprDesktopDiscovery(discovery({ desktopAuthentication: { + protocolVersion: 1, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + } })), null, "legacy desktop authentication protocol v1 must fail closed"); +}); + +function neverEndingResponse( + status: number, + headers: Readonly>, + onCancel: () => void, +): Response { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + }, + cancel() { + onCancel(); + }, + }), { + status, + headers: Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => ( + entry[1] !== undefined + ))), + }); +} + +test("every early response rejection cancels a never-ending body", async () => { + for (const branch of [ + { status: 404, headers: { "content-type": "application/json" }, kind: "unsupported" }, + { status: 503, headers: { "content-type": "application/json" }, kind: "unreachable" }, + { status: 200, headers: { "content-type": "text/html" }, kind: "invalid" }, + { + status: 200, + headers: { "content-type": "application/json", "content-length": "9000" }, + kind: "tooLarge", + }, + ] as const) { + let canceled = 0; + const fetchImpl = (async () => neverEndingResponse( + branch.status, + branch.headers, + () => { canceled += 1; }, + )) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, fetchImpl, 100), { kind: branch.kind }); + assert.equal(canceled, 1, branch.kind); + } +}); + +test("fatal UTF-8, malformed JSON, and incomplete schema are invalid rather than unreachable", async () => { + const invalidUtf8 = (async () => new Response(Uint8Array.from([0xc3, 0x28]), { + headers: { "content-type": "application/json" }, + })) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, invalidUtf8, 100), { kind: "invalid" }); + + for (const body of ["{", JSON.stringify({ schemaVersion: 1, product: "ProPR" })]) { + let signal: AbortSignal | undefined; + const fetchImpl = (async (_url, init) => { + signal = init?.signal ?? undefined; + return new Response(body, { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, fetchImpl, 100), { kind: "invalid" }); + assert.equal(signal?.aborted, true); + } +}); + +test("timeout cancels an active body and late-settling responses are canceled on arrival", async () => { + let activeCanceled = 0; + const active = (async () => neverEndingResponse( + 200, + { "content-type": "application/json" }, + () => { activeCanceled += 1; }, + )) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, active, 10), { kind: "timeout" }); + assert.equal(activeCanceled, 1); + + for (const status of [200, 404, 503]) { + let settle!: (response: Response) => void; + let lateCanceled = 0; + const late = (() => new Promise((resolve) => { settle = resolve; })) as typeof fetch; + assert.deepEqual(await probeConnectDiscovery(ENDPOINT, late, 10), { kind: "timeout" }); + settle(neverEndingResponse(status, { "content-type": "text/html" }, () => { lateCanceled += 1; })); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(lateCanceled, 1, `late status ${status}`); + } +}); + +test("abort between reader acquisition and listener installation cancels without reading or leaking", async () => { + const controller = new AbortController(); + let reads = 0; + let cancellations = 0; + let releases = 0; + let listeners = 0; + const originalAdd = controller.signal.addEventListener.bind(controller.signal); + const originalRemove = controller.signal.removeEventListener.bind(controller.signal); + controller.signal.addEventListener = ((...args: Parameters) => { + listeners += 1; + return originalAdd(...args); + }) as AbortSignal["addEventListener"]; + controller.signal.removeEventListener = ((...args: Parameters) => { + listeners -= 1; + return originalRemove(...args); + }) as AbortSignal["removeEventListener"]; + + const response = { + headers: new Headers({ "content-type": "application/json" }), + body: { + getReader() { + controller.abort(); + return { + cancel: async () => { cancellations += 1; }, + read: async () => { reads += 1; return { done: true, value: undefined }; }, + releaseLock: () => { releases += 1; }, + }; + }, + }, + } as unknown as Response; + + await assert.rejects(() => readBoundedBody(response, controller.signal), /aborted/); + assert.equal(reads, 0); + assert.equal(cancellations, 1); + assert.equal(releases, 1); + assert.equal(listeners, 0); +}); + +test("serialized JSON is bounded and cannot include local secret sentinels", async () => { + const secret = "cloudflare-token-SENTINEL"; + const status = await resolveConnectStatus({ + cfg: cfg({ uiTunnelToken: secret }), + sidecarRunning: true, + publicInstanceIdentity: IDENTITY, + fetchImpl: jsonFetch(), + }); + const output = JSON.stringify(status); + assert.ok(output.length < 2048); + assert.equal(output.includes(secret), false); + assert.deepEqual(Object.keys(status), [ + "schemaVersion", "status", "canonicalEndpoint", "publicInstanceIdentity", + "configured", "enabled", "sidecarRunning", "apiReady", "restartRequired", + "compatibility", "version", "reasonCodes", + ]); +}); diff --git a/packages/cli/src/commands/connectCommand.ts b/packages/cli/src/commands/connectCommand.ts new file mode 100644 index 000000000..4d17cc92d --- /dev/null +++ b/packages/cli/src/commands/connectCommand.ts @@ -0,0 +1,453 @@ +import { Command } from "commander"; +import { + PROPR_CONNECT_DISCOVERY_MAX_BYTES, + PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + canonicalProprProxyUrl, + evaluateProprApiCompatibility, + parseProprDesktopDiscoveryJson, + type ProprDesktopDiscovery, +} from "@propr/shared"; +import { prepareConnectHostConfig } from "../orchestrator/index.js"; +import type { OrchestratorConfig } from "../orchestrator/types.js"; +import { + ConnectRootError, + PublicInstanceIdentityError, + readTrustedConnectTunnelOverride, + readSnapshotPublicInstanceIdentity, + withOwnedConnectRootSnapshot, +} from "../connectIdentity.js"; +import { WindowsAuthorityInspectionError } from "../connectRootAuthority.js"; + +export const CONNECT_STATUS_EXIT = { + ready: 0, + internalFailure: 1, + notReady: 0, + incompatible: 2, + invalidConfig: 1, + timeout: 0, +} as const; + +export type ConnectStatusKind = keyof typeof CONNECT_STATUS_EXIT; +export type ConnectStatusReasonCode = + | "NOT_CONFIGURED" + | "TUNNEL_DISABLED" + | "SIDECAR_NOT_RUNNING" + | "API_UNREACHABLE" + | "API_TIMEOUT" + | "DISCOVERY_UNSUPPORTED" + | "DISCOVERY_INVALID" + | "DISCOVERY_TOO_LARGE" + | "API_INCOMPATIBLE" + | "DESKTOP_AUTHENTICATION_UNSUPPORTED" + | "IDENTITY_MISMATCH" + | "ENDPOINT_MISMATCH" + | "RESTART_REQUIRED" + | "INVALID_ROOT" + | "INVALID_ENDPOINT" + | "IDENTITY_UNAVAILABLE" + | "INTERNAL_FAILURE" + | "ACL_DIAGNOSTIC_UNAVAILABLE"; + +export interface ConnectStatusDocument { + schemaVersion: typeof PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION; + status: ConnectStatusKind; + canonicalEndpoint: string | null; + publicInstanceIdentity: string | null; + configured: boolean; + enabled: boolean; + sidecarRunning: boolean; + apiReady: boolean; + restartRequired: boolean; + compatibility: string | null; + version: string | null; + reasonCodes: ConnectStatusReasonCode[]; +} + +/** An unavailable root-authority diagnostic is a hard readiness boundary. */ +export function unavailableRootAuthorityStatus(): ConnectStatusDocument { + return baseDocument("invalidConfig", { reasonCodes: ["ACL_DIAGNOSTIC_UNAVAILABLE"] }); +} + +type DiscoveryProbeResult = + | { kind: "ok"; discovery: ProprDesktopDiscovery } + | { kind: "timeout" } + | { kind: "unreachable" } + | { kind: "unsupported" } + | { kind: "invalid" } + | { kind: "tooLarge" }; + +function baseDocument( + status: ConnectStatusKind, + overrides: Partial = {}, +): ConnectStatusDocument { + return { + schemaVersion: PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + status, + canonicalEndpoint: null, + publicInstanceIdentity: null, + configured: false, + enabled: false, + sidecarRunning: false, + apiReady: false, + restartRequired: false, + compatibility: null, + version: null, + reasonCodes: [], + ...overrides, + }; +} + +/** The fixed failure document for a missing, empty, or ambiguous explicit root. */ +export function invalidConnectRootStatus(): ConnectStatusDocument { + return baseDocument("invalidConfig", { reasonCodes: ["INVALID_ROOT"] }); +} + +function parseContentLength(response: Response): number | null { + const raw = response.headers.get("content-length"); + if (raw === null) return null; + if (!/^\d{1,10}$/.test(raw)) return Number.POSITIVE_INFINITY; + return Number(raw); +} + +function cancelResponseBody(response: Response): void { + try { + const cancellation = response.body?.cancel(); + if (cancellation) void cancellation.catch(() => undefined); + } catch { + // Cancellation is best-effort at the transport adapter boundary; the + // owning AbortController is also aborted before probe return. + } +} + +export type BoundedBodyResult = + | { kind: "ok"; body: string } + | { kind: "tooLarge" } + | { kind: "invalid" }; + +export async function readBoundedBody(response: Response, signal: AbortSignal): Promise { + const declaredLength = parseContentLength(response); + if (declaredLength !== null && declaredLength > PROPR_CONNECT_DISCOVERY_MAX_BYTES) { + cancelResponseBody(response); + return { kind: "tooLarge" }; + } + if (!response.body) return { kind: "ok", body: "" }; + + if (signal.aborted) { + cancelResponseBody(response); + throw new Error("Connect discovery response was aborted"); + } + const reader = response.body.getReader(); + let canceled = false; + const abort = () => { + if (canceled) return; + canceled = true; + try { + void reader.cancel().catch(() => undefined); + } catch { + // The stream may already be closed or errored. + } + }; + // Check on both sides of listener installation. AbortSignal dispatch is + // synchronous, so after the second check either this listener observed the + // abort or it remains installed for the subsequent body read. + if (signal.aborted) abort(); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + if (signal.aborted) throw new Error("Connect discovery response was aborted"); + while (true) { + const { done, value } = await reader.read(); + if (signal.aborted) throw new Error("Connect discovery response was aborted"); + if (done) break; + if (!value) continue; + length += value.byteLength; + if (length > PROPR_CONNECT_DISCOVERY_MAX_BYTES) { + abort(); + return { kind: "tooLarge" }; + } + chunks.push(value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return { kind: "ok", body: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }; + } catch { + cancelResponseBody(response); + return { kind: "invalid" }; + } + } finally { + signal.removeEventListener("abort", abort); + try { + reader.releaseLock(); + } catch { + // A transport may keep cancellation pending briefly; the listener is + // already detached and cancellation remains owned by the reader. + } + } +} + +async function performDiscoveryFetch( + canonicalEndpoint: string, + fetchImpl: typeof fetch, + signal: AbortSignal, +): Promise { + try { + const response = await fetchImpl(`${canonicalEndpoint}/api/desktop/discovery`, { + signal, + redirect: "manual", + headers: { Accept: "application/json" }, + }); + if (signal.aborted) { + cancelResponseBody(response); + return { kind: "timeout" }; + } + if (response.status === 404) { + cancelResponseBody(response); + return { kind: "unsupported" }; + } + if (!response.ok) { + cancelResponseBody(response); + return { kind: "unreachable" }; + } + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json") { + cancelResponseBody(response); + return { kind: "invalid" }; + } + const bodyResult = await readBoundedBody(response, signal); + if (bodyResult.kind !== "ok") return { kind: bodyResult.kind }; + const discovery = parseProprDesktopDiscoveryJson(bodyResult.body); + if (!discovery) cancelResponseBody(response); + return discovery ? { kind: "ok", discovery } : { kind: "invalid" }; + } catch { + return signal.aborted ? { kind: "timeout" } : { kind: "unreachable" }; + } +} + +/** One bounded, redirect-free probe with a deadline that does not trust fetch to abort itself. */ +export async function probeConnectDiscovery( + canonicalEndpoint: string, + fetchImpl: typeof fetch = fetch, + timeoutMs = 5000, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve({ kind: "timeout" }); + }, timeoutMs); + }); + try { + return await Promise.race([ + performDiscoveryFetch(canonicalEndpoint, fetchImpl, controller.signal), + timeout, + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + controller.abort(); + } +} + +export interface ResolveConnectStatusOptions { + cfg: Pick; + sidecarRunning: boolean; + publicInstanceIdentity: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +export interface LocalConnectStatusDependencies { + fetchImpl?: typeof fetch; + inspectTunnel?: ( + cfg: OrchestratorConfig, + ) => { kind: 'ok'; running: boolean } | { kind: 'internalFailure' }; + /** @internal Fixed smoke-only phase outcomes; never carries errors or native evidence. */ + reportSmokeDiagnostic?: ( + phase: 'authority-inspection' | 'status-resolution', + code: 'STARTED' | 'PASSED' | 'FAILED', + ) => void; +} + +/** Pure status state machine used by the CLI wiring and deterministic tests. */ +export async function resolveConnectStatus({ + cfg, + sidecarRunning, + publicInstanceIdentity, + fetchImpl = fetch, + timeoutMs = 5000, +}: ResolveConnectStatusOptions): Promise { + const configuredValue = cfg.uiPublicApiUrl; + const canonicalEndpoint = canonicalProprProxyUrl(configuredValue) ?? null; + const enabled = Boolean(cfg.uiTunnelEnabled); + const common = { + canonicalEndpoint, + publicInstanceIdentity, + configured: canonicalEndpoint !== null, + enabled, + sidecarRunning, + }; + + if ((configuredValue && !canonicalEndpoint) || (cfg.proprInstanceId && !canonicalEndpoint)) { + return baseDocument("invalidConfig", { ...common, reasonCodes: ["INVALID_ENDPOINT"] }); + } + + const reasons: ConnectStatusReasonCode[] = []; + if (!canonicalEndpoint) reasons.push("NOT_CONFIGURED"); + if (!enabled) reasons.push("TUNNEL_DISABLED"); + if (enabled && !sidecarRunning) reasons.push("SIDECAR_NOT_RUNNING"); + if (reasons.length > 0 || !canonicalEndpoint) { + return baseDocument("notReady", { ...common, reasonCodes: reasons }); + } + + const probe = await probeConnectDiscovery(canonicalEndpoint, fetchImpl, timeoutMs); + if (probe.kind === "timeout") { + return baseDocument("timeout", { ...common, reasonCodes: ["API_TIMEOUT"] }); + } + if (probe.kind === "unreachable") { + return baseDocument("notReady", { ...common, reasonCodes: ["API_UNREACHABLE"] }); + } + if (probe.kind === "unsupported") { + return baseDocument("incompatible", { ...common, reasonCodes: ["DISCOVERY_UNSUPPORTED"] }); + } + if (probe.kind === "invalid" || probe.kind === "tooLarge") { + return baseDocument("incompatible", { + ...common, + reasonCodes: [probe.kind === "tooLarge" ? "DISCOVERY_TOO_LARGE" : "DISCOVERY_INVALID"], + }); + } + + const remoteMetadata = { + compatibility: probe.discovery.apiCompatibility, + version: probe.discovery.version, + }; + if (probe.discovery.publicInstanceIdentity !== publicInstanceIdentity) { + return baseDocument("notReady", { + ...common, + ...remoteMetadata, + reasonCodes: ["IDENTITY_MISMATCH"], + }); + } + if (probe.discovery.canonicalEndpoint !== canonicalEndpoint) { + return baseDocument("notReady", { + ...common, + ...remoteMetadata, + restartRequired: true, + reasonCodes: ["ENDPOINT_MISMATCH", "RESTART_REQUIRED"], + }); + } + const compatibility = evaluateProprApiCompatibility(probe.discovery); + if (!compatibility.compatible) { + return baseDocument("incompatible", { + ...common, + ...remoteMetadata, + reasonCodes: ["API_INCOMPATIBLE"], + }); + } + const authentication = probe.discovery.desktopAuthentication; + if ( + !authentication.browserPairing + || !authentication.instanceBearerTokens + || !authentication.socketIoBearerAuthentication + ) { + return baseDocument("incompatible", { + ...common, + ...remoteMetadata, + reasonCodes: ["DESKTOP_AUTHENTICATION_UNSUPPORTED"], + }); + } + return baseDocument("ready", { ...common, ...remoteMetadata, apiReady: true }); +} + +export async function getLocalConnectStatus( + root: string | undefined, + dependencies: LocalConnectStatusDependencies = {}, +): Promise { + let phase: 'authority-inspection' | 'status-resolution' = 'authority-inspection'; + dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); + try { + const prepared = await prepareConnectHostConfig(); + const local = await withOwnedConnectRootSnapshot(root, async (snapshot) => { + const cfg = prepared.resolveSnapshot(snapshot); + const tunnelEnabledOverride = await readTrustedConnectTunnelOverride(snapshot.requestedRoot); + const effectiveCfg = tunnelEnabledOverride === undefined + ? cfg + : { ...cfg, uiTunnelEnabled: tunnelEnabledOverride }; + // Status is discovery, not setup: never create/repair identity state or + // invoke a privileged Windows protection operation from this path. + const publicInstanceIdentity = await readSnapshotPublicInstanceIdentity(snapshot.identityDirectory); + const sidecarInspection = (dependencies.inspectTunnel ?? prepared.inspectTunnel)(effectiveCfg); + return { + kind: "verified" as const, + cfg: { + uiPublicApiUrl: effectiveCfg.uiPublicApiUrl, + proprInstanceId: effectiveCfg.proprInstanceId, + uiTunnelEnabled: effectiveCfg.uiTunnelEnabled, + }, + publicInstanceIdentity, + sidecarInspection, + }; + }, { parseEnvFile: prepared.parseEnvFile }); + dependencies.reportSmokeDiagnostic?.(phase, 'PASSED'); + phase = 'status-resolution'; + dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); + if (local.sidecarInspection.kind === "internalFailure") { + const result = baseDocument("internalFailure", { reasonCodes: ["INTERNAL_FAILURE"] }); + dependencies.reportSmokeDiagnostic?.(phase, 'PASSED'); + return result; + } + const result = await resolveConnectStatus({ + cfg: local.cfg, + sidecarRunning: local.sidecarInspection.running, + publicInstanceIdentity: local.publicInstanceIdentity, + fetchImpl: dependencies.fetchImpl, + }); + dependencies.reportSmokeDiagnostic?.(phase, 'PASSED'); + return result; + } catch (error) { + dependencies.reportSmokeDiagnostic?.(phase, 'FAILED'); + if (error instanceof WindowsAuthorityInspectionError) return unavailableRootAuthorityStatus(); + if (error instanceof ConnectRootError) { + return invalidConnectRootStatus(); + } + if (error instanceof PublicInstanceIdentityError) { + return baseDocument("invalidConfig", { reasonCodes: ["IDENTITY_UNAVAILABLE"] }); + } + return baseDocument("internalFailure", { reasonCodes: ["INTERNAL_FAILURE"] }); + } +} + +function printHumanStatus(document: ConnectStatusDocument): void { + console.log(`Connect status: ${document.status}`); + console.log(` endpoint: ${document.canonicalEndpoint ?? "not configured"}`); + console.log(` enabled: ${document.enabled ? "yes" : "no"}`); + console.log(` sidecar: ${document.sidecarRunning ? "running" : "stopped"}`); + console.log(` API ready: ${document.apiReady ? "yes" : "no"}`); + if (document.restartRequired) console.log(" restart required: yes"); + if (document.reasonCodes.length > 0) console.log(` reasons: ${document.reasonCodes.join(", ")}`); +} + +export function createConnectCommand(): Command { + const command = new Command("connect").description("Discover the local ProPR Connect endpoint safely"); + command + .command("status") + .description("Print the versioned secret-free desktop discovery contract") + .option("--root ", "Explicit caller-owned stack root (required)") + .option("-j, --json", "Emit one bounded JSON document on stdout") + .action(async (options: { root?: string; json?: boolean }) => { + const document = await getLocalConnectStatus(options.root); + if (options.json) console.log(JSON.stringify(document)); + else printHumanStatus(document); + if (document.status !== "ready") { + console.error(`ProPR Connect discovery: ${document.status}.`); + } + process.exitCode = CONNECT_STATUS_EXIT[document.status]; + }); + return command; +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index f105b390a..8ce767dd1 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -25,6 +25,7 @@ export { createStartCommand } from "./startCommand.js"; export { createStackStatusCommand, createStopCommand } from "./stackCommands.js"; export { createUiCommand, createDocsCommand } from "./uiDocsCommands.js"; export { createTunnelCommand } from "./tunnelCommand.js"; +export { createConnectCommand } from "./connectCommand.js"; export { createTankCommand } from "./tankCommands.js"; export { createRelayCommand } from "./relayCommands.js"; export { createRuntimeCommand } from "./runtimeCommands.js"; diff --git a/packages/cli/src/commands/initStack.test.ts b/packages/cli/src/commands/initStack.test.ts index 470be49a6..e6904e618 100644 --- a/packages/cli/src/commands/initStack.test.ts +++ b/packages/cli/src/commands/initStack.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -58,13 +59,15 @@ test("stack scaffolding does not change the chosen project root mode", async () } }); -test("stack generation includes detected credentials in the published environment", async () => { - const root = mkdtempSync(join(tmpdir(), "propr-private-stack-")); - const home = mkdtempSync(join(tmpdir(), "propr-private-home-")); +test("stack generation remains operational and publishes its environment and identity", async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "propr-private-stack-"))); + const home = realpathSync.native(mkdtempSync(join(tmpdir(), "propr-private-home-"))); const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; try { mkdirSync(join(home, ".claude")); process.env.HOME = home; + process.env.USERPROFILE = home; const result = await scaffoldStack( { root }, @@ -77,14 +80,43 @@ test("stack generation includes detected credentials in the published environmen assert.ok(envLines.includes("NODE_ENV=production")); assert.ok(!envLines.includes("NODE_ENV=development")); assert.ok(envLines.includes(`HOST_CLAUDE_DIR=${join(home, ".claude")}`)); + assert.match( + readFileSync(join(root, "data", "public-instance-identity.json"), "utf-8"), + /"publicInstanceIdentity"/, + ); } finally { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); } }); +test("Windows stack scaffolding does not require discovery authority", async () => { + if (process.platform !== "win32") return; + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "propr-windows-stack-"))); + try { + writeFileSync(join(root, ".env"), "SESSION_SECRET=existing\nNODE_ENV=production\n"); + const result = await scaffoldStack( + { root }, + { persistStackRoot: async () => undefined }, + ); + + assert.equal(result.envSkipped, true); + assert.deepEqual(result.dirsCreated.filter((name) => ["data", "logs", "repos"].includes(name)), [ + "data", "logs", "repos", + ]); + assert.match( + readFileSync(join(root, "data", "public-instance-identity.json"), "utf-8"), + /"publicInstanceIdentity"/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("packaged runtime materialization leaves the source template reusable", () => { const sourceTemplate = "LOG_LEVEL=debug\nNODE_ENV=development\n"; diff --git a/packages/cli/src/commands/initStack.ts b/packages/cli/src/commands/initStack.ts index 71fa7ea37..bd2d6e326 100644 --- a/packages/cli/src/commands/initStack.ts +++ b/packages/cli/src/commands/initStack.ts @@ -9,7 +9,7 @@ import { Command } from "commander"; import { randomBytes } from "node:crypto"; -import { existsSync, chmodSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, chmodSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { homedir } from "node:os"; @@ -20,6 +20,7 @@ import { secureExistingPrivateFile, writePrivateFileAtomic, } from "../utils/privateFilesystem.js"; +import { getOrCreatePublicInstanceIdentity } from "../connectIdentity.js"; export function materializeSessionSecret( template: string, @@ -171,16 +172,26 @@ export async function scaffoldStack( for (const sub of ["data", "logs", "repos"]) { const dir = join(rootDir, sub); const created = !existsSync(dir); - ensurePrivateDirectory(dir); + await ensurePrivateDirectory(dir); (created ? result.dirsCreated : result.dirsSkipped).push(sub); } + // The public installation identity belongs to the durable data boundary, not + // .env or a tunnel credential. Re-scaffolding/upgrading preserves it; replacing + // the stack data creates a fresh identity on the next initialization. + // macOS commonly spells its temporary-directory ancestor as /var even + // though the already-created root is canonically beneath /private/var. + // Canonicalize the root, then append the literal data entry so the identity + // layer still observes and rejects a symlink at data itself. + const canonicalRootDir = realpathSync.native(rootDir); + await getOrCreatePublicInstanceIdentity(join(canonicalRootDir, "data")); + // 2. Load the environment content that will be used below. const envExists = existsSync(envPath); let envContent: string; let shouldWriteEnv = false; if (envExists && !options.force) { - secureExistingPrivateFile(envPath); + await secureExistingPrivateFile(envPath); envContent = readFileSync(envPath, "utf-8"); result.envSkipped = true; const nodeEnv = envContent.match(/^\s*(?:export\s+)?NODE_ENV\s*=\s*([^#\r\n]*)/m)?.[1] @@ -202,9 +213,9 @@ export async function scaffoldStack( materializeSessionSecret(readFileSync(example, "utf-8")), ); if (options.force && envExists) { - secureExistingPrivateFile(envPath); + await secureExistingPrivateFile(envPath); const bakPath = `${envPath}.bak`; - writePrivateFileAtomic(bakPath, readFileSync(envPath), { secureParent: false }); + await writePrivateFileAtomic(bakPath, readFileSync(envPath), { secureParent: false }); result.envBackedUp = true; } shouldWriteEnv = true; @@ -234,10 +245,9 @@ export async function scaffoldStack( result.pendingCredentials = toAppend; if (shouldWriteEnv) { - writePrivateFileAtomic(envPath, envContent, { secureParent: false }); + await writePrivateFileAtomic(envPath, envContent, { secureParent: false }); result.envCreated = true; } - // 3b. When Vibe is in play, pre-create its prompt-cache dir so spawned Vibe // agent containers can bind-mount a writable host directory. Creating it // here (owned by the invoking user) avoids Docker auto-creating it as diff --git a/packages/cli/src/commands/setup/agentHostActions.ts b/packages/cli/src/commands/setup/agentHostActions.ts new file mode 100644 index 000000000..1cf470714 --- /dev/null +++ b/packages/cli/src/commands/setup/agentHostActions.ts @@ -0,0 +1,65 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import type { AgentSetupActions } from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; +import { localhostServiceUrl } from "../../utils/dockerPort.js"; + +/** Bind the portable agent setup engine to the CLI API and Docker launcher. */ +export function createDefaultAgentSetupActions(configManager?: ConfigManager): AgentSetupActions { + const localApiClient = async (rootDir: string): Promise => { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + const { createApiClient } = await import("../../api/client.js"); + return createApiClient({ baseUrl: localhostServiceUrl(cfg.apiPort) }); + }; + + return { + async listAgents(rootDir) { + const { listAgents } = await import("../../api/agents.js"); + return (await listAgents(await localApiClient(rootDir))).agents; + }, + async addAgent(rootDir, options) { + const { addAgent } = await import("../../api/agents.js"); + await addAgent(options, await localApiClient(rootDir)); + }, + async loginableAgents() { + const { loginableAgents } = await import("../agentValidation.js"); + return loginableAgents(); + }, + async loginAgent(rootDir, type) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { planAgentLogin } = await import("../agentValidation.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const temporaryRoot = mkdtempSync(join(tmpdir(), "propr-setup-login-")); + const workspaceDir = join(temporaryRoot, "workspace"); + mkdirSync(workspaceDir, { recursive: true, mode: 0o700 }); + try { + const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath); + if (error || !plan) return { available: false, success: false, detail: error }; + if (!orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim()) { + return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` }; + } + mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 }); + const result = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" }); + return result.status === 0 + ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` } + : { available: true, success: false, detail: `${type} login exited with code ${result.status ?? "?"}` }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }, + async validateAgents(rootDir, types) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { validateAgents } = await import("../agentValidation.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true }); + return rows.map((row) => ({ + type: row.type, + status: row.image.status === "ok" ? "ok" as const : row.image.status === "fail" ? "failed" as const : "skipped" as const, + detail: row.image.detail, + })); + }, + }; +} diff --git a/packages/cli/src/commands/setup/agents.ts b/packages/cli/src/commands/setup/agents.ts index f10dac354..ce6c1455a 100644 --- a/packages/cli/src/commands/setup/agents.ts +++ b/packages/cli/src/commands/setup/agents.ts @@ -1,294 +1,2 @@ -/** - * Agent enablement + image-based authentication for `propr setup`. - * - * This runs as a setup step *after the stack is up* (the backend must be - * reachable to read and write agent configuration). It does three things, each - * non-destructively: - * - * 1. Reads the agents already configured in the running backend. - * 2. Adds any *selected* agent whose type is not yet configured, seeding it - * from the shared {@link AGENT_DEFAULTS} metadata (alias + supported - * models). Existing agents are never disabled, deleted, or re-aliased — a - * re-run only fills in what is missing. - * 3. For selected agents that support an interactive image login (see - * {@link planAgentLogin}), offers to authenticate through the agent's - * Docker image and runs the login only for the ones the user confirms. - * - * Like the engine, this module is UI-agnostic: the side effects live behind the - * injectable {@link AgentSetupActions} seam (tests pass mocks so the flow runs - * without Docker, the network, or a TTY) and the single user decision is - * collected through the optional {@link AgentSetupParams.confirmLogin} callback - * (a missing callback means "authenticate nothing", the safe default). - */ - -import type { ConfigManager } from "../../config/index.js"; -import { AGENT_DEFAULTS, type AgentType } from "@propr/shared"; -import type { AddAgentOptions, AgentConfig } from "../../api/agents.js"; -import { localhostServiceUrl } from "../../utils/dockerPort.js"; - -/** Outcome of attempting to authenticate a single agent through its image. */ -export interface AgentLoginResult { - /** False when the agent has no usable image-login plan (nothing was run). */ - available: boolean; - /** True when an interactive login ran and exited successfully. */ - success: boolean; - /** Human-readable detail (error reason or status line). */ - detail?: string; -} - -export interface AgentConnectivityResult { - type: string; - status: "ok" | "failed" | "skipped"; - detail: string; -} - -/** - * The side effects the agent-setup step performs against the running stack. - * Defaults bind to the real backend API and orchestrator (see - * {@link createDefaultAgentSetupActions}); tests override any subset. - */ -export interface AgentSetupActions { - /** List the agents currently configured in the running backend. */ - listAgents(rootDir: string): Promise; - /** Add a new agent to the backend configuration. */ - addAgent(rootDir: string, options: AddAgentOptions): Promise; - /** Agent types that support an interactive image login (have a login plan). */ - loginableAgents(): Promise; - /** Authenticate one agent through its image; interactive (inherits stdio). */ - loginAgent(rootDir: string, type: string): Promise; - /** Run a live, image-only request that mirrors the worker credential mount. */ - validateAgents(rootDir: string, types: string[]): Promise; -} - -/** Inputs for {@link runAgentSetup}. */ -export interface AgentSetupParams { - rootDir: string; - /** Agent types the user selected earlier in the flow (pull/configure steps). */ - selectedAgents: string[]; - actions: AgentSetupActions; - /** - * Confirm which of the loginable candidates to authenticate now. Returns the - * subset to log in. Omitted (or returning an empty array) authenticates none. - */ - confirmLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; - onLog?(line: string): void; -} - -/** What the agent-setup step did, for the caller to render as a step status. */ -export interface AgentSetupOutcome { - /** Agent types newly added to the backend configuration. */ - added: string[]; - /** Selected agent types that were already configured (left untouched). */ - alreadyConfigured: string[]; - /** Agents that authenticated successfully through their image. */ - authenticated: string[]; - /** Agents the user chose to authenticate but whose login did not succeed. */ - authFailed: string[]; - /** Agents whose worker-image connectivity check returned a valid response. */ - validated: string[]; - /** Agents whose live image check failed or could not run. */ - validationFailed: string[]; - /** Exact recovery commands for agents that still need attention. */ - nextCommands: string[]; - /** Non-fatal problems encountered (surfaced as a warning by the caller). */ - errors: string[]; -} - -/** - * Enable the selected agents in the running backend and, on confirmation, - * authenticate the ones that support an image login. Never throws for expected - * conditions — every failure is captured in {@link AgentSetupOutcome.errors} so - * the caller can settle the step as a warning rather than aborting setup. - */ -export async function runAgentSetup(params: AgentSetupParams): Promise { - const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params; - const outcome: AgentSetupOutcome = { - added: [], - alreadyConfigured: [], - authenticated: [], - authFailed: [], - validated: [], - validationFailed: [], - nextCommands: [], - errors: [], - }; - - if (selectedAgents.length === 0) return outcome; - - // 1. Read the current backend configuration. Without it we cannot safely tell - // which agents are new, so a read failure stops here (nothing was changed). - let existing: AgentConfig[]; - try { - existing = await actions.listAgents(rootDir); - } catch (error) { - outcome.errors.push(`could not read backend agents: ${(error as Error).message}`); - return outcome; - } - - // 2. Add the selected agents that are not yet configured. Match by type so we - // never add a second agent for a type the user already runs — existing - // agents (enabled or not) are left exactly as they are. - const configuredTypes = new Set(existing.map((agent) => agent.type)); - for (const type of selectedAgents) { - if (configuredTypes.has(type as AgentType)) { - outcome.alreadyConfigured.push(type); - continue; - } - const defaults = AGENT_DEFAULTS[type as AgentType]; - if (!defaults) continue; // unknown type — guarded, but never trust the input - try { - onLog?.(`enabling agent ${type}…`); - // Seed from shared metadata: alias + the full supported-model set. The - // backend resolves the default docker image and host config path, so we - // don't pass them (a literal "~" path would otherwise reach the backend). - await actions.addAgent(rootDir, { - alias: defaults.defaultAlias, - type: type as AgentType, - models: defaults.defaultModels, - enabled: true, - }); - outcome.added.push(type); - configuredTypes.add(type as AgentType); - } catch (error) { - outcome.errors.push(`could not enable ${type}: ${(error as Error).message}`); - } - } - - // 3. Image-based authentication — only for selected agents that actually have - // a login plan, and only for the ones the user confirms. - let loginable: Set; - try { - loginable = new Set(await actions.loginableAgents()); - } catch (error) { - outcome.errors.push(`could not determine which agents support image login: ${(error as Error).message}`); - loginable = new Set(); - } - const candidates = selectedAgents.filter((type) => loginable.has(type)); - if (candidates.length > 0 && confirmLogin) { - let chosen: string[] = []; - try { - chosen = await confirmLogin({ candidates, rootDir }); - } catch (error) { - // A failed/cancelled prompt must not abort the whole run — validation and - // exact recovery commands are still useful. - outcome.errors.push(`agent login prompt failed: ${(error as Error).message}`); - } - const chosenSet = new Set(chosen.filter((type) => loginable.has(type))); - // Iterate the candidate order (not the user's), so logins run in a stable order. - for (const type of candidates) { - if (!chosenSet.has(type)) continue; - try { - onLog?.(`authenticating ${type} through its image…`); - const result = await actions.loginAgent(rootDir, type); - if (result.detail) onLog?.(result.detail); - if (result.available && result.success) outcome.authenticated.push(type); - else outcome.authFailed.push(type); - } catch (error) { - outcome.authFailed.push(type); - outcome.errors.push(`login for ${type} failed: ${(error as Error).message}`); - } - } - } - - // 4. Always validate the selected agents from the same image/mount shape the - // worker uses. This is one live call per agent (host calls are deliberately - // skipped), so setup catches a successful host login that was not mounted into - // Docker without doubling subscription usage. - try { - onLog?.(`checking agent connectivity through worker image${selectedAgents.length === 1 ? "" : "s"}…`); - const checks = await actions.validateAgents(rootDir, selectedAgents); - for (const check of checks) { - onLog?.(`${check.type}: ${check.detail}`); - if (check.status === "ok") { - outcome.validated.push(check.type); - continue; - } - outcome.validationFailed.push(check.type); - if (loginable.has(check.type)) outcome.nextCommands.push(`propr agent login ${check.type}`); - outcome.nextCommands.push(`propr check agents --agents ${check.type}`); - } - } catch (error) { - outcome.errors.push(`could not validate agent connectivity: ${(error as Error).message}`); - for (const type of selectedAgents) { - if (loginable.has(type)) outcome.nextCommands.push(`propr agent login ${type}`); - outcome.nextCommands.push(`propr check agents --agents ${type}`); - } - } - - outcome.nextCommands = Array.from(new Set(outcome.nextCommands)); - - return outcome; -} - -/** - * Build the production {@link AgentSetupActions}, lazily importing the heavy - * orchestrator/API/validation modules only when an action runs — keeping the - * engine import cheap and Docker-free for tests, which replace these anyway. - */ -export function createDefaultAgentSetupActions(configManager?: ConfigManager): AgentSetupActions { - /** A client pointed at the local stack's API port (not the saved remote URL). */ - const localApiClient = async (rootDir: string): Promise => { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - const { createApiClient } = await import("../../api/client.js"); - return createApiClient({ baseUrl: localhostServiceUrl(cfg.apiPort) }); - }; - - return { - async listAgents(rootDir) { - const { listAgents } = await import("../../api/agents.js"); - const client = await localApiClient(rootDir); - const response = await listAgents(client); - return response.agents; - }, - async addAgent(rootDir, options) { - const { addAgent } = await import("../../api/agents.js"); - const client = await localApiClient(rootDir); - await addAgent(options, client); - }, - async loginableAgents() { - const { loginableAgents } = await import("../agentValidation.js"); - return loginableAgents(); - }, - async loginAgent(rootDir, type) { - const { mkdirSync, mkdtempSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { spawnSync } = await import("node:child_process"); - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { planAgentLogin } = await import("../agentValidation.js"); - - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const tmp = mkdtempSync(join(tmpdir(), "propr-setup-login-")); - const workspaceDir = join(tmp, "workspace"); - mkdirSync(workspaceDir, { recursive: true }); - try { - const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath); - if (error || !plan) return { available: false, success: false, detail: error }; - // The image must be present locally; setup pulls the unified agent image - // when any agent is selected, but a failed pull would leave it absent. - if (orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim().length === 0) { - return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` }; - } - mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 }); - const res = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" }); - return res.status === 0 - ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` } - : { available: true, success: false, detail: `${type} login exited with code ${res.status ?? "?"}` }; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }, - async validateAgents(rootDir, types) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { validateAgents } = await import("../agentValidation.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true }); - return rows.map((row) => ({ - type: row.type, - status: row.image.status === "ok" ? "ok" : row.image.status === "fail" ? "failed" : "skipped", - detail: row.image.detail, - })); - }, - }; -} +export * from "@propr/local-setup"; +export { createDefaultAgentSetupActions } from "./agentHostActions.js"; diff --git a/packages/cli/src/commands/setup/engine.test.ts b/packages/cli/src/commands/setup/engine.test.ts index 7e4998d6d..2f047d6bc 100644 --- a/packages/cli/src/commands/setup/engine.test.ts +++ b/packages/cli/src/commands/setup/engine.test.ts @@ -118,6 +118,47 @@ test("imports an upload-compatible gh token after the backend becomes healthy", assert.ok(log.includes('visual previews: configured from the gh CLI session (@octocat)')); }); +test("keeps visual-preview credential failures non-blocking and secrets out of reporter output", async () => { + const sentinels = [ + "ghp_TOKEN_SENTINEL_123456789", + "Bearer BEARER_SENTINEL_123456789", + "https://secret.example/SENSITIVE_PATH_SENTINEL", + "SENSITIVE_USERNAME_SENTINEL", + ]; + const logOutput: string[] = []; + const progressOutput: string[] = []; + let healthChecks = 0; + let attempts = 0; + + const result = await runSetup({ + root: "/stack", + reporter: { + onLog: (line) => logOutput.push(line), + onProgress: (event) => progressOutput.push(JSON.stringify(event)), + }, + actions: mockActions({ + checkBackendHealth: async () => { + healthChecks += 1; + return { healthy: true, detail: "API healthy" }; + }, + configureVisualPreviewCredential: async () => { + attempts += 1; + throw new Error(sentinels.join(" ")); + }, + }), + }); + + assert.equal(result.completed, true); + assert.equal(statusOf(result.state, "start-stack"), "done"); + assert.equal(healthChecks, 1); + assert.equal(attempts, 1); + assert.ok(logOutput.includes("visual previews: could not import the gh CLI token; add a PAT in Settings")); + for (const sentinel of sentinels) { + assert.equal(logOutput.join("\n").includes(sentinel), false); + assert.equal(progressOutput.join("\n").includes(sentinel), false); + } +}); + test("an incomplete stack root (missing dirs) is re-scaffolded even when .env exists", async () => { let scaffolded = false; const result = await runSetup({ diff --git a/packages/cli/src/commands/setup/engine.ts b/packages/cli/src/commands/setup/engine.ts index 0e5289566..3cb26d717 100644 --- a/packages/cli/src/commands/setup/engine.ts +++ b/packages/cli/src/commands/setup/engine.ts @@ -1,1624 +1,121 @@ -/** - * Setup wizard engine. - * - * `propr setup` walks a new user from a bare host to a running local - * control-plane stack. It combines what `propr check` and `propr init stack` - * already do, then sequences the remaining one-time tasks — pulling images, - * recording agent credentials, choosing GitHub auth, starting the stack and - * validating its health, configuring the whitelist, optionally connecting a - * first repository, and surfacing the UI URL. - * - * The engine is intentionally UI-agnostic. It owns the *order* of the flow and - * the *decision logic* (what to run, what to skip, what is safe), but performs - * no rendering and prompts no user directly. Two seams keep it decoupled: - * - * - {@link SetupPrompts} — callback hooks a renderer supplies to collect user - * decisions (which agents, which auth mode, whether to add a repo, …). Every - * hook is optional; a missing hook falls back to a safe, non-interactive - * default (keep what exists, skip optional work). Ink and the readline - * fallback will provide these in later issues. - * - {@link SetupActions} — the side-effecting operations (run checks, scaffold, - * pull, start, health-probe, add repo). Defaults bind to the real - * orchestrator and commands via {@link createDefaultActions}; tests inject - * mocks so the whole flow runs without Docker, the network, or a TTY. - * - * Safety contract (enforced here, not just by convention): - * - The stack is initialized only when `.env` is missing or the user picks a - * new root — an existing functional install is left intact on re-run. - * - `.env` is never overwritten wholesale; edits go through the non-destructive - * {@link applyEnvSelection} (per-key, never blanks an existing value). - * - No step deletes user data; a running stack is reused, not recreated. - * - Core images pull by default; the agent image pulls when an agent is selected. - */ - -import { existsSync, mkdirSync } from "node:fs"; -import { homedir, hostname } from "node:os"; -import { isAbsolute, join, normalize } from "node:path"; -import { - resolveGithubEventIntakeMode, - validateIntakeModePrerequisites, - DEFAULT_PROPR_GH_RELAY_URL, - type GithubAuthMode, - type GithubAuthModeResult, -} from "@propr/shared"; -import type { ConfigManager } from "../../config/index.js"; -import type { AuthorizedInstallation, RelayClientOptions } from "../../api/relay.js"; import { - buildIntakeEnvVars, - defaultIntakeChoice, - intakeModeLabel, - saveWhitelist, - type GithubIntakeDecision, - type GithubIntakeMode, -} from "./github.js"; -import type { ChecksOutcome, RunChecksOptions } from "../checkCommands.js"; -import type { InitStackOptions, InitStackResult } from "../initStack.js"; -import { - createDefaultAgentSetupActions, - runAgentSetup, - type AgentSetupActions, -} from "./agents.js"; -import { - applyEnvSelection, - clearEnvKeys, - createSetupState, - detectGithubAuthMode, - getStep, - inspectDatastoreAdministrators, - inspectStackInit, - isSetupComplete, - readEnvVars, + runSetup as runLocalSetup, + retrySetup as retryLocalSetup, resolveSetupRoot, - updateStep, - type EnvSelectionResult, - type DatastoreAdminInspection, - type StackInitState, -} from "./state.js"; -import type { SetupState, SetupStep, SetupStepId, SetupStepPatch } from "./types.js"; + type RunSetupOptions as LocalRunSetupOptions, + type SetupActions as LocalSetupActions, + type SetupReporter, + type SetupRunResult, +} from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; import { localhostServiceUrl } from "../../utils/dockerPort.js"; +import { createDefaultActions as createHostActions } from "./hostActions.js"; -const DEFAULT_PROPR_GITHUB_APP_INSTALL_URL = "https://github.com/apps/propr-dev/installations/new"; - -/** Match the API's distinction between real OAuth credentials and example placeholders. */ -function isConfiguredOAuthValue(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return Boolean(normalized && !normalized.startsWith("your_") && normalized !== "changeme"); -} - -function isTruthyEnvFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized === "true" || normalized === "1"; -} - -function normalizeServiceUrl(value: string | undefined): string | undefined { - try { - if (!value?.trim()) return undefined; - const url = new URL(value.trim()); - if (url.username || url.password || url.search || url.hash) return undefined; - const path = url.pathname.replace(/\/+$/, ""); - return `${url.origin}${path}`; - } catch { - return undefined; - } -} - -function isSupportedLoopbackCallback(value: string | undefined): boolean { - try { - if (!value?.trim()) return false; - const url = new URL(value.trim()); - const hostname = url.hostname.toLowerCase(); - return ( - url.protocol === "http:" && - (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") && - url.username === "" && - url.password === "" && - url.pathname === "/api/auth/github/callback" && - url.search === "" && - url.hash === "" - ); - } catch { - return false; - } -} - -/** - * Catalog of supported agents: the image each one needs and the host - * credential directories recorded into `.env` when it is selected. Mirrors - * `agentDescriptors()` in ../checkCommands.ts and `detectCredentials()` in - * ../initStack.ts — kept local so the engine has no rendering/command imports. - */ -interface AgentDescriptor { - type: string; - /** Unified agent manifest image key. */ - imageKey: string; - /** Host credential dirs mounted into the agent container. */ - credentials: { envKey: string; defaultDir: string }[]; -} - -function agentCatalog(): AgentDescriptor[] { - const home = homedir(); - return [ - { type: "claude", imageKey: "agent", credentials: [{ envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude") }] }, - { type: "codex", imageKey: "agent", credentials: [{ envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex") }] }, - { type: "antigravity", imageKey: "agent", credentials: [{ envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini") }] }, - { - type: "opencode", - imageKey: "agent", - credentials: [ - { envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode") }, - { envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode") }, - ], - }, - { type: "vibe", imageKey: "agent", credentials: [{ envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe") }] }, - ]; -} - -/** Reject unsafe Docker bind sources before any recursive filesystem write. */ -function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { - if ( - !isAbsolute(path) - || normalize(path) === "/" - || path.includes(":") - || /[\u0000-\u001f\u007f-\u009f]/.test(path) - ) { - throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); - } -} - -/** Agent types whose default credential directory exists on this host. */ -function detectInstalledAgents(catalog: AgentDescriptor[]): string[] { - return catalog.filter((a) => a.credentials.some((c) => existsSync(c.defaultDir))).map((a) => a.type); -} - -// --------------------------------------------------------------------------- -// Decisions the renderer collects from the user. -// --------------------------------------------------------------------------- - -/** Where to put the stack, and whether to scaffold it. */ -export interface RootDecision { - /** Stack root to use (absolute). May differ from the resolved default. */ - rootDir: string; - /** - * Ensure this root is scaffolded, creating any *missing* `.env`/data/logs/repos - * pieces. Non-destructive: scaffolding runs without `force`, so an existing - * `.env` is always preserved — this fills in what is absent, it never resets a - * working install. (A root with a missing `.env` or sub-directory is scaffolded - * regardless of this flag; the flag only forces a scaffold pass on a root that - * already looks complete.) - */ - reinitialize: boolean; -} - -/** Outcome of the GitHub-auth prompt. */ -export interface GithubAuthDecision { - /** Keep the existing configuration untouched. */ - keep?: boolean; - /** Informational: the auth mode the user picked. */ - mode?: GithubAuthMode; - /** Env values to write (non-destructively, overwriting only these keys). */ - vars?: Record; - /** - * Relay path: the user chose token relay and wants the engine to enroll on - * their behalf (discover the installation, mint the token, write the relay - * env vars) using the stored `propr login` token. `relayUrl` is the relay base - * URL to enroll against — the hosted default unless overridden. Mutually - * exclusive with `vars`. - */ - enrollRelay?: { relayUrl: string }; -} - -/** A repository to start monitoring. */ -export interface RepoSelection { - fullName: string; - alias?: string; - baseBranch?: string; -} - -/** - * Hooks a renderer implements to drive user decisions. All optional: a missing - * hook means "use the safe default" (keep existing config, skip optional work), - * which is exactly what lets the engine run unattended in tests. - */ -export interface SetupPrompts { - /** Choose/confirm the stack root. Default: keep resolved root, scaffold only if `.env` is absent. */ - resolveStackRoot?(ctx: { currentRoot: string; init: StackInitState }): Promise; - /** Pick which agents to enable. Default: the agents detected on this host. */ - selectAgents?(ctx: { available: string[]; detected: string[] }): Promise; - /** Configure GitHub auth. Default: keep whatever `.env` already has. */ - configureGithubAuth?(ctx: { current: GithubAuthModeResult }): Promise; - /** - * Choose which installation to enroll when the relay reports more than one the - * user can access. Only consulted for the ambiguous (>1) case; a single - * installation is auto-selected and zero is an error. Default (no hook): the - * first installation. - */ - selectInstallation?(ctx: { installations: AuthorizedInstallation[] }): Promise; - /** - * Ask whether to run the interactive `propr login` (gh CLI) now when Connect - * enrollment or protected local API steps need a user token and none is - * stored. `reason` explains which part of setup needs it. - */ - confirmGithubLogin?(ctx: { reason: string }): Promise; - /** Offer to open the official hosted ProPR GitHub App installation page. */ - confirmGithubAppInstall?(ctx: { url: string }): Promise; - /** Continue enrollment after the user finishes the browser installation. */ - confirmGithubAppInstalled?(ctx: { url: string }): Promise; - /** - * Choose how the backend ingests GitHub events (routing WebSocket, polling, or - * direct webhooks). `defaultMode` is the choice to pre-select: the auth-derived - * recommendation on a fresh install, but `"keep"` when `.env` already carries - * an intake decision so a blank Enter never rewrites a working config. - * `currentMode` is the intake mode `.env` resolves to today. Default: keep. - */ - configureIntake?(ctx: { - authMode: GithubAuthMode; - defaultMode: GithubIntakeMode | "keep"; - currentMode: GithubIntakeMode; - }): Promise; - /** Confirm starting the stack. Default: start it. */ - confirmStartStack?(ctx: { rootDir: string; alreadyRunning: boolean }): Promise; - /** - * Choose which of the selected agents to authenticate through their image - * (only agents with an image-login plan are offered). Returns the subset to - * log in. Default: authenticate none. - */ - confirmAgentLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; - /** Provide the user whitelist. Return null to keep the current value. Default: keep. */ - configureWhitelist?(ctx: { current: string[]; demoMode: boolean }): Promise; - /** Optionally add a first repository. Return null to skip. Default: skip. */ - addRepository?(ctx: { rootDir: string }): Promise; - /** - * Ask whether to open the UI in a browser. Returning `true` makes the engine - * launch it (via {@link SetupActions.openUrl}); the renderer only collects the - * yes/no. Default: don't open, just report the URL. - */ - launchUi?(ctx: { url: string }): Promise; -} - -// --------------------------------------------------------------------------- -// Progress reporting. -// --------------------------------------------------------------------------- - -/** Progress hooks a renderer implements to reflect engine state. All optional. */ -export interface SetupReporter { - /** Fired after every state transition with the latest immutable snapshot. */ - onState?(state: SetupState): void; - /** Fired when a step becomes active. */ - onStepStart?(step: SetupStep): void; - /** Fired when a step reaches a terminal status. */ - onStepSettled?(step: SetupStep): void; - /** Free-form progress lines (e.g. docker pull output). */ - onLog?(line: string): void; -} - -// --------------------------------------------------------------------------- -// Injectable side effects. -// --------------------------------------------------------------------------- - -export interface PullImagesParams { - rootDir: string; - /** Agent types whose images should be pulled (in addition to core images). */ - agentTypes: string[]; - onLog?: (line: string) => void; -} - -export interface PullImagesResult { - pulledCore: string[]; - pulledAgents: string[]; - /** Core images that failed to pull — fatal, the stack cannot start. */ - failedCore: string[]; - /** Agent images that failed to pull — non-fatal, only those agents are affected. */ - failedAgents: string[]; -} - -export interface StartStackParams { - rootDir: string; - ui?: boolean; - docs?: boolean; - onLog?: (line: string) => void; -} - -export interface BackendHealthParams { - rootDir: string; - timeoutMs?: number; -} - -export interface BackendHealth { - healthy: boolean; - detail: string; - /** - * Set when the backend answered the probe (it is reachable and running) but - * rejected the request for authentication or authorization reasons rather - * than being genuinely unhealthy. The value lets the caller recommend login - * for a 401 without giving the same incorrect advice for a 403. - */ - accessFailure?: "unauthorized" | "forbidden"; -} +export * from "@propr/local-setup"; export interface VisualPreviewCredentialSetupResult { - status: 'configured' | 'already-configured' | 'environment-managed' | 'missing' | 'unsupported'; + status: "configured" | "already-configured" | "environment-managed" | "missing" | "unsupported"; githubUsername?: string; } -/** Classify an HTTP access failure from the protected backend status route. */ -export function classifyBackendAccessError(error: unknown): BackendHealth | undefined { - const httpStatus = (error as { status?: unknown } | null)?.status; - if (httpStatus !== 401 && httpStatus !== 403) return undefined; - - const accessFailure = httpStatus === 401 ? "unauthorized" : "forbidden"; - const message = error instanceof Error ? error.message : String(error); - return { - healthy: false, - accessFailure, - detail: `backend is running but rejected the status request as ${accessFailure} (${message})`, - }; -} - -/** - * The operations the engine performs against the outside world. Defaults bind - * to the real orchestrator/commands (see {@link createDefaultActions}); tests - * override any subset. - */ -export interface SetupActions extends AgentSetupActions { - runChecks(options: RunChecksOptions): Promise; - inspectStackInit(rootDir: string): StackInitState; - /** Inspect the configured datastore's durable administrator state without modifying it. */ - inspectDatastoreAdministrators(rootDir: string): Promise; - scaffoldStack(options: InitStackOptions): Promise; - /** - * Persist the resolved stack root to the CLI config so later `propr start` / - * `propr status` invoked without `--root` target this stack. `scaffoldStack` - * already records it whenever it runs; this exists for the reuse path (an - * already-initialized root that setup leaves untouched), which would otherwise - * leave config pointing at a stale root or the cwd. A no-op without a config. - */ - persistStackRoot(rootDir: string): Promise; - readEnvVars(rootDir: string): Record; - applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }): EnvSelectionResult; - /** Remove keys from `.env` entirely (used to clear a value, not blank it). */ - clearEnvKeys(rootDir: string, keys: string[]): void; - detectGithubAuthMode(rootDir: string): GithubAuthModeResult; - /** Ensure a selected agent's host credential path is a directory, creating it securely when absent. */ - prepareAgentCredentialDir(path: string): void; - pullImages(params: PullImagesParams): Promise; - isStackRunning(rootDir: string): Promise; - startStack(params: StartStackParams): Promise; - checkBackendHealth(params: BackendHealthParams): Promise; - /** Seed preview uploads from the authenticated gh CLI session when possible. */ +/** CLI setup actions, including host-specific visual-preview credential seeding. */ +export interface SetupActions extends LocalSetupActions { configureVisualPreviewCredential(rootDir: string): Promise; - addRepository(selection: RepoSelection, rootDir: string): Promise; - resolveUiUrl(rootDir: string): Promise; - /** Open `url` in the host's default browser (best-effort; may reject). */ - openUrl(url: string): Promise; - /** - * Save the user whitelist through the running backend's settings API. A - * partial update — only the whitelist key is sent, so unrelated settings are - * left intact. - */ - saveWhitelistSetting(rootDir: string, users: string[]): Promise; - /** True when a GitHub user token is stored (relay enrollment and protected local API calls need it). */ - hasGithubToken(): boolean; - /** - * List the relay installations the stored GitHub identity can access (drives - * auto-select / the picker during relay enrollment). Throws if not logged in. - */ - fetchRelayInstallations(params: { - relayUrl?: string; - }): Promise<{ username: string; installations: AuthorizedInstallation[] }>; - /** - * Mint a relay token for `installationId`, returning the token and the relay - * URL it was minted against (the hosted default unless `relayUrl` overrides). - */ - enrollRelay(params: { - relayUrl?: string; - installationId: string; - label?: string; - }): Promise<{ relayUrl: string; token: string }>; - /** Authenticate with GitHub via the interactive `gh` CLI and store the token. */ - loginWithGithub(params?: { onLog?: (line: string) => void }): Promise; } -/** Options for {@link runSetup}. */ -export interface RunSetupOptions { +/** CLI-compatible options layered over the host-neutral package contract. */ +export interface RunSetupOptions extends Omit { configManager?: ConfigManager; - /** Explicit stack root flag (highest precedence). */ root?: string; - prompts?: SetupPrompts; - reporter?: SetupReporter; - /** Override any subset of the default actions (tests inject mocks here). */ actions?: Partial; - skipRemoteImageCheck?: boolean; } -/** Final outcome of a setup run. */ -export interface SetupRunResult { - rootDir: string; - state: SetupState; - /** Environment-check outcome, when the check step ran. */ - checks?: ChecksOutcome; - /** True when every required step finished without a blocking failure. */ - completed: boolean; -} - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Build the production {@link SetupActions}, lazily importing the heavy - * orchestrator/command/API modules only when an action actually runs. This - * keeps `import`ing the engine cheap (and Docker-free) for tests, which replace - * these actions anyway. - */ export function createDefaultActions(configManager?: ConfigManager): SetupActions { - /** A client pointed at the local stack's API port (not the saved remote URL). */ - const localApiClient = async (rootDir: string): Promise => { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); - const options = { baseUrl: localhostServiceUrl(cfg.apiPort) }; - // Keep the local client on setup's active profile and, importantly, the - // token that an in-progress setup login just stored. Creating an unrelated - // manager here can otherwise lose profile context and call protected local - // endpoints without the token setup has already obtained. - return configManager - ? createApiClientWithConfig(configManager, options) - : createApiClient(options); - }; - return { - // Agent enablement + image-login actions, bound to the local stack. - ...createDefaultAgentSetupActions(configManager), - async runChecks(options) { - const { runChecks } = await import("../checkCommands.js"); - return runChecks(options); - }, - inspectStackInit, - inspectDatastoreAdministrators, - async scaffoldStack(options) { - const { scaffoldStack } = await import("../initStack.js"); - return scaffoldStack(options); - }, - async persistStackRoot(rootDir) { - // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path - // records the root too. Best-effort: without a config there is nowhere to - // persist it (tests run this way), so it is simply a no-op. - await configManager?.setStackRoot(rootDir); - }, - readEnvVars, - applyEnvSelection, - clearEnvKeys, - detectGithubAuthMode, - prepareAgentCredentialDir(path) { - assertSafeAgentCredentialDir(path); - mkdirSync(path, { recursive: true, mode: 0o700 }); - }, - async pullImages({ rootDir, agentTypes, onLog }) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const selected = new Set(agentTypes); - const result: PullImagesResult = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] }; - - for (const [key, tag] of Object.entries(cfg.images)) { - if (key === "docs" && !cfg.docsEnabled) continue; - const isAgent = key === "agent"; - // Pull the shared agent image when the user selected any agent; core images - // (api/worker/daemon/redis/…) always pull. - if (isAgent && selected.size === 0) continue; - - onLog?.(`pulling ${tag}…`); - // Async exec keeps the event loop free so the wizard's Ink spinner keeps - // animating while the (often slow) pull runs, instead of freezing. - const pulled = await orch.dockerAsync(["pull", tag]); - if (pulled.status === 0) { - try { - orch.tagAgentLatest(key, tag); - } catch { - /* best-effort local retag; the pull itself succeeded */ - } - (isAgent ? result.pulledAgents : result.pulledCore).push(tag); - } else { - (isAgent ? result.failedAgents : result.failedCore).push(tag); - } - } - return result; - }, - async isStackRunning(rootDir) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - return orch.isStackRunningAsync(cfg); - }, - async startStack({ rootDir, ui, docs, onLog }) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - // Pre-create the host Vibe prompt-cache dir owned by this user so Docker - // does not auto-create it as root on first bind-mount — a root-owned dir - // would fail the writability check and block future `propr start` runs. - try { - const { ensureVibePromptCacheDir } = await import("../initStack.js"); - ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir); - } catch { - /* best-effort: startup validation will surface an actionable error */ - } - const validation = orch.validateEnv(cfg); - for (const warning of validation.warnings) onLog?.(`warning: ${warning}`); - if (!validation.ok) { - throw new Error(`stack environment is not ready:\n - ${validation.errors.join("\n - ")}`); - } - // Use the async start path: `propr setup` drives this from behind a live - // Ink TUI, so the blocking synchronous startStack would freeze the spinner - // and swallow keystrokes for the seconds-to-minutes a cold start takes. - await orch.ensureNetworkAsync(cfg, onLog); - await orch.startStackAsync(cfg, { - ui: ui ?? configManager?.getUiEnabled() ?? true, - docs: docs ?? cfg.docsEnabled, - onLog, - }); - }, - async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) { - const { getSystemStatus } = await import("../../api/system.js"); - const client = await localApiClient(rootDir); - const deadline = Date.now() + timeoutMs; - let lastError = "no response"; - // Containers take a few seconds to report healthy; poll until the deadline. - do { - try { - const status = await getSystemStatus(client); - if (String(status.api).toLowerCase() === "healthy") { - return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` }; - } - lastError = `API reports "${status.api}"`; - } catch (error) { - // A 401/403 is not an unhealthy backend — the API answered but denied - // this protected request. Return immediately so setup does not stall - // on a running backend, while preserving whether remediation requires - // authentication (401) or an authorization/configuration check (403). - const accessFailure = classifyBackendAccessError(error); - if (accessFailure) return accessFailure; - lastError = (error as Error).message; - } - if (Date.now() >= deadline) break; - await sleep(2_000); - } while (Date.now() < deadline); - return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; - }, + ...createHostActions(configManager), async configureVisualPreviewCredential(rootDir) { const token = configManager?.getGithubToken()?.trim(); - if (!token) return { status: 'missing' }; - if (!/^(?:gho_|ghp_|github_pat_)/.test(token)) return { status: 'unsupported' }; + if (!token) return { status: "missing" }; + if (!/^(?:gho_|ghp_|github_pat_)/.test(token)) return { status: "unsupported" }; - const { getVisualPreviewAuthStatus, saveVisualPreviewUploadToken } = await import('../../api/visualPreviewAuth.js'); - const client = await localApiClient(rootDir); + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); + const clientOptions = { baseUrl: localhostServiceUrl(cfg.apiPort) }; + const client = configManager + ? createApiClientWithConfig(configManager, clientOptions) + : await createApiClient(clientOptions); + const { getVisualPreviewAuthStatus, saveVisualPreviewUploadToken } = await import("../../api/visualPreviewAuth.js"); const current = await getVisualPreviewAuthStatus(client); - if (current.status === 'active') { - return { status: 'already-configured', githubUsername: current.githubUsername }; + if (current.status === "active") { + return { status: "already-configured", githubUsername: current.githubUsername }; } - if (current.source === 'environment') return { status: 'environment-managed' }; + if (current.source === "environment") return { status: "environment-managed" }; const configured = await saveVisualPreviewUploadToken(token, client); - return { status: 'configured', githubUsername: configured.githubUsername }; - }, - async addRepository({ fullName, alias, baseBranch }, rootDir) { - const { addRepo } = await import("../../api/repos.js"); - // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await addRepo(fullName, { alias, baseBranch }, client); - }, - async resolveUiUrl(rootDir) { - const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); - return localhostServiceUrl(cfg.uiPort); - }, - async openUrl(url) { - // Open in the host's default browser with the platform launcher. Detached - // and unref'd so the wizard isn't held open by the child, with stdio - // ignored so the launcher can't scribble over the TUI. - const { spawn } = await import("node:child_process"); - const platform = process.platform; - const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: "ignore", detached: true }); - child.once("error", reject); - // The launcher returns immediately; once it has spawned we're done. - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); - }, - async saveWhitelistSetting(rootDir, users) { - const { updateSetting } = await import("../../api/settings.js"); - // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await updateSetting("github_user_whitelist", users, client); - }, - hasGithubToken() { - return Boolean(configManager?.getGithubToken()); - }, - async fetchRelayInstallations({ relayUrl }) { - const { fetchAuthenticatedUser } = await import("../../api/relay.js"); - const me = await fetchAuthenticatedUser(relayClient(relayUrl)); - return { username: me.username, installations: me.installations }; - }, - async enrollRelay({ relayUrl, installationId, label }) { - const { enrollRelayToken } = await import("../../api/relay.js"); - const client = relayClient(relayUrl); - // Default the token label to the hostname, mirroring `propr relay enroll`. - const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() }); - return { relayUrl: client.baseUrl, token: result.token }; - }, - async loginWithGithub({ onLog } = {}) { - if (!configManager) return false; - const { loginWithGithubCli } = await import("../../auth/githubLogin.js"); - const result = await loginWithGithubCli(configManager, { interactive: true, onLog }); - if (!result.ok) onLog?.(result.message); - return result.ok; + return { status: "configured", githubUsername: configured.githubUsername }; }, }; - - /** - * Build a relay client bound to the stored GitHub token. The hosted relay is - * the default base URL; an explicit `relayUrl` (self-hosted) overrides it. - */ - function relayClient(relayUrl?: string): RelayClientOptions { - const githubToken = configManager?.getGithubToken(); - if (!githubToken) { - throw new Error("Not logged in to GitHub. Run `propr login` first."); - } - return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken }; - } } -/** - * Run the setup flow end to end, in a safe order, driven by the supplied - * prompts and reflected through the reporter. Returns the final step state and - * the environment-check outcome. Never throws for expected conditions (a failed - * required step stops the flow and is reported in the returned state); only - * truly unexpected programmer errors propagate. - */ -export async function runSetup(options: RunSetupOptions = {}): Promise { - const { configManager, prompts = {}, reporter = {}, skipRemoteImageCheck } = options; - const actions: SetupActions = { ...createDefaultActions(configManager), ...options.actions }; - const catalog = agentCatalog(); - - let rootDir = resolveSetupRoot(configManager, options.root); - let state = createSetupState(rootDir); - let checks: ChecksOutcome | undefined; - /** Agents chosen at the pull step, reused when recording credentials. */ - let selectedAgents: string[] = []; - /** True only when the configured datastore conclusively has no durable administrator. */ - let bootstrapIdentityEligible = false; - /** Set after this run successfully writes an authenticated identity to the administrator environment. */ - let bootstrapAdministratorSeeded = false; - let datastoreAdminInspection: DatastoreAdminInspection | undefined; - /** True only after the local API answers the setup health probe. */ - let backendReady = false; - - const emit = (): void => reporter.onState?.(state); - const stepOf = (id: SetupStepId): SetupStep => getStep(state, id)!; - const begin = (id: SetupStepId): void => { - state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined }); - emit(); - reporter.onStepStart?.(stepOf(id)); - }; - const settle = (id: SetupStepId, patch: SetupStepPatch): void => { - state = updateStep(state, id, patch); - emit(); - reporter.onStepSettled?.(stepOf(id)); - }; - const log = (line: string): void => reporter.onLog?.(line); - const finish = (): SetupRunResult => ({ - rootDir, - state, - checks, - // A terminal-looking step list is not a working installation unless the - // API actually became healthy during this run. - completed: isSetupComplete(state) && backendReady, - }); - - /** - * Relay enrollment for the auth step. Ensures a GitHub token (offering the - * interactive login when a `confirmGithubLogin` hook is present), discovers the - * installation (auto-select one, pick among many, error on none), mints the - * relay token, and writes the relay env vars. Returns a success `detail` or a - * actionable `note`. It never throws for expected problems; the caller marks - * the auth step failed and stops before launching a backend that cannot boot. - */ - const enrollRelayForSetup = async ( - relayUrl: string - ): Promise<{ detail?: string; note?: { detail: string; nextAction?: string } }> => { - // 1. A stored GitHub token is required. Offer interactive login when the - // renderer supports it. The Ink entry point performs this handoff before - // enabling raw mode; the sequential renderer prompts through this hook. - if (!actions.hasGithubToken()) { - const reason = "Relay enrollment needs a GitHub token."; - if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { - await actions.loginWithGithub({ onLog: log }); - } - if (!actions.hasGithubToken()) { - return { - note: { - detail: "relay not enrolled — not logged in to GitHub", - nextAction: "Run `propr login`, then re-run `propr setup` and accept ProPR Connect.", - }, - }; - } - } - - try { - // 2. Discover installations: auto-select the only one, pick among many, - // error when there are none. - let { username, installations } = await actions.fetchRelayInstallations({ relayUrl }); - const usingHostedRelay = - relayUrl.replace(/\/+$/, "") === DEFAULT_PROPR_GH_RELAY_URL.replace(/\/+$/, ""); - if (installations.length === 0 && usingHostedRelay && prompts.confirmGithubAppInstall) { - const installUrl = DEFAULT_PROPR_GITHUB_APP_INSTALL_URL; - if (await prompts.confirmGithubAppInstall({ url: installUrl })) { - await actions.openUrl(installUrl); - const installed = prompts.confirmGithubAppInstalled - ? await prompts.confirmGithubAppInstalled({ url: installUrl }) - : false; - if (installed) { - ({ username, installations } = await actions.fetchRelayInstallations({ relayUrl })); - } - } - } - if (installations.length === 0) { - return { - note: { - detail: "relay not enrolled — no GitHub App installation available", - nextAction: usingHostedRelay - ? `Install the default ProPR GitHub App at ${DEFAULT_PROPR_GITHUB_APP_INSTALL_URL}, then re-run setup.` - : `Ask the administrator of ${relayUrl} for that relay's GitHub App installation URL, install it, then re-run setup.`, - }, - }; - } - let installationId: string; - if (installations.length === 1) { - installationId = String(installations[0].installation_id); - log(`relay: using installation ${installationId} (${installations[0].account_login})`); - } else if (prompts.selectInstallation) { - installationId = await prompts.selectInstallation({ installations }); - } else { - installationId = String(installations[0].installation_id); - } - - // 3. Mint the relay token and write the relay env vars (overwriting only - // these keys). PROPR_DEMO_MODE=false ensures the new relay config isn't - // shadowed by a leftover demo flag (see detectGithubAuthMode). - const { relayUrl: resolvedRelayUrl, token } = await actions.enrollRelay({ relayUrl, installationId }); - const existingEnv = actions.readEnvVars(rootDir); - const existingAdminUsers = [...new Set( - (existingEnv.PROPR_ADMIN_USERS ?? "") - .split(",") - .map((value) => value.trim().toLowerCase()) - .filter(Boolean) - )]; - const hasExistingAdminUsers = existingAdminUsers.length > 0; - const seedBootstrapAdmin = bootstrapIdentityEligible && !hasExistingAdminUsers; - const existingWhitelist = (existingEnv.GITHUB_USER_WHITELIST ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const whitelistHasIdentity = existingWhitelist.some( - (value) => value.toLowerCase() === username.trim().toLowerCase() - ); - const bootstrapWhitelist = seedBootstrapAdmin && !whitelistHasIdentity - ? [...existingWhitelist, username].join(",") - : undefined; - const tunnelOverride = configManager?.getTunnelEnabled(rootDir); - const managedTunnelEnabled = tunnelOverride ?? Boolean( - existingEnv.PROPR_UI_TUNNEL_TOKEN?.trim() || isTruthyEnvFlag(existingEnv.PROPR_UI_TUNNEL_ENABLED) - ); - const explicitBrowserAuthMode = existingEnv.PROPR_WEB_AUTH_MODE?.trim().toLowerCase(); - const hasExplicitBrowserAuthMode = - explicitBrowserAuthMode === "connect" || - explicitBrowserAuthMode === "github" || - explicitBrowserAuthMode === "disabled"; - const customBrowserOAuthApplies = - !managedTunnelEnabled && - !hasExplicitBrowserAuthMode && - isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_ID) && - isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_SECRET); - const usesHostedConnect = - normalizeServiceUrl(resolvedRelayUrl) === normalizeServiceUrl(DEFAULT_PROPR_GH_RELAY_URL) && - normalizeServiceUrl(existingEnv.PROPR_CONNECT_URL || "https://connect.propr.dev") === - "https://connect.propr.dev"; - const callbackUrl = existingEnv.GH_OAUTH_CALLBACK_URL || - "http://localhost:4000/api/auth/github/callback"; - const automaticConnectApplies = - managedTunnelEnabled || - (usesHostedConnect && isSupportedLoopbackCallback(callbackUrl)); - actions.applyEnvSelection( - rootDir, - { - PROPR_DEMO_MODE: "false", - GH_AUTH_MODE: "relay", - PROPR_GH_RELAY_URL: resolvedRelayUrl, - PROPR_GH_RELAY_TOKEN: token, - GH_INSTALLATION_ID: installationId, - // Select hosted Connect only for its managed tunnel and exact - // loopback callback deployments. Explicit modes, custom OAuth, and - // custom/self-hosted relay paths remain operator-owned. - ...(automaticConnectApplies && !hasExplicitBrowserAuthMode && !customBrowserOAuthApplies - ? { PROPR_WEB_AUTH_MODE: "connect" } - : {}), - // The relay identity was just authenticated by GitHub and owns this - // installation, so it is the safe bootstrap administrator only when - // the configured datastore is absent or conclusively contains no - // durable administrator. Existing environment administrators and - // durable database administrators are always preserved. - ...(seedBootstrapAdmin ? { PROPR_ADMIN_USERS: username } : {}), - // Preserve every user-managed whitelist entry, adding the enrolled - // identity only when bootstrap enrollment needs it. - ...(bootstrapWhitelist ? { GITHUB_USER_WHITELIST: bootstrapWhitelist } : {}), - }, - { overwrite: true } - ); - bootstrapAdministratorSeeded = seedBootstrapAdmin; - const adminDetail = hasExistingAdminUsers - ? "kept existing administrators" - : seedBootstrapAdmin - ? `bootstrap administrator: ${username}` - : datastoreAdminInspection?.status === "uninspectable" - ? "left administrators unchanged because the datastore could not be inspected" - : "left administrators unchanged on existing stack"; - return { - detail: `auth mode: relay (installation ${installationId}); ${adminDetail}`, - }; - } catch (error) { - return { - note: { - detail: `relay enrollment failed — ${(error as Error).message}`, - nextAction: "Confirm the shared GitHub App is installed and you own the installation, then re-run setup.", - }, - }; - } - }; - - emit(); - - // 1. Environment checks — run first; their results steer the rest. - begin("check"); - try { - checks = await actions.runChecks({ root: rootDir, skipRemoteImageCheck }); - } catch (error) { - settle("check", { - status: "failed", - detail: `could not run environment checks: ${(error as Error).message}`, - nextAction: "Resolve the error above, then re-run setup.", - }); - return finish(); - } - const dockerProblem = blockingDockerFailure(checks); - if (dockerProblem) { - settle("check", { - status: "failed", - detail: dockerProblem, - nextAction: "Install/start Docker and ensure this user can run `docker info`, then re-run setup.", - }); - return finish(); - } - const fails = checks.results.filter((r) => r.status === "fail").length; - const warns = checks.results.filter((r) => r.status === "warn").length; - settle("check", { - status: warns > 0 || fails > 0 ? "warning" : "done", - detail: `${checks.results.length} checks (${fails} failing, ${warns} warnings) — addressing them below`, - }); - - // 2. Initialize stack — only when `.env` is missing or the user picks a new - // root. An existing functional install is never re-scaffolded or clobbered. - begin("init-stack"); - try { - let initSettlement: SetupStepPatch; - let init = actions.inspectStackInit(rootDir); - let userChoseReinit = false; - if (prompts.resolveStackRoot) { - const decision = await prompts.resolveStackRoot({ currentRoot: rootDir, init }); - if (decision.rootDir && decision.rootDir !== rootDir) { - rootDir = decision.rootDir; - state = { ...state, rootDir }; - init = actions.inspectStackInit(rootDir); - } - userChoseReinit = decision.reinitialize; - } - - // Scaffold whenever the stack is incomplete — `.env` missing *or* a required - // sub-directory (data/logs/repos) absent — or when the user explicitly chose - // to (re)initialize a root. Keying off `initialized` (not just `envExists`) - // means a half-scaffolded root with a stray `.env` but no `data/` still gets - // its directories created, instead of being silently treated as ready and - // failing later at startup. scaffoldStack runs without `force`, so an existing - // `.env` is always preserved — re-running setup never clobbers it. - const reinitialize = !init.initialized || userChoseReinit; - if (reinitialize) { - // No `force`: scaffoldStack creates a fresh `.env` only when absent and - // otherwise leaves the existing one in place. - const result = await actions.scaffoldStack({ root: rootDir }); - // Adopt the absolute root scaffoldStack actually resolved. A root typed at - // the prompt may be relative or have a trailing slash; without this every - // later step (env writes, health probe, UI URL) would key off the raw - // string while the scaffold landed at the resolved path. - if (result.rootDir && result.rootDir !== rootDir) { - rootDir = result.rootDir; - state = { ...state, rootDir }; - } - // Persist through setup's active ConfigManager as well as scaffoldStack's - // initializer. Otherwise later setup saves (for example GitHub login or - // tunnel preferences) can write a stale in-memory config and silently - // discard the root that scaffoldStack recorded through its own manager. - await actions.persistStackRoot(rootDir); - const created = [...result.dirsCreated]; - initSettlement = { - status: "done", - detail: result.envCreated - ? `scaffolded stack at ${rootDir}${created.length ? ` (created ${created.join(", ")})` : ""}` - : `stack root ready at ${rootDir} (existing .env kept)`, - }; - } else { - // Reuse path: scaffolding is skipped, so nothing has recorded this root in - // config. Persist it now so a later `propr start` / `propr status` without - // --root targets this stack rather than an old saved root or the cwd. - await actions.persistStackRoot(rootDir); - initSettlement = { status: "skipped", detail: `using existing stack at ${rootDir} (.env preserved)` }; - } - - // Eligibility comes from the configured datastore itself, not scaffold - // artifacts. This recovers migrated databases with no durable administrator - // and follows the runtime's DB_FILENAME/DATA_DIR resolution. Configured - // paths outside the launcher's data bind mount cannot be safely inspected - // from the host and remain ineligible (fail closed). - datastoreAdminInspection = await actions.inspectDatastoreAdministrators(rootDir); - bootstrapIdentityEligible = - datastoreAdminInspection.status === "absent" || datastoreAdminInspection.status === "no-admin"; - if (datastoreAdminInspection.status === "uninspectable") { - const inspectionDetail = datastoreAdminInspection.detail ?? "configured datastore is unavailable"; - log(`administrator inspection: ${inspectionDetail}`); - } - // Inspect before reporting initialization success so this step has exactly - // one terminal settlement even when inspection itself throws. An - // uninspectable datastore is evaluated after auth resolves because demo - // mode does not require an instance administrator. - settle("init-stack", initSettlement); - } catch (error) { - settle("init-stack", { - status: "failed", - detail: `could not initialize stack: ${(error as Error).message}`, - nextAction: "Check directory permissions and that .env.example is available, then re-run setup.", - }); - return finish(); - } - - // 3. Pull images — core images by default, plus the shared agent image when - // the user selects an agent (defaulting to those detected on this host). - begin("pull-images"); - const detected = detectInstalledAgents(catalog); - try { - const requested = prompts.selectAgents - ? await prompts.selectAgents({ available: catalog.map((a) => a.type), detected }) - : detected; - // Guard the engine boundary: a renderer may hand back unknown or duplicate - // agent names. Keep only types we know about, de-duped (first occurrence - // wins), so unknown names never reach pullImages() and a duplicate can't - // double-apply credentials in the configure-agents step below. - const known = new Set(catalog.map((a) => a.type)); - selectedAgents = [...new Set(requested)].filter((type) => known.has(type)); - - const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log }); - if (pull.failedCore.length > 0) { - settle("pull-images", { - status: "failed", - detail: `failed to pull core image(s): ${pull.failedCore.join(", ")}`, - nextAction: "Check registry access / network and re-run setup; the stack cannot start without core images.", - }); - return finish(); - } - const pulledCount = pull.pulledCore.length + pull.pulledAgents.length; - if (pull.failedAgents.length > 0) { - settle("pull-images", { - status: "warning", - detail: `pulled ${pulledCount} image(s); ${pull.failedAgents.length} agent image(s) unavailable`, - nextAction: "Jobs using those agents fail until their images pull. Re-run `propr images pull` later.", - }); - } else { - settle("pull-images", { status: "done", detail: `pulled ${pulledCount} image(s)` }); - } - } catch (error) { - settle("pull-images", { - status: "failed", - detail: `could not pull images: ${(error as Error).message}`, - nextAction: "Check Docker and registry access, then re-run setup.", - }); - return finish(); - } - - // 4. Configure agents — record detected host credential dirs for the selected - // agents, non-destructively (never blanks an existing value). - begin("configure-agents"); - try { - if (selectedAgents.length === 0) { - settle("configure-agents", { - status: "skipped", - detail: "no agents selected", - nextAction: "Log in with an agent CLI on this host, then re-run setup to record its credentials.", - }); - } else { - const vars: Record = {}; - const existingEnv = actions.readEnvVars(rootDir); - for (const type of selectedAgents) { - const desc = catalog.find((a) => a.type === type); - if (!desc) continue; - for (const cred of desc.credentials) { - // A selected agent may not have logged in yet. Prepare its host mount - // before the stack starts so Docker never creates a root-owned path, - // and record it now so the post-login image validation sees exactly - // the mount the worker will use. - const configuredDir = existingEnv[cred.envKey]; - const effectiveDir = configuredDir?.trim() ? configuredDir : cred.defaultDir; - assertSafeAgentCredentialDir(effectiveDir, cred.envKey); - actions.prepareAgentCredentialDir(effectiveDir); - vars[cred.envKey] = effectiveDir; - } - } - const applied = actions.applyEnvSelection(rootDir, vars, { overwrite: false }); - const detailParts: string[] = []; - detailParts.push(applied.written.length > 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record"); - if (applied.skipped.length > 0) detailParts.push(`${applied.skipped.length} already set`); - settle("configure-agents", { status: "done", detail: detailParts.join("; ") }); - } - } catch (error) { - settle("configure-agents", { - status: "failed", - detail: `could not record agent credentials: ${(error as Error).message}`, - nextAction: "Correct invalid HOST_* credential paths and check write permissions on .env, then re-run setup.", - }); - return finish(); - } - - // 5. GitHub authentication — keep what works; only write the keys the user - // explicitly chose. Missing Connect/App credentials are a hard stop because - // every non-demo backend process exits before the health probe can pass. - begin("github-auth"); - let resolvedAuth: GithubAuthModeResult; - // Set by the relay path: `relayNote` drives a failed settle (and skips - // partial writes); `relayDoneDetail` carries the success line. Both stay unset - // for the keep / custom-App / no-prompt paths, which fall back to the - // mode-derived settle below. - let relayNote: { detail: string; nextAction?: string } | undefined; - let relayDoneDetail: string | undefined; - try { - const currentAuth = actions.detectGithubAuthMode(rootDir); - let authDecision: GithubAuthDecision | undefined; - if (prompts.configureGithubAuth) authDecision = await prompts.configureGithubAuth({ current: currentAuth }); - if (authDecision?.enrollRelay) { - const outcome = await enrollRelayForSetup(authDecision.enrollRelay.relayUrl); - relayNote = outcome.note; - relayDoneDetail = outcome.detail; - } else if (authDecision?.vars && Object.keys(authDecision.vars).length > 0) { - actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }); - } - resolvedAuth = relayDoneDetail - ? { mode: "relay", warnings: [] } - : actions.detectGithubAuthMode(rootDir); - } catch (error) { - settle("github-auth", { - status: "failed", - detail: `could not configure GitHub auth: ${(error as Error).message}`, - nextAction: "Check .env access and your GitHub auth settings, then re-run setup.", - }); - return finish(); - } - if (relayNote) { - settle("github-auth", { status: "failed", detail: relayNote.detail, nextAction: relayNote.nextAction }); - return finish(); - } - if (resolvedAuth.mode === "none") { - settle("github-auth", { - status: "failed", - detail: "no GitHub auth configured", - nextAction: "Choose ProPR Connect (default), configure your own GitHub App, or enable demo mode, then re-run setup.", - }); - return finish(); - } - - // Every non-demo start needs either an environment administrator or a - // durable one. Relay enrollment above already seeds its authenticated - // identity when the datastore is conclusively empty. On a keep rerun, the - // same identity can be recovered safely only when the stored GitHub session - // can access the installation already configured for this stack. - const demoModeEnabled = isTruthyEnvFlag(actions.readEnvVars(rootDir).PROPR_DEMO_MODE); - let keptRelayBootstrapIdentity: string | undefined; - const configuredAdministrators = (): string[] => - (actions.readEnvVars(rootDir).PROPR_ADMIN_USERS ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const durableAdministratorExists = datastoreAdminInspection?.status === "has-admin"; - if ( - !demoModeEnabled && - !durableAdministratorExists && - !bootstrapAdministratorSeeded && - configuredAdministrators().length === 0 && - bootstrapIdentityEligible && - resolvedAuth.mode === "relay" && - actions.hasGithubToken() - ) { - const env = actions.readEnvVars(rootDir); - const installationId = env.GH_INSTALLATION_ID?.trim(); - if (installationId) { - try { - const identity = await actions.fetchRelayInstallations({ - relayUrl: env.PROPR_GH_RELAY_URL?.trim() || undefined, - }); - const username = identity.username.trim(); - const ownsConfiguredInstallation = identity.installations.some( - (installation) => String(installation.installation_id) === installationId - ); - if (username && ownsConfiguredInstallation) { - const existingWhitelist = (env.GITHUB_USER_WHITELIST ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - const whitelistHasIdentity = existingWhitelist.some( - (value) => value.toLowerCase() === username.toLowerCase() - ); - actions.applyEnvSelection( - rootDir, - { - PROPR_ADMIN_USERS: username, - ...(!whitelistHasIdentity - ? { GITHUB_USER_WHITELIST: [...existingWhitelist, username].join(",") } - : {}), - }, - { overwrite: true } - ); - bootstrapAdministratorSeeded = true; - keptRelayBootstrapIdentity = username; - } - } catch (error) { - log(`administrator bootstrap: could not verify the configured relay identity: ${(error as Error).message}`); - } - } - } - - if ( - !demoModeEnabled && - !durableAdministratorExists && - !bootstrapAdministratorSeeded && - configuredAdministrators().length === 0 - ) { - const inspectionDetail = datastoreAdminInspection?.status === "uninspectable" - ? ` (${datastoreAdminInspection.detail ?? "the configured datastore could not be inspected"})` - : ""; - settle("github-auth", { - status: "failed", - detail: `no instance administrator is configured${inspectionDetail}`, - nextAction: - "Set PROPR_ADMIN_USERS to at least one GitHub username, repair the configured datastore, or re-run setup and enroll ProPR Connect with an authenticated GitHub account.", - }); - return finish(); - } - - // The GitHub App authenticates the backend to GitHub, but it does not - // authenticate this CLI user to the backend. Everything setup does after the - // stack starts (/api/status, agent configuration, settings, and repositories) - // is protected by bearer auth, so obtain the same user token as `propr login` - // before making any of those calls. Connect enrollment already guarantees a - // token; this covers custom-App and GitHub-only demo configurations alike. - if (!demoModeEnabled && !actions.hasGithubToken()) { - const reason = "Finishing setup requires a GitHub user token for protected backend API steps."; - if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { - await actions.loginWithGithub({ onLog: log }); - } - if (!actions.hasGithubToken()) { - settle("github-auth", { - status: "failed", - detail: `auth mode: ${resolvedAuth.mode}; GitHub user login is required to finish setup`, - nextAction: "Run `propr login`, then re-run `propr setup`; the existing stack configuration will be reused.", - }); - return finish(); - } - } - - if (relayDoneDetail) { - settle("github-auth", { status: "done", detail: relayDoneDetail }); - } else if (resolvedAuth.warnings.length > 0) { - // The mode resolves, but the shared detector flagged a partial/ambiguous - // configuration — surface it so the user can fix it before it bites later. - settle("github-auth", { - status: "warning", - detail: `auth mode: ${resolvedAuth.mode} — ${resolvedAuth.warnings.join("; ")}`, - }); - } else { - settle("github-auth", { - status: "done", - detail: keptRelayBootstrapIdentity - ? `auth mode: ${resolvedAuth.mode}; bootstrap administrator: ${keptRelayBootstrapIdentity}` - : `auth mode: ${resolvedAuth.mode}`, - }); - } - - // 5b. GitHub event intake — how the backend learns about GitHub events - // (routing WebSocket, polling, or direct webhooks). Written before startup - // because the API/daemon resolve GITHUB_EVENT_INTAKE_MODE at boot. Demo - // mode has no GitHub access, so there is nothing to ingest. - begin("intake"); - try { - if (resolvedAuth.mode === "demo") { - settle("intake", { status: "skipped", detail: "demo mode — no GitHub events to ingest" }); - } else { - const envNow = actions.readEnvVars(rootDir); - // Resolve the mode the backend would pick from today's `.env` (unset - // defaults to routing_websocket, the hosted relay path) so the prompt and - // any "kept current" message reflect what actually runs. - const { mode: currentMode } = resolveGithubEventIntakeMode({ - eventIntakeMode: envNow.GITHUB_EVENT_INTAKE_MODE, - enableGithubWebhooks: envNow.ENABLE_GITHUB_WEBHOOKS, - }); - // When `.env` already records an intake decision, default the prompt to - // "keep" so a blank Enter on a re-run can't silently flip a working config - // (e.g. disable existing direct webhooks). This also covers older `.env` - // files that only carry the legacy `ENABLE_GITHUB_WEBHOOKS` boolean: it - // still resolves to a real `currentMode`, so a blank Enter must keep that - // rather than rewrite it to the auth-derived recommendation. Only a truly - // fresh install (neither key set) falls back to the recommendation. - const intakeConfigured = - envNow.GITHUB_EVENT_INTAKE_MODE !== undefined || envNow.ENABLE_GITHUB_WEBHOOKS !== undefined; - const defaultMode = defaultIntakeChoice(resolvedAuth.mode, { intakeConfigured }); - let decision: GithubIntakeDecision | undefined; - if (prompts.configureIntake) { - decision = await prompts.configureIntake({ authMode: resolvedAuth.mode, defaultMode, currentMode }); - } - // The mode that will be in effect after this step — the explicit pick, or - // the current `.env` value when the user keeps it. `effectiveEnv` mirrors - // what `.env` holds *after* any write so the prerequisite check below sees - // the freshly written secret/mode, not the pre-write snapshot. - let effectiveMode = currentMode; - let effectiveEnv = envNow; - let detail: string; - if (decision && !decision.keep && decision.mode) { - // buildIntakeEnvVars rejects an empty webhook secret — caught below and - // surfaced as a warning rather than writing a config the API won't boot. - const vars = buildIntakeEnvVars(decision.mode, { webhookSecret: decision.webhookSecret }); - actions.applyEnvSelection(rootDir, vars, { overwrite: true }); - effectiveMode = decision.mode; - effectiveEnv = { ...envNow, ...vars }; - detail = `intake: ${intakeModeLabel(decision.mode)}`; - } else { - detail = `intake: kept current (${intakeModeLabel(currentMode)})`; - } - // Validate the resolved mode against the shared prerequisite rules so a - // silently-broken intake config (most commonly routing_websocket without - // relay auth + a relay token) surfaces here instead of as a backend boot - // failure after `propr start`. - const prereq = validateIntakeModePrerequisites({ - intakeMode: effectiveMode, - authMode: resolvedAuth.mode, - routingUrl: effectiveEnv.PROPR_ROUTING_URL, - relayUrl: effectiveEnv.PROPR_GH_RELAY_URL, - relayToken: effectiveEnv.PROPR_GH_RELAY_TOKEN, - webhookSecret: effectiveEnv.GH_WEBHOOK_SECRET, - }); - if (prereq.valid) { - settle("intake", { status: "done", detail }); - } else { - settle("intake", { - status: "failed", - detail: `${detail} — ${prereq.errors.join("; ")}`, - nextAction: - effectiveMode === "routing_websocket" - ? "Enroll with the hosted relay (`propr relay enroll`) so routing_websocket has relay auth + a relay token, or choose polling." - : "Resolve the missing intake prerequisites in .env, then re-run setup.", - }); - return finish(); - } - } - } catch (error) { - // An IntakeConfigError (e.g. direct webhooks chosen with no secret) is - // non-blocking: leave intake as-is and tell the user how to finish it. - settle("intake", { - status: "warning", - detail: `could not configure GitHub intake: ${(error as Error).message}`, - nextAction: - "Set GITHUB_EVENT_INTAKE_MODE (and GH_WEBHOOK_SECRET for direct_webhook) in .env, then re-run setup.", - }); - } - - // 6. Start the stack and validate backend health. A running stack is reused, - // not recreated, so user data and live work are untouched. - begin("start-stack"); - try { - const alreadyRunning = await actions.isStackRunning(rootDir); - const startConfirmed = prompts.confirmStartStack ? await prompts.confirmStartStack({ rootDir, alreadyRunning }) : true; - if (!startConfirmed) { - settle("start-stack", { - status: "skipped", - detail: "stack not started — setup is incomplete until the backend is running", - nextAction: "Start it later with `propr start`, or re-run `propr setup` and confirm startup.", - }); - } else { - if (alreadyRunning) { - log("stack already running — leaving it intact"); - } else { - await actions.startStack({ rootDir, onLog: log }); - } - const health = await actions.checkBackendHealth({ rootDir }); - if (health.healthy) { - backendReady = true; - settle("start-stack", { - status: "done", - detail: alreadyRunning ? `stack already running — ${health.detail}` : health.detail, - }); - } else { - settle("start-stack", { - status: "failed", - detail: health.detail, - // The backend answered, so access failures need account-oriented - // remediation rather than service-health troubleshooting. A 401 calls - // for login; a 403 calls for permission/configuration checks. - nextAction: health.accessFailure === "unauthorized" - ? "Run `propr login` to obtain a GitHub user token, then re-run `propr setup`; the running stack will be reused." - : health.accessFailure === "forbidden" - ? "Check the authenticated account, the stack's bootstrap-admin configuration, and its access permissions, then re-run `propr setup`; the running stack will be reused." - : "Run `propr status` / `propr remote-status` and inspect the API logs, then re-run setup.", - }); - } - } - } catch (error) { - settle("start-stack", { - status: "failed", - detail: `could not start the stack: ${(error as Error).message}`, - nextAction: "Run `propr start` to see the full startup output.", - }); - return finish(); - } - - // Reuse an upload-compatible token from `gh auth token` when setup already - // authenticated the operator through the CLI. This is best-effort and does - // not make an otherwise healthy setup fail; the same credential can always - // be added later in Settings without restarting the stack. - if (backendReady && resolvedAuth.mode !== 'demo') { - try { - const previewCredential = await actions.configureVisualPreviewCredential(rootDir); - if (previewCredential.status === 'configured') { - log(`visual previews: configured from the gh CLI session${previewCredential.githubUsername ? ` (@${previewCredential.githubUsername})` : ''}`); - } else if (previewCredential.status === 'already-configured') { - log('visual previews: upload credential already configured'); - } else if (previewCredential.status === 'unsupported') { - log('visual previews: the gh CLI token type cannot upload attachments; add a PAT in Settings'); - } else if (previewCredential.status === 'environment-managed') { - log('visual previews: GITHUB_VISUAL_PREVIEW_TOKEN is invalid; replace or remove that environment override'); - } - } catch (error) { - log(`visual previews: could not import the gh CLI token (${(error as Error).message}); add a PAT in Settings`); - } - } - - // 7. Enable agents in the running backend — add the selected agents that are - // missing (existing ones are never disabled or deleted) and, on - // confirmation, authenticate the ones that support an image login. This - // runs after startup because it talks to the live backend API. Any problem - // is a non-blocking warning: agents can always be configured later. - begin("enable-agents"); - // This step talks to the live backend API, so it only makes sense once the - // stack is up. When the backend is unavailable, skip rather than fire - // doomed API calls that would surface as confusing warnings. - if (!backendReady) { - settle("enable-agents", { - status: "skipped", - detail: "backend is not healthy — agents are enabled through the running backend", - nextAction: "Start the stack (`propr start`), then re-run `propr setup` to enable and authenticate the selected agents.", - }); - } else { - try { - const outcome = await runAgentSetup({ - rootDir, - selectedAgents, - actions, - confirmLogin: prompts.confirmAgentLogin, - onLog: log, - }); - if (selectedAgents.length === 0) { - settle("enable-agents", { - status: "skipped", - detail: "no agents selected", - nextAction: "Enable agents later in the UI or with `propr agent add`.", - }); - } else { - const parts: string[] = []; - if (outcome.added.length > 0) parts.push(`enabled ${outcome.added.join(", ")}`); - if (outcome.alreadyConfigured.length > 0) parts.push(`${outcome.alreadyConfigured.length} already configured`); - if (outcome.authenticated.length > 0) parts.push(`authenticated ${outcome.authenticated.join(", ")}`); - if (outcome.authFailed.length > 0) parts.push(`${outcome.authFailed.length} login(s) did not complete`); - if (outcome.validated.length > 0) parts.push(`connectivity verified: ${outcome.validated.join(", ")}`); - if (outcome.validationFailed.length > 0) parts.push(`${outcome.validationFailed.length} connectivity check(s) need attention`); - const detail = parts.length > 0 ? parts.join("; ") : "no changes needed"; - if (outcome.errors.length > 0 || outcome.authFailed.length > 0 || outcome.validationFailed.length > 0) { - settle("enable-agents", { - status: "warning", - detail: outcome.errors.length > 0 ? `${detail}; ${outcome.errors.join("; ")}` : detail, - nextAction: outcome.nextCommands.length > 0 - ? `Run: ${outcome.nextCommands.map((command) => `\`${command}\``).join("; then ")}` - : "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", - }); - } else { - settle("enable-agents", { status: "done", detail }); - } - } - } catch (error) { - // runAgentSetup is built not to throw for expected conditions; anything that - // escapes is treated as a non-blocking warning so it can't abort setup. - settle("enable-agents", { - status: "warning", - detail: `could not configure agents: ${(error as Error).message}`, - nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", - }); - } +function reportVisualPreviewCredential(result: VisualPreviewCredentialSetupResult, reporter: SetupReporter): void { + let line: string | undefined; + if (result.status === "configured") { + line = `visual previews: configured from the gh CLI session${result.githubUsername ? ` (@${result.githubUsername})` : ""}`; + } else if (result.status === "already-configured") { + line = "visual previews: upload credential already configured"; + } else if (result.status === "unsupported") { + line = "visual previews: the gh CLI token type cannot upload attachments; add a PAT in Settings"; + } else if (result.status === "environment-managed") { + line = "visual previews: GITHUB_VISUAL_PREVIEW_TOKEN is invalid; replace or remove that environment override"; } + if (!line) return; + reporter.onLog?.(line); + reporter.onProgress?.({ type: "log", line }); +} - // 8. Whitelist — restrict who can trigger ProPR. Written non-destructively. - begin("whitelist"); - try { - const envNow = actions.readEnvVars(rootDir); - const currentWhitelist = (envNow.GITHUB_USER_WHITELIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); - const demoMode = resolvedAuth.mode === "demo"; - let whitelist: string[] | null = null; - if (prompts.configureWhitelist) whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); - if (whitelist !== null) { - // Trim, drop blanks, and de-dupe (first occurrence wins) so the value - // matches saveWhitelist's "cleaned, de-duped usernames" contract — a - // duplicate entry would otherwise inflate the saved count and settings. - const cleaned = [...new Set(whitelist.map((s) => s.trim()).filter(Boolean))]; - // Prefer the settings API when the backend is up so the change applies - // immediately (and never overwrites unrelated settings); always mirror into - // .env so it survives a restart. Falls back to .env if the API is down. - const backendRunning = backendReady && await actions.isStackRunning(rootDir); - const saved = await saveWhitelist({ - users: cleaned, - backendRunning, - saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users), - saveViaEnv: (users) => { - // A non-empty list is written; clearing to "none" must *remove* the key - // rather than blank it. applyEnvSelection ignores blank values (so it - // never clobbers a value), which means `GITHUB_USER_WHITELIST=""` would - // be skipped and the old list would survive on the next restart — so we - // delete the key outright instead. - if (users.length > 0) { - actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }); - } else { - actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]); - } - }, - }); - const where = saved.target === "settings" ? "via settings API" : "in .env"; - const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`; - if (saved.error) { - settle("whitelist", { - status: "warning", - detail: `${summary}; settings update failed: ${saved.error}`, - nextAction: "The whitelist is in .env; it will apply when the backend restarts.", - }); - } else { - settle("whitelist", { status: "done", detail: summary }); - } - } else if (currentWhitelist.length > 0) { - settle("whitelist", { status: "done", detail: `${currentWhitelist.length} user(s) already allowed` }); - } else if (demoMode) { - settle("whitelist", { status: "skipped", detail: "demo mode — whitelist not required" }); - } else { - settle("whitelist", { - status: "warning", - detail: "no whitelist configured — any authenticated GitHub user could trigger processing", - nextAction: "Set GITHUB_USER_WHITELIST in .env to a comma-separated list of allowed usernames.", - }); - } - } catch (error) { - settle("whitelist", { - status: "failed", - detail: `could not configure the whitelist: ${(error as Error).message}`, - nextAction: "Check .env access, then re-run setup.", - }); - return finish(); - } +function createSetupActions( + configManager: ConfigManager | undefined, + overrides: Partial | undefined, + reporter: SetupReporter, +): SetupActions { + const actions = { ...createDefaultActions(configManager), ...overrides } as SetupActions; + const checkBackendHealth = actions.checkBackendHealth; + let previewCredentialAttempted = false; - // 9. Repository (optional) — adding a repo must never fail the whole run. - begin("repo"); - // Adding a repo goes through the running backend's API, so skip it (without - // even prompting) when the backend is unavailable — there is nothing - // to add it to yet. - if (!backendReady) { - settle("repo", { - status: "skipped", - detail: "backend is not healthy — a repository is connected through the running backend", - nextAction: "Start the stack (`propr start`), then add one with `propr repo add `.", - }); - } else { - try { - // The prompt itself is part of this optional step — a renderer that throws - // while collecting the repo must degrade to a warning, not abort the run. - const repoSelection = prompts.addRepository ? await prompts.addRepository({ rootDir }) : null; - if (!repoSelection) { - settle("repo", { status: "skipped", detail: "no repository added" }); - } else { - try { - await actions.addRepository(repoSelection, rootDir); - settle("repo", { status: "done", detail: `monitoring ${repoSelection.fullName}` }); - } catch (error) { - settle("repo", { - status: "warning", - detail: `could not add ${repoSelection.fullName}: ${(error as Error).message}`, - nextAction: "Add it later with `propr repo add `.", - }); - } - } - } catch (error) { - settle("repo", { - status: "warning", - detail: `could not collect a repository to add: ${(error as Error).message}`, - nextAction: "Add it later with `propr repo add `.", - }); - } - } + return { + ...actions, + async checkBackendHealth(params) { + const health = await checkBackendHealth(params); + if (!health.healthy || previewCredentialAttempted) return health; + previewCredentialAttempted = true; - // 10. UI (optional) — surface the URL and, when the user confirms, actually - // open it in their default browser. - begin("launch-ui"); - if (!backendReady) { - settle("launch-ui", { - status: "skipped", - detail: "UI not opened — the backend is not healthy", - nextAction: "Resolve the startup failure, then re-run `propr setup`.", - }); - return finish(); - } - let uiUrl = ""; - try { - uiUrl = await actions.resolveUiUrl(rootDir); - } catch { - /* non-fatal: just omit the URL */ - } - let opened = false; - let openFailed = false; - try { - // The prompt only asks *whether* to open; the engine performs the open so - // both renderers behave identically and neither has to import a launcher. - const wantsOpen = uiUrl && prompts.launchUi ? await prompts.launchUi({ url: uiUrl }) : false; - if (wantsOpen) { try { - await actions.openUrl(uiUrl); - opened = true; + if (actions.detectGithubAuthMode(params.rootDir).mode === "demo") return health; + reportVisualPreviewCredential(await actions.configureVisualPreviewCredential(params.rootDir), reporter); } catch { - // Headless host, no launcher, etc. — fall back to just printing the URL. - openFailed = true; + const line = "visual previews: could not import the gh CLI token; add a PAT in Settings"; + reporter.onLog?.(line); + reporter.onProgress?.({ type: "log", line }); } - } - } catch { - /* opening the UI is best-effort; a failed launch prompt must not fail setup */ - } - settle("launch-ui", { - status: opened ? "done" : "skipped", - detail: uiUrl - ? openFailed - ? `UI available at ${uiUrl} (could not open a browser automatically)` - : opened - ? `opened ${uiUrl}` - : `UI available at ${uiUrl}` - : "UI URL unavailable", - }); + return health; + }, + }; +} - return finish(); +export async function runSetup(options: RunSetupOptions = {}): Promise { + const { configManager, actions: overrides, root, ...portable } = options; + const reporter = portable.reporter ?? {}; + const actions = createSetupActions(configManager, overrides, reporter); + return runLocalSetup({ + ...portable, + root: resolveSetupRoot(configManager, root), + actions, + }); } -/** - * Detect an environment problem that blocks the entire flow: Docker missing or - * its daemon unreachable. Other failures (e.g. GitHub auth) are addressed by - * later steps and must not abort setup here. - * - * Keyed off the structured `Docker` check group rather than exact check names, - * so re-wording a check in checkCommands.ts can't silently let setup continue - * past a missing/unreachable engine. Within that group only the engine checks - * ("Docker installed", "Docker daemon") ever report `fail`; the socket check is - * informational and tops out at `warn`, so a `fail` here always means Docker - * itself cannot run the stack. - */ -function blockingDockerFailure(outcome: ChecksOutcome): string | undefined { - return outcome.results.find((r) => r.group === "Docker" && r.status === "fail")?.detail; +export function retrySetup(previous: SetupRunResult, options: Omit = {}): Promise { + const { configManager, actions: overrides, ...portable } = options; + const reporter = portable.reporter ?? {}; + const actions = createSetupActions(configManager, overrides, reporter); + return retryLocalSetup(previous, { ...portable, actions }); } diff --git a/packages/cli/src/commands/setup/github.ts b/packages/cli/src/commands/setup/github.ts index 3c93c457e..8c377b107 100644 --- a/packages/cli/src/commands/setup/github.ts +++ b/packages/cli/src/commands/setup/github.ts @@ -1,269 +1 @@ -/** - * GitHub event-intake + user-whitelist helpers for `propr setup`. - * - * Two concerns the setup wizard must guide a new user through, factored out of - * the engine so the decision logic lives in one tested place and both renderers - * (Ink + readline) share it: - * - * - **Intake mode** — how the backend learns about GitHub events, selected by - * the `GITHUB_EVENT_INTAKE_MODE` `.env` key (the legacy `ENABLE_GITHUB_WEBHOOKS` - * boolean is deprecated and no longer selects the mode). Three paths: - * routing_websocket — events stream over the hosted ProPR routing - * WebSocket; no inbound webhook listener and no own - * GitHub App required. The default, and only usable - * with relay auth (PROPR_GH_RELAY_TOKEN). - * polling — the daemon polls the GitHub API on an interval; works - * with any usable GitHub auth and needs no inbound URL. - * direct_webhook — GitHub posts directly to the local API; requires an - * own GitHub App plus a signing secret so forged - * payloads are rejected. - * {@link buildIntakeEnvVars} turns a chosen mode into the exact `.env` keys - * (`GITHUB_EVENT_INTAKE_MODE`, and `GH_WEBHOOK_SECRET` for direct webhooks), - * refusing to produce a direct_webhook config without a secret — the API - * would otherwise refuse to boot. - * - * - **User whitelist** — which GitHub users may trigger ProPR. Saved through - * the settings API when the backend is running (a partial update that never - * clobbers unrelated settings), and mirrored into `.env` so the value - * survives a restart. {@link saveWhitelist} owns that routing and degrades to - * an `.env`-only write when the backend is down or the API call fails. - * - * Like the rest of the setup module these helpers are UI-agnostic and free of - * Docker/network imports: side effects are passed in as callbacks so the engine - * binds them to the real API/`.env` and tests drive the whole thing in memory. - */ - -import type { GithubAuthMode, GithubEventIntakeMode } from "@propr/shared"; - -/** - * How the backend ingests GitHub events. Aliased to the shared - * {@link GithubEventIntakeMode} so the wizard and the backend boot path can't - * drift on the values the `GITHUB_EVENT_INTAKE_MODE` `.env` key accepts: - * routing_websocket — events stream over the ProPR routing WebSocket (default) - * polling — the daemon polls the GitHub API; no inbound exposure - * direct_webhook — GitHub posts to a local /webhook endpoint (needs a secret) - */ -export type GithubIntakeMode = GithubEventIntakeMode; - -/** Documentation surfaced in the intake prompt's detail text. */ -export const INTAKE_DOCS_URL = "https://docs.propr.dev/docs/architecture/daemon"; -/** Documentation for configuring direct webhook delivery. */ -export const WEBHOOK_DOCS_URL = "https://docs.propr.dev/docs/tutorials/setup-server"; - -/** - * Outcome of the intake prompt the renderer hands back to the engine. Mirrors - * {@link GithubAuthDecision}: a `keep` leaves the current `.env` untouched, - * otherwise the chosen `mode` (plus a secret for webhooks) is applied. - */ -export interface GithubIntakeDecision { - /** Keep the existing intake configuration untouched. */ - keep?: boolean; - /** The intake mode the user picked. */ - mode?: GithubIntakeMode; - /** Signing secret, required (and only used) when `mode === "direct_webhook"`. */ - webhookSecret?: string; -} - -/** Thrown when an intake selection is missing required input (e.g. a webhook secret). */ -export class IntakeConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "IntakeConfigError"; - } -} - -/** - * The intake mode to pre-select for a given GitHub auth mode. The hosted routing - * WebSocket is the product default, but it only works with relay auth (it needs - * a relay token and the shared ProPR App), so it's recommended only when relay - * auth is configured. Every other auth mode falls back to polling, which works - * with any usable GitHub auth and needs no inbound network exposure — and unlike - * direct webhooks requires no public URL or own GitHub App. - */ -export function defaultIntakeMode(authMode: GithubAuthMode): GithubIntakeMode { - return authMode === "relay" ? "routing_websocket" : "polling"; -} - -/** - * The intake choice the prompt should pre-select. - * - * On a re-run where `.env` already carries an intake decision - * (`GITHUB_EVENT_INTAKE_MODE` is set), the safe default is `"keep"`: a blank Enter - * must never silently rewrite a working config — e.g. an existing - * `direct_webhook` install must not flip to `routing_websocket` just because the - * auth-derived recommendation differs. This upholds the setup engine's re-run - * safety model (keep existing config unless the user explicitly changes it). Only - * on a fresh install, with no intake config yet, do we fall back to the - * auth-derived recommendation from {@link defaultIntakeMode}. - */ -export function defaultIntakeChoice( - authMode: GithubAuthMode, - opts: { intakeConfigured: boolean } -): GithubIntakeMode | "keep" { - return opts.intakeConfigured ? "keep" : defaultIntakeMode(authMode); -} - -/** - * Translate a chosen {@link GithubIntakeMode} into the `.env` keys it implies. - * The mode is selected by `GITHUB_EVENT_INTAKE_MODE`, the value the backend boot - * path resolves (see resolveGithubEventIntakeMode); the deprecated - * `ENABLE_GITHUB_WEBHOOKS` boolean is intentionally never written here. - * - * - `routing_websocket` / `polling` set `GITHUB_EVENT_INTAKE_MODE` to the mode - * and nothing else — routing events arrive over the relay WebSocket and - * polling pulls them from the API, neither needing a local webhook listener. - * A previously recorded `GH_WEBHOOK_SECRET` is intentionally *not* cleared: - * `applyEnvSelection`/`upsertEnvVars` only set keys, never remove them. The - * leftover secret is inert while not in direct_webhook mode (the API never - * reads it), but callers wanting a pristine `.env` must remove it by hand. - * - `direct_webhook` records the signing secret alongside the mode. An - * empty/whitespace secret is rejected with {@link IntakeConfigError}: the API - * refuses to boot in direct_webhook mode with no secret, so writing it would - * only break startup. - */ -export function buildIntakeEnvVars( - mode: GithubIntakeMode, - opts: { webhookSecret?: string } = {} -): Record { - switch (mode) { - case "routing_websocket": - case "polling": - return { GITHUB_EVENT_INTAKE_MODE: mode }; - case "direct_webhook": { - const secret = (opts.webhookSecret ?? "").trim(); - if (!secret) { - throw new IntakeConfigError( - "A webhook secret is required for direct webhooks — the API refuses to start without one." - ); - } - return { GITHUB_EVENT_INTAKE_MODE: "direct_webhook", GH_WEBHOOK_SECRET: secret }; - } - } -} - -/** A short, human-readable label for an intake mode, shared by both renderers. */ -export function intakeModeLabel(mode: GithubIntakeMode): string { - switch (mode) { - case "routing_websocket": - return "ProPR routing WebSocket (hosted relay)"; - case "polling": - return "polling (no inbound webhooks)"; - case "direct_webhook": - return "direct webhooks (signing secret recorded)"; - } -} - -/** - * One intake mode's availability under a given GitHub auth mode, for the intake - * prompt. Each renderer maps this onto a selectable (or inactive) option. - */ -export interface IntakeModeOption { - /** The intake mode this entry describes. */ - mode: GithubIntakeMode; - /** False when the chosen auth mode cannot support this intake path. */ - available: boolean; - /** - * A short note for the renderer to surface next to the option: when - * `available` is false this is *why* the path is closed; when true it is an - * optional caveat (e.g. polling's production-suitability warning). - */ - note?: string; -} - -/** - * The intake modes to show for a given GitHub auth mode, in display order, each - * flagged available or not. Unavailable modes are intentionally still returned - * so the prompt can show them inactive with the reason — a new user sees the - * full set and learns why a path is closed rather than wondering where it went. - * - * The availability rules mirror {@link validateIntakeModePrerequisites} so the - * prompt and the backend boot-time check can never disagree: - * - routing_websocket needs the ProPR token relay; a custom GitHub App can't use it. - * - direct_webhook needs your own GitHub App; the ProPR relay can't deliver to it. - * - polling works with either usable auth, but is not recommended for production. - */ -export function intakeModeOptions(authMode: GithubAuthMode): IntakeModeOption[] { - const relay = authMode === "relay"; - const app = authMode === "app"; - return [ - { - mode: "routing_websocket", - available: relay, - note: relay - ? undefined - : "needs the ProPR GitHub App (token relay); not available with a custom GitHub App", - }, - { - mode: "polling", - available: relay || app, - note: - relay || app - ? "not recommended for production: subject to GitHub API rate limits and delayed event detection (depends on the polling interval and the number of repos/PRs/issues)" - : "needs usable GitHub auth — configure the token relay or a custom GitHub App first", - }, - { - mode: "direct_webhook", - available: app, - note: app - ? undefined - : "needs your own custom GitHub App; not available with the ProPR token relay", - }, - ]; -} - -// --------------------------------------------------------------------------- -// Whitelist persistence. -// --------------------------------------------------------------------------- - -/** Where {@link saveWhitelist} persisted the whitelist. */ -export interface SaveWhitelistResult { - /** The store the value was written to as its source of truth. */ - target: "settings" | "env"; - /** Number of users in the saved whitelist (0 means cleared). */ - count: number; - /** - * Set when a settings-API save was attempted but failed, after which the - * helper fell back to `.env`. Surfaced as a warning by the caller. - */ - error?: string; -} - -/** Inputs for {@link saveWhitelist}. Side effects are injected so it stays pure-ish and testable. */ -export interface SaveWhitelistParams { - /** The cleaned, de-duped usernames to persist (may be empty to clear). */ - users: string[]; - /** Whether the local backend is up — gates the settings-API path. */ - backendRunning: boolean; - /** Persist through the running backend's settings API (partial update). */ - saveViaSettings(users: string[]): Promise; - /** Persist into `.env` (non-destructive, single key). */ - saveViaEnv(users: string[]): void; -} - -/** - * Persist the user whitelist, preferring the settings API when the backend is - * running so the change takes effect immediately without a restart, and always - * mirroring into `.env` so it survives one. If the API call fails we fall back - * to the `.env` write and report the error rather than abort setup. - * - * The settings-API path issues a *partial* update (only the whitelist key), so - * unrelated settings are never overwritten. - */ -export async function saveWhitelist(params: SaveWhitelistParams): Promise { - const { users, backendRunning, saveViaSettings, saveViaEnv } = params; - if (backendRunning) { - try { - await saveViaSettings(users); - // Mirror into `.env` so the whitelist persists across `propr start`. - saveViaEnv(users); - return { target: "settings", count: users.length }; - } catch (error) { - // The backend rejected the update (or was unreachable after all) — keep - // the value in `.env` so it is not lost, and surface why. - saveViaEnv(users); - return { target: "env", count: users.length, error: (error as Error).message }; - } - } - saveViaEnv(users); - return { target: "env", count: users.length }; -} +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setup/hostActions.ts b/packages/cli/src/commands/setup/hostActions.ts new file mode 100644 index 000000000..af1aa4746 --- /dev/null +++ b/packages/cli/src/commands/setup/hostActions.ts @@ -0,0 +1,242 @@ +import { mkdirSync } from "node:fs"; +import { hostname } from "node:os"; +import { isAbsolute, normalize } from "node:path"; +import { DEFAULT_PROPR_GH_RELAY_URL } from "@propr/shared"; +import { + applyEnvSelection, + clearEnvKeys, + classifyBackendAccessError, + detectGithubAuthMode, + inspectDatastoreAdministrators, + inspectStackInit, + readEnvVars, + type PullImagesResult, + type SetupActions, +} from "@propr/local-setup"; +import type { ConfigManager } from "../../config/index.js"; +import type { RelayClientOptions } from "../../api/relay.js"; +import { localhostServiceUrl } from "../../utils/dockerPort.js"; +import { createDefaultAgentSetupActions } from "./agentHostActions.js"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { + if (!isAbsolute(path) || normalize(path) === "/" || path.includes(":") || /[\u0000-\u001f\u007f-\u009f]/.test(path)) { + throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); + } +} + +export function createDefaultActions(configManager?: ConfigManager): SetupActions { + /** A client pointed at the local stack's API port (not the saved remote URL). */ + const localApiClient = async (rootDir: string): Promise => { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); + const options = { baseUrl: localhostServiceUrl(cfg.apiPort) }; + // Keep the local client on setup's active profile and, importantly, the + // token that an in-progress setup login just stored. Creating an unrelated + // manager here can otherwise lose profile context and call protected local + // endpoints without the token setup has already obtained. + return configManager + ? createApiClientWithConfig(configManager, options) + : createApiClient(options); + }; + + return { + // Agent enablement + image-login actions, bound to the local stack. + ...createDefaultAgentSetupActions(configManager), + async runChecks(options) { + const { runChecks } = await import("../checkCommands.js"); + return runChecks(options); + }, + inspectStackInit, + inspectDatastoreAdministrators, + async scaffoldStack(options) { + const { scaffoldStack } = await import("../initStack.js"); + return scaffoldStack(options); + }, + async persistStackRoot(rootDir) { + // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path + // records the root too. Best-effort: without a config there is nowhere to + // persist it (tests run this way), so it is simply a no-op. + await configManager?.setStackRoot(rootDir); + }, + readEnvVars, + applyEnvSelection, + clearEnvKeys, + detectGithubAuthMode, + prepareAgentCredentialDir(path) { + assertSafeAgentCredentialDir(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + }, + async pullImages({ rootDir, agentTypes, onLog }) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + const selected = new Set(agentTypes); + const result: PullImagesResult = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] }; + + for (const [key, tag] of Object.entries(cfg.images)) { + if (key === "docs" && !cfg.docsEnabled) continue; + const isAgent = key === "agent"; + // Pull the shared agent image when the user selected any agent; core images + // (api/worker/daemon/redis/…) always pull. + if (isAgent && selected.size === 0) continue; + + onLog?.(`pulling ${tag}…`); + // Async exec keeps the event loop free so the wizard's Ink spinner keeps + // animating while the (often slow) pull runs, instead of freezing. + const pulled = await orch.dockerAsync(["pull", tag]); + if (pulled.status === 0) { + try { + orch.tagAgentLatest(key, tag); + } catch { + /* best-effort local retag; the pull itself succeeded */ + } + (isAgent ? result.pulledAgents : result.pulledCore).push(tag); + } else { + (isAgent ? result.failedAgents : result.failedCore).push(tag); + } + } + return result; + }, + async isStackRunning(rootDir) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + return orch.isStackRunningAsync(cfg); + }, + async startStack({ rootDir, ui, docs, onLog }) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + // Pre-create the host Vibe prompt-cache dir owned by this user so Docker + // does not auto-create it as root on first bind-mount — a root-owned dir + // would fail the writability check and block future `propr start` runs. + try { + const { ensureVibePromptCacheDir } = await import("../initStack.js"); + ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir); + } catch { + /* best-effort: startup validation will surface an actionable error */ + } + const validation = orch.validateEnv(cfg); + for (const warning of validation.warnings) onLog?.(`warning: ${warning}`); + if (!validation.ok) { + throw new Error(`stack environment is not ready:\n - ${validation.errors.join("\n - ")}`); + } + // Use the async start path: `propr setup` drives this from behind a live + // Ink TUI, so the blocking synchronous startStack would freeze the spinner + // and swallow keystrokes for the seconds-to-minutes a cold start takes. + await orch.ensureNetworkAsync(cfg, onLog); + await orch.startStackAsync(cfg, { + ui: ui ?? configManager?.getUiEnabled() ?? true, + docs: docs ?? cfg.docsEnabled, + onLog, + }); + }, + async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) { + const { getSystemStatus } = await import("../../api/system.js"); + const client = await localApiClient(rootDir); + const deadline = Date.now() + timeoutMs; + let lastError = "no response"; + // Containers take a few seconds to report healthy; poll until the deadline. + do { + try { + const status = await getSystemStatus(client); + if (String(status.api).toLowerCase() === "healthy") { + return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` }; + } + lastError = `API reports "${status.api}"`; + } catch (error) { + // A 401/403 is not an unhealthy backend — the API answered but denied + // this protected request. Return immediately so setup does not stall + // on a running backend, while preserving whether remediation requires + // authentication (401) or an authorization/configuration check (403). + const accessFailure = classifyBackendAccessError(error); + if (accessFailure) return accessFailure; + lastError = (error as Error).message; + } + if (Date.now() >= deadline) break; + await sleep(2_000); + } while (Date.now() < deadline); + return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; + }, + async addRepository({ fullName, alias, baseBranch }, rootDir) { + const { addRepo } = await import("../../api/repos.js"); + // Point the client at this stack's API port rather than the saved remote. + const client = await localApiClient(rootDir); + await addRepo(fullName, { alias, baseBranch }, client); + }, + async resolveUiUrl(rootDir) { + const { getHostConfig } = await import("../../orchestrator/index.js"); + const { cfg } = await getHostConfig({ configManager, root: rootDir }); + return localhostServiceUrl(cfg.uiPort); + }, + async openUrl(url) { + // Open in the host's default browser with the platform launcher. Detached + // and unref'd so the wizard isn't held open by the child, with stdio + // ignored so the launcher can't scribble over the TUI. + const { spawn } = await import("node:child_process"); + const platform = process.platform; + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.once("error", reject); + // The launcher returns immediately; once it has spawned we're done. + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); + }, + async saveWhitelistSetting(rootDir, users) { + const { updateSetting } = await import("../../api/settings.js"); + // Point the client at this stack's API port rather than the saved remote. + const client = await localApiClient(rootDir); + await updateSetting("github_user_whitelist", users, client); + }, + hasGithubToken() { + return Boolean(configManager?.getGithubToken()); + }, + async fetchRelayInstallations({ relayUrl }) { + const { fetchAuthenticatedUser } = await import("../../api/relay.js"); + const me = await fetchAuthenticatedUser(relayClient(relayUrl)); + return { username: me.username, installations: me.installations }; + }, + async enrollRelay({ relayUrl, installationId, label }) { + const { enrollRelayToken } = await import("../../api/relay.js"); + const client = relayClient(relayUrl); + // Default the token label to the hostname, mirroring `propr relay enroll`. + const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() }); + return { relayUrl: client.baseUrl, token: result.token }; + }, + async loginWithGithub({ onLog } = {}) { + if (!configManager) return false; + const { loginWithGithubCli } = await import("../../auth/githubLogin.js"); + const result = await loginWithGithubCli(configManager, { interactive: true, onLog }); + if (!result.ok) onLog?.(result.message); + return result.ok; + }, + getTunnelEnabled(rootDir) { + return configManager?.getTunnelEnabled(rootDir); + }, + }; + + /** + * Build a relay client bound to the stored GitHub token. The hosted relay is + * the default base URL; an explicit `relayUrl` (self-hosted) overrides it. + */ + function relayClient(relayUrl?: string): RelayClientOptions { + const githubToken = configManager?.getGithubToken(); + if (!githubToken) { + throw new Error("Not logged in to GitHub. Run `propr login` first."); + } + return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken }; + } +} + +/** + * Run the setup flow end to end, in a safe order, driven by the supplied + * prompts and reflected through the reporter. Returns the final step state and + * the environment-check outcome. Never throws for expected conditions (a failed + * required step stops the flow and is reported in the returned state); only + * truly unexpected programmer errors propagate. + */ diff --git a/packages/cli/src/commands/setup/state.ts b/packages/cli/src/commands/setup/state.ts index 5a4e821e7..8c377b107 100644 --- a/packages/cli/src/commands/setup/state.ts +++ b/packages/cli/src/commands/setup/state.ts @@ -1,421 +1 @@ -/** - * Setup wizard domain helpers. - * - * Pure, side-effect-light helpers that the `propr setup` driver and both - * renderers (Ink TUI and readline fallback) build on: - * - resolving the stack root (reusing the orchestrator's precedence rules), - * - inspecting whether the stack is already initialized, - * - reading and *safely* editing .env (non-destructive by default), - * - constructing and transitioning the {@link SetupState} step model. - * - * Nothing here loads the orchestrator's Docker core or renders UI, so the - * module can be imported and unit-tested without Docker, Ink, or readline. - * `resolveStackRoot` lives in ../../orchestrator/index.js but only reads config - * and env — it does not start Docker. - */ - -import { lstatSync, readFileSync, statSync } from "node:fs"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { resolveGithubAuthMode, type GithubAuthModeResult } from "@propr/shared"; -import { resolveStackRoot } from "../../orchestrator/index.js"; -import type { ConfigManager } from "../../config/index.js"; -import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "../../utils/envFile.js"; -import { - SETUP_STEP_DEFINITIONS, - type SetupState, - type SetupStep, - type SetupStepId, - type SetupStepPatch, -} from "./types.js"; - -/** - * Sub-directories scaffoldStack creates under the stack root. Exported so the - * setup driver and tests can create/check the same scaffold shape without - * duplicating these names. - */ -export const STACK_SUBDIRS = ["data", "logs", "repos"] as const; - -/** True only when `path` exists and is a directory. Missing paths read false. */ -function isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } -} - -/** True only when `path` exists and is a regular file. Missing paths read false. */ -function isFile(path: string): boolean { - try { - return statSync(path).isFile(); - } catch { - return false; - } -} - -/** True when a value is missing or contains only whitespace. */ -function isBlank(value: string | undefined): boolean { - return value === undefined || value.trim() === ""; -} - -/** - * Resolve the stack root for setup, reusing the orchestrator's precedence: - * explicit flag → PROPR_ROOT env → saved config stackRoot → cwd. Does not load - * Docker. - */ -export function resolveSetupRoot( - configManager: ConfigManager | undefined, - flagRoot?: string -): string { - return resolveStackRoot(configManager, flagRoot); -} - -/** Absolute path to the .env file for a given stack root. */ -export function envPathFor(rootDir: string): string { - return join(rootDir, ".env"); -} - -/** Snapshot of which scaffolded pieces of a stack root already exist. */ -export interface StackInitState { - rootDir: string; - envExists: boolean; - /** Per-subdir existence (data/, logs/, repos/). */ - dirs: Record<(typeof STACK_SUBDIRS)[number], boolean>; - /** True when .env and all expected sub-directories are present. */ - initialized: boolean; -} - -/** - * Inspect whether the stack at `rootDir` looks initialized. Read-only — never - * creates anything — so callers can decide whether to skip or re-run - * scaffolding. A plain file standing in for an expected directory (or vice - * versa) counts as *not* initialized, matching what the runtime requires. - */ -export function inspectStackInit(rootDir: string): StackInitState { - const envExists = isFile(envPathFor(rootDir)); - const dirs = {} as StackInitState["dirs"]; - for (const sub of STACK_SUBDIRS) { - dirs[sub] = isDirectory(join(rootDir, sub)); - } - const initialized = envExists && STACK_SUBDIRS.every((sub) => dirs[sub]); - return { rootDir, envExists, dirs, initialized }; -} - -export type DatastoreAdminStatus = "absent" | "no-admin" | "has-admin" | "uninspectable"; - -/** Result of inspecting the configured SQLite datastore for a durable administrator. */ -export interface DatastoreAdminInspection { - status: DatastoreAdminStatus; - /** Host path inspected, when the configured path could be resolved. */ - databasePath?: string; - /** Actionable diagnostic when inspection could not be completed safely. */ - detail?: string; -} - -/** Runtime paths used by the app image started by the CLI launcher. */ -const APP_WORKDIR = "/usr/src/app"; -const CONTAINER_DATA_DIR = join(APP_WORKDIR, "data"); - -/** - * Resolve the API's SQLite filename to the corresponding host bind-mount path. - * This mirrors @propr/core's DB_FILENAME/DATA_DIR precedence and resolves - * relative values from the app image's working directory. Only files below - * /usr/src/app/data are inspectable from the host because that is the sole data - * bind mount supplied by the CLI launcher. - */ -function resolveDatastorePath( - rootDir: string, - configuredPath: string | undefined, - configuredDataDir: string | undefined -): string { - const dbFilename = configuredPath; - const runtimePath = dbFilename - ? resolve(APP_WORKDIR, dbFilename) - : resolve(APP_WORKDIR, join(configuredDataDir ?? CONTAINER_DATA_DIR, "propr.sqlite")); - const childPath = relative(CONTAINER_DATA_DIR, runtimePath); - const outsideDataDir = - childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath); - if (outsideDataDir) { - throw new Error( - `runtime path ${runtimePath} is outside the mounted data directory ${CONTAINER_DATA_DIR}` - ); - } - return resolve(rootDir, "data", childPath); -} - -/** - * Reject symbolic links between the host bind-mount root and the configured - * datastore. A link that is valid in the host namespace may resolve to a - * different target inside the container, so following it cannot establish - * bootstrap eligibility for the datastore the API will actually use. - */ -function assertDatastorePathHasNoSymlinks(rootDir: string, databasePath: string): void { - const dataRoot = resolve(rootDir, "data"); - const childPath = relative(dataRoot, databasePath); - let currentPath = dataRoot; - - for (const component of childPath.split(sep).filter(Boolean)) { - currentPath = join(currentPath, component); - try { - if (lstatSync(currentPath).isSymbolicLink()) { - throw new Error(`configured datastore path contains a symbolic link: ${currentPath}`); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - } -} - -/** - * Inspect the configured SQLite datastore without creating or migrating it. - * Missing databases and databases conclusively lacking a durable administrator - * are bootstrap-eligible. Every resolution, I/O, schema, and query failure is - * reported as uninspectable so callers can fail closed. - */ -export async function inspectDatastoreAdministrators(rootDir: string): Promise { - let databasePath: string; - try { - const env = readEnvVars(rootDir); - databasePath = resolveDatastorePath(rootDir, env.DB_FILENAME, env.DATA_DIR); - } catch (error) { - return { - status: "uninspectable", - detail: `could not resolve configured datastore: ${(error as Error).message}`, - }; - } - - try { - assertDatastorePathHasNoSymlinks(rootDir, databasePath); - const stat = statSync(databasePath); - if (!stat.isFile()) { - return { status: "uninspectable", databasePath, detail: "configured datastore is not a regular file" }; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { status: "absent", databasePath }; - } - return { - status: "uninspectable", - databasePath, - detail: `could not inspect configured datastore: ${(error as Error).message}`, - }; - } - - let database: import("node:sqlite").DatabaseSync | undefined; - try { - const { DatabaseSync } = await import("node:sqlite"); - database = new DatabaseSync(databasePath, { readOnly: true, timeout: 5_000 }); - const membersTable = database.prepare( - "SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'instance_members' LIMIT 1" - ).get(); - if (!membersTable) return { status: "no-admin", databasePath }; - - const durableAdmin = database.prepare( - "SELECT 1 AS found FROM instance_members WHERE role = 'admin' LIMIT 1" - ).get(); - return { status: durableAdmin ? "has-admin" : "no-admin", databasePath }; - } catch (error) { - return { - status: "uninspectable", - databasePath, - detail: `could not query configured datastore: ${(error as Error).message}`, - }; - } finally { - try { - database?.close(); - } catch { - // The read query already produced a conclusive result; closing the - // read-only handle cannot widen authorization and needs no retry here. - } - } -} - -/** Convenience predicate over {@link inspectStackInit}. */ -export function isStackInitialized(rootDir: string): boolean { - return inspectStackInit(rootDir).initialized; -} - -/** - * Parse the .env at `rootDir` into a flat map. Returns `{}` when the file is - * absent. Mirrors the assignment shape the rest of the stack relies on: - * `KEY=value`, optionally `export `-prefixed, ignoring blanks and comments. - * For unquoted values a trailing ` # comment` is stripped, matching the - * orchestrator's env-file reader (and the round-trip that {@link upsertEnvVars} - * guards against); surrounding quotes on quoted values are stripped and their - * contents kept verbatim. This is intentionally a lightweight reader, not a - * full dotenv implementation — it does not handle escaped quotes or multiline - * values. - */ -export function readEnvVars(rootDir: string): Record { - const envPath = envPathFor(rootDir); - // Treat anything that is not a regular file (absent, a directory, a broken - // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a - // malformed stack surfaces as not-initialized instead of crashing the read. - if (!isFile(envPath)) return {}; - const vars: Record = {}; - for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) { - const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); - if (!match) continue; - const [, key, rawValue] = match; - const trimmed = rawValue.trim(); - const quoted = trimmed.match(/^(["'])(.*)\1$/); - // Quoted values keep their contents verbatim; unquoted values drop a - // trailing inline comment so reads agree with what upsertEnvVars allows. - vars[key] = quoted ? quoted[2] : trimmed.replace(/\s+#.*$/, ""); - } - return vars; -} - -/** True when `key` is present in .env with a non-blank value. */ -export function hasEnvValue(rootDir: string, key: string): boolean { - return !isBlank(readEnvVars(rootDir)[key]); -} - -/** Outcome of a {@link applyEnvSelection} call. */ -export interface EnvSelectionResult { - /** Keys actually written to .env this call. */ - written: string[]; - /** Keys left untouched because a value already existed (non-overwrite mode). */ - skipped: string[]; -} - -/** - * Safely edit .env for a setup step. - * - * Non-destructive by default: a key is only written when it is currently - * absent/empty, so re-running `propr setup` never clobbers values the user - * already set. Pass `{ overwrite: true }` for steps where the user explicitly - * selected a new value and intends to replace whatever is there. - * - * Blank selections (empty or whitespace-only) are ignored entirely — a step - * that has nothing to write must not blank out an existing value. Writes go - * through - * {@link upsertEnvVars}, which preserves unrelated lines and tightens the - * file's permissions. - */ -export function applyEnvSelection( - rootDir: string, - vars: Record, - opts: { overwrite?: boolean } = {} -): EnvSelectionResult { - const existing = readEnvVars(rootDir); - const toWrite: Record = {}; - const written: string[] = []; - const skipped: string[] = []; - - for (const [key, value] of Object.entries(vars)) { - if (isBlank(value)) continue; // never blank out an existing value - const alreadySet = !isBlank(existing[key]); - if (alreadySet && !opts.overwrite) { - skipped.push(key); - continue; - } - toWrite[key] = value; - written.push(key); - } - - if (written.length > 0) { - upsertEnvVars(envPathFor(rootDir), toWrite); - } - return { written, skipped }; -} - -/** - * Remove `keys` from the stack's `.env` entirely. - * - * {@link applyEnvSelection} can only set keys (and deliberately ignores blank - * values so it never clobbers a value the user set), so it cannot *clear* a key: - * writing `KEY=` would leave an empty assignment that reads back as a set-but- - * empty value. Setup steps that must genuinely drop a stale key — clearing the - * user whitelist back to "none", removing a key when switching modes — call this - * instead. A missing `.env` or absent keys are no-ops. - */ -export function clearEnvKeys(rootDir: string, keys: string[]): void { - clearEnvFileKeys(envPathFor(rootDir), keys); -} - -/** - * Infer the current GitHub auth mode from the stack's .env, so the github-auth - * step can show what is already configured (and skip prompting when valid). - * Reuses the shared resolver the backend uses, so the two can't drift. - */ -export function detectGithubAuthMode(rootDir: string): GithubAuthModeResult { - const env = readEnvVars(rootDir); - const truthy = /^(1|true|yes|on)$/i; - return resolveGithubAuthMode({ - demoMode: truthy.test(env.PROPR_DEMO_MODE ?? ""), - ghAuthMode: env.GH_AUTH_MODE, - relayUrl: env.PROPR_GH_RELAY_URL, - relayToken: env.PROPR_GH_RELAY_TOKEN, - appId: env.GH_APP_ID, - // The CLI stack records the App key as HOST_GH_PRIVATE_KEY (the orchestrator - // bind-mounts it and sets the in-container GH_PRIVATE_KEY_PATH to that path), - // so accept either when inferring app mode — otherwise a stack configured by - // `propr setup` would resolve as "none" despite being fully set up. - privateKeyPath: env.GH_PRIVATE_KEY_PATH ?? env.HOST_GH_PRIVATE_KEY, - installationId: env.GH_INSTALLATION_ID, - }); -} - -/** Build the initial, all-`pending` setup state for a resolved stack root. */ -export function createSetupState(rootDir: string): SetupState { - return { - rootDir, - steps: SETUP_STEP_DEFINITIONS.map((def) => ({ ...def, status: "pending" })), - }; -} - -/** Look up a step by id. */ -export function getStep(state: SetupState, id: SetupStepId): SetupStep | undefined { - return state.steps.find((step) => step.id === id); -} - -/** - * Return a new state with `id`'s step patched. Immutable so renderers can diff - * by reference; unknown ids return the state unchanged. - */ -export function updateStep( - state: SetupState, - id: SetupStepId, - patch: SetupStepPatch -): SetupState { - let changed = false; - const steps = state.steps.map((step) => { - if (step.id !== id) return step; - changed = true; - return { ...step, ...patch }; - }); - return changed ? { ...state, steps } : state; -} - -/** - * The next step the wizard should act on: the first one still `pending`. Used - * by the sequential renderer to drive the flow and by the TUI to highlight the - * current step. - * - * A failed required step blocks everything after it (see the `failed` status in - * ./types.ts), so once one is encountered there is no next step until it is - * retried — `undefined` is returned. Failed *optional* steps don't block. - */ -export function nextPendingStep(state: SetupState): SetupStep | undefined { - // Scan for a blocking failure first so the "a failed required step blocks - // everything after it" contract holds even if state was patched out of - // order (e.g. a later step failed before an earlier one finished). - if (state.steps.some((step) => !step.optional && step.status === "failed")) { - return undefined; - } - return state.steps.find((step) => step.status === "pending"); -} - -/** - * True once every required step has reached a terminal, non-failed state. - * Optional steps never block completion; a single failed required step does. - */ -export function isSetupComplete(state: SetupState): boolean { - return state.steps.every((step) => { - if (step.status === "failed") return false; - if (step.optional) return true; - return step.status === "done" || step.status === "skipped" || step.status === "warning"; - }); -} +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setup/types.ts b/packages/cli/src/commands/setup/types.ts index 436b84262..8c377b107 100644 --- a/packages/cli/src/commands/setup/types.ts +++ b/packages/cli/src/commands/setup/types.ts @@ -1,154 +1 @@ -/** - * Setup wizard domain types. - * - * `propr setup` walks a new user through getting a local control-plane stack - * running end to end. The flow coordinates several existing commands - * (environment checks, stack scaffolding, image pulls, agent + GitHub - * configuration, stack startup, whitelist + repo setup, and UI launch). - * - * These types are intentionally free of any rendering concern so the same - * step/status model can drive an Ink TUI and a plain readline fallback. They - * carry no Docker, Ink, or readline imports — see ./state.ts for the pure - * helpers that compute and transition this state. - */ - -/** Stable identifiers for each step of the setup flow, in run order. */ -export type SetupStepId = - | "check" - | "init-stack" - | "pull-images" - | "configure-agents" - | "github-auth" - | "intake" - | "start-stack" - | "enable-agents" - | "whitelist" - | "repo" - | "launch-ui"; - -/** - * Lifecycle status of a single step. - * pending — not started yet - * active — currently running - * done — completed successfully - * skipped — intentionally not run (already satisfied, or an optional step the - * user declined) - * warning — completed but with non-fatal issues the user should see - * failed — errored; blocks any step that depends on it - */ -export type SetupStepStatus = - | "pending" - | "active" - | "done" - | "skipped" - | "warning" - | "failed"; - -/** A single step in the setup flow plus its current presentation state. */ -export interface SetupStep { - id: SetupStepId; - /** Short label for progress lists. */ - title: string; - /** One-line explanation of what the step does. */ - description: string; - /** Optional steps may be skipped without blocking completion. */ - optional: boolean; - status: SetupStepStatus; - /** Live detail line (e.g. "pulled 6 images", "Docker daemon unreachable"). */ - detail?: string; - /** - * Suggested next action when the step is blocked, failed, or needs user - * input — shown by both renderers so the user knows how to proceed. - */ - nextAction?: string; -} - -/** Aggregate state for the whole setup flow. */ -export interface SetupState { - /** Resolved stack root where .env, data/, logs/, repos/ live. */ - rootDir: string; - /** Ordered steps; index order is the intended run order. */ - steps: SetupStep[]; -} - -/** - * Patch applied to a step when transitioning its state. Limited to runtime - * presentation fields — the static flow definition (title, description, - * optional) is canonical and cannot be altered through a patch. - */ -export type SetupStepPatch = Partial>; - -/** - * Canonical, ordered step definitions. All start `pending`; renderers and the - * command driver transition them via the helpers in ./state.ts. - */ -export const SETUP_STEP_DEFINITIONS: ReadonlyArray< - Pick -> = [ - { - id: "check", - title: "Environment checks", - description: "Verify Docker, images, and agent credentials are ready.", - optional: false, - }, - { - id: "init-stack", - title: "Initialize stack", - description: "Scaffold the stack root (.env, data/, logs/, repos/).", - optional: false, - }, - { - id: "pull-images", - title: "Pull images", - description: "Download the ProPR service and agent container images.", - optional: false, - }, - { - id: "configure-agents", - title: "Configure agents", - description: "Record detected host agent-credential directories in .env.", - optional: false, - }, - { - id: "github-auth", - title: "GitHub authentication", - description: "Choose how the backend authenticates to GitHub.", - optional: false, - }, - { - id: "intake", - title: "GitHub intake", - description: "Choose how the backend ingests GitHub events (routing WebSocket, polling, or direct webhooks).", - optional: false, - }, - { - id: "start-stack", - title: "Start stack", - description: "Launch the local control-plane services.", - optional: false, - }, - { - id: "enable-agents", - title: "Enable agents", - description: "Enable the selected agents in the backend and authenticate through their images.", - optional: false, - }, - { - id: "whitelist", - title: "Whitelist setup", - description: "Restrict which GitHub users may trigger ProPR.", - optional: false, - }, - { - id: "repo", - title: "Repository setup", - description: "Optionally connect a first repository to work on.", - optional: true, - }, - { - id: "launch-ui", - title: "Launch UI", - description: "Open the ProPR web UI.", - optional: true, - }, -]; +export * from "@propr/local-setup"; diff --git a/packages/cli/src/commands/setupCommand.test.ts b/packages/cli/src/commands/setupCommand.test.ts index 5638f8fb8..aa4581fe9 100644 --- a/packages/cli/src/commands/setupCommand.test.ts +++ b/packages/cli/src/commands/setupCommand.test.ts @@ -140,6 +140,39 @@ test("--no-skill conflicts with --install-skill", async () => { assert.match(errors.join(""), /cannot be used with/); }); +for (const platform of ["darwin", "win32"] as const) { + test(`setup reaches the agent-skill and engine flow on ${platform}`, { concurrency: false }, async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { ...originalPlatform, value: platform }); + const offeredTargets: Array = []; + let sequentialRuns = 0; + const exitCodes: number[] = []; + + try { + const command = createSetupCommand({ + offerAgentSkill: async options => { + offeredTargets.push(options?.explicitTargets); + return []; + }, + createConfig: async () => ({} as never), + runSequential: async () => { + sequentialRuns += 1; + return { completed: true } as never; + }, + exit: code => { exitCodes.push(code); }, + }); + + await command.parseAsync(["node", "propr", "--no-tui", "--install-skill", "codex"]); + + assert.deepEqual(offeredTargets, ["codex"]); + assert.equal(sequentialRuns, 1); + assert.deepEqual(exitCodes, [0]); + } finally { + Object.defineProperty(process, "platform", originalPlatform); + } + }); +} + for (const proprDemoMode of [undefined, "false"] as const) { test(`Ink login is required for GH_AUTH_MODE=demo when PROPR_DEMO_MODE is ${proprDemoMode ?? "absent"}`, () => { assert.equal(shouldPrepareInkGithubLogin(proprDemoMode, false), true); diff --git a/packages/cli/src/commands/setupCommand.ts b/packages/cli/src/commands/setupCommand.ts index 42efba1d4..f0e4f2804 100644 --- a/packages/cli/src/commands/setupCommand.ts +++ b/packages/cli/src/commands/setupCommand.ts @@ -54,6 +54,13 @@ export interface SetupSkillOfferOptions { install?: (target: AgentSkillTarget) => AgentSkillOperationResult; } +export interface SetupCommandDependencies { + offerAgentSkill?: typeof offerSetupAgentSkill; + createConfig?: typeof createConfigManager; + runSequential?: typeof runSequentialSetup; + exit?: (code: number) => void; +} + /** * Offer the bundled operator skill once during guided setup. A non-interactive * invocation performs no home-directory writes unless explicit targets were @@ -174,7 +181,7 @@ async function prepareInkGithubLogin(configManager: ConfigManager, root?: string if (!result.ok) console.warn(`GitHub login was not completed: ${result.message}`); } -export function createSetupCommand(): Command { +export function createSetupCommand(dependencies: SetupCommandDependencies = {}): Command { return new Command("setup") .description("Guided one-time setup for the local ProPR stack") .option("--root ", "Stack root directory (where .env/data/logs/repos live)") @@ -218,7 +225,7 @@ cannot prompt and exits with guidance — scaffold non-interactively instead wit try { let skillReadline: ReturnType | undefined; const canPromptForSkill = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); - await offerSetupAgentSkill({ + await (dependencies.offerAgentSkill ?? offerSetupAgentSkill)({ explicitTargets: options.installSkill, enabled: options.skill, interactive: canPromptForSkill, @@ -231,7 +238,7 @@ cannot prompt and exits with guidance — scaffold non-interactively instead wit }); skillReadline?.close(); - const configManager = await createConfigManager(); + const configManager = await (dependencies.createConfig ?? createConfigManager)(); const { skipRemoteImageCheck } = options; const useInk = options.tui !== false && canRenderInkSetup(); @@ -244,23 +251,25 @@ cannot prompt and exits with guidance — scaffold non-interactively instead wit root: options.root, skipRemoteImageCheck, }); - process.exit(result.completed ? 0 : 1); + (dependencies.exit ?? process.exit)(result.completed ? 0 : 1); + return; } - const result = await runSequentialSetup({ + const result = await (dependencies.runSequential ?? runSequentialSetup)({ configManager, root: options.root, skipRemoteImageCheck, }); - process.exit(result.completed ? 0 : 1); + (dependencies.exit ?? process.exit)(result.completed ? 0 : 1); } catch (error) { if (error instanceof SequentialSetupUnavailableError) { // Already actionable guidance — print it verbatim, no "Error:" prefix. console.error(error.message); - process.exit(1); + (dependencies.exit ?? process.exit)(1); + return; } console.error(`Error during setup: ${(error as Error).message}`); - process.exit(1); + (dependencies.exit ?? process.exit)(1); } }); } diff --git a/packages/cli/src/commands/tunnelCommand.test.ts b/packages/cli/src/commands/tunnelCommand.test.ts index f19828d86..9f0a499bc 100644 --- a/packages/cli/src/commands/tunnelCommand.test.ts +++ b/packages/cli/src/commands/tunnelCommand.test.ts @@ -89,11 +89,11 @@ function emptyStackStatus(): ReturnType { const sink = () => {}; -test("tunnel setup builds env from the Connect proxy URL", () => { +test("tunnel setup builds env from the exact Connect proxy URL", () => { assert.deepEqual( buildTunnelSetupEnv({ token: "secret-token", - url: "https://t-abc123.propr.dev/", + url: "https://t-abc123.propr.dev", }), { PROPR_UI_TUNNEL_TOKEN: "secret-token", @@ -108,6 +108,25 @@ test("tunnel setup builds env from the Connect proxy URL", () => { ); }); +test("tunnel setup rejects every noncanonical raw URL spelling", () => { + for (const url of [ + "https://t-abc123.propr.dev/", + "https://t-abc123.propr.dev////", + "https://T-AbC123.ProPR.dev", + " https://t-abc123.propr.dev", + "https://user@t-abc123.propr.dev///", + "https://t-abc123.propr.dev:443///", + "https://t-abc123.propr.dev/path///", + "https://t-abc123.propr.dev?query=1///", + "https://t-abc123.propr.dev#fragment///", + "https://t%2dabc123.propr.dev///", + "https://t-abc123.propr.dev.///", + "https://t-\u00e4bc.propr.dev///", + ]) { + assert.throws(() => buildTunnelSetupEnv({ token: "secret-token", url }), /hosted proxy URL/); + } +}); + test("tunnel setup builds env from an instance id", () => { assert.deepEqual( buildTunnelSetupEnv({ @@ -163,6 +182,25 @@ test("tunnel setup rejects a proxy URL carrying a path", () => { ); }); +test("tunnel setup rejects alternate raw Connect URL spellings", () => { + for (const url of [ + " https://t-abc123.propr.dev", + "https://t-abc123.propr.dev ", + "https://t-abc123.propr.dev/", + "https://t-abc123.propr.dev//", + "HTTPS://t-abc123.propr.dev", + "https://T-abc123.propr.dev", + "https://t-abc123.propr.dev:443", + "https://x.t-abc123.propr.dev", + ]) { + assert.throws( + () => buildTunnelSetupEnv({ token: "secret-token", url }), + /hosted proxy URL/, + url, + ); + } +}); + test("tunnel setup rejects --force because it only applies to tunnel on", () => { assert.throws( () => validateTunnelCommandOptions("setup", { force: true }), @@ -192,6 +230,15 @@ test("tunnel setup canonicalizes a mixed-case instance id", () => { ); }); +test("tunnel setup removes a mixed-case existing t- prefix exactly once", () => { + const env = buildTunnelSetupEnv({ + token: "secret-token", + instanceId: "T-AbC123", + }); + assert.equal(env.PROPR_INSTANCE_ID, "abc123"); + assert.equal(env.PROPR_UI_PUBLIC_API_URL, "https://t-abc123.propr.dev"); +}); + test("tunnel setup --start starts a stopped stack with tunnel settings", async () => { const calls: Array<{ fn: string; uiTunnelEnabled?: boolean }> = []; const { configManager, value } = fakeConfigManager(undefined); diff --git a/packages/cli/src/commands/tunnelCommand.ts b/packages/cli/src/commands/tunnelCommand.ts index 3daddf673..c970b97e3 100644 --- a/packages/cli/src/commands/tunnelCommand.ts +++ b/packages/cli/src/commands/tunnelCommand.ts @@ -23,6 +23,7 @@ import { proprInstanceProxyUrl, proprTunnelEndpoints, isProprProxyUrl, + canonicalProprProxyUrl, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, } from "@propr/shared"; @@ -418,7 +419,9 @@ export function buildTunnelSetupEnv(input: TunnelSetupInput): TunnelSetupEnv { const token = input.token.trim(); if (!token) throw new Error("--token is required"); - const explicitUrl = input.url?.trim().replace(/\/+$/, ""); + // URL authority is exact raw input: do not trim, fold case, or remove slashes + // before the shared canonical parser sees it. + const explicitUrl = input.url; const explicitInstanceId = input.instanceId?.trim(); if (!explicitUrl && !explicitInstanceId) { throw new Error("provide --url https://t-.propr.dev or --instance-id "); @@ -428,17 +431,17 @@ export function buildTunnelSetupEnv(input: TunnelSetupInput): TunnelSetupEnv { if (!candidateUrl) { throw new Error(`could not derive a hosted proxy URL from --instance-id (${explicitInstanceId})`); } - if (!isProprProxyUrl(candidateUrl)) { + const canonicalUrl = canonicalProprProxyUrl(candidateUrl); + if (!canonicalUrl) { throw new Error(`tunnel URL must be a bare hosted proxy URL such as https://${PROPR_UI_PROXY_LABEL_PREFIX}.${PROPR_UI_PROXY_SUFFIX} (no path/query/fragment)`); } - // Canonicalize: URL parsing already lowercases the host, and `.origin` drops - // any (validated-absent) path so the persisted value matches what the launcher - // resolves. DNS is case-insensitive, so the instance id is lowercased too — a - // mixed-case --instance-id would otherwise diverge from the launcher's value. - const publicUrl = new URL(candidateUrl).origin; + // candidateUrl has already passed the exact raw Connect-origin contract. + // Instance-id input remains a derivation input and is lowercased before its + // canonical endpoint is generated. + const publicUrl = candidateUrl; const derivedInstanceId = instanceIdFromProxyUrl(publicUrl); - const normalizedExplicitInstanceId = explicitInstanceId?.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) + const normalizedExplicitInstanceId = explicitInstanceId?.toLowerCase().startsWith(PROPR_UI_PROXY_LABEL_PREFIX) ? explicitInstanceId.slice(PROPR_UI_PROXY_LABEL_PREFIX.length) : explicitInstanceId; const instanceId = (normalizedExplicitInstanceId ?? derivedInstanceId)?.toLowerCase(); @@ -658,7 +661,7 @@ async function runTunnelSetup(options: { console.log(` hosted UI: ${vars.FRONTEND_URL}`); console.log(` OAuth callback: ${vars.GH_OAUTH_CALLBACK_URL}`); console.log(" GitHub OAuth: register the callback URL above in your GitHub OAuth App"); - console.log(` Hosted UI link: ${vars.FRONTEND_URL}?tunnel=${encodeURIComponent(vars.PROPR_UI_PUBLIC_API_URL)}`); + console.log(` Hosted UI link: ${vars.FRONTEND_URL}?tunnel=${new URL(vars.PROPR_UI_PUBLIC_API_URL).hostname}`); console.log(""); if (options.start) { diff --git a/packages/cli/src/config/ConfigManager.test.ts b/packages/cli/src/config/ConfigManager.test.ts index 662712804..6bf4fc5c7 100644 --- a/packages/cli/src/config/ConfigManager.test.ts +++ b/packages/cli/src/config/ConfigManager.test.ts @@ -54,6 +54,25 @@ test("getRemoteProfiles returns copied profiles and includes an empty default pr } }); +test("read-only configuration inspection never repairs permissions or writes", async () => { + if (process.platform === "win32") return; + const tempDir = createTempDir(); + const configPath = join(tempDir, "config.json"); + try { + writeFileSync(configPath, JSON.stringify({ tunnelEnabledByRoot: { "/trusted/root": false } })); + chmodSync(configPath, 0o644); + const manager = new ConfigManager(tempDir, { readOnly: true, warn: () => undefined }); + await manager.init(); + + assert.equal(manager.getTunnelEnabled("/trusted/root"), undefined); + assert.equal(lstatSync(configPath).mode & 0o777, 0o644); + await assert.rejects(manager.setTunnelEnabled("/trusted/root", true), /read-only/); + assert.equal(lstatSync(configPath).mode & 0o777, 0o644); + } finally { + cleanupTempDir(tempDir); + } +}); + test("setRemoteProfile updates a named profile without changing the active profile", async () => { const tempDir = createTempDir(); try { @@ -380,39 +399,47 @@ test("root-specific tunnel toggles do not alter another stack", async () => { } }); -test("configuration tokens are persisted atomically under private modes", async () => { - if (process.platform === "win32") return; +test("configuration save remains operational on Windows and uses private modes elsewhere", { timeout: 20_000 }, async () => { + const started = Date.now(); const tempDir = createTempDir(); try { - chmodSync(tempDir, 0o755); + if (process.platform !== "win32") chmodSync(tempDir, 0o755); const manager = new ConfigManager(tempDir); await manager.init(); await manager.setGithubToken("private-token"); const configPath = join(tempDir, "config.json"); - assert.equal(lstatSync(tempDir).mode & 0o777, 0o700); - assert.equal(lstatSync(configPath).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal(lstatSync(tempDir).mode & 0o777, 0o700); + assert.equal(lstatSync(configPath).mode & 0o777, 0o600); + } assert.match(readFileSync(configPath, "utf8"), /private-token/); assert.deepEqual(readdirSync(tempDir).filter(name => name.includes(".tmp-")), []); + assert.ok(Date.now() - started < 20_000, "configuration save exceeded its Windows aggregate deadline"); } finally { cleanupTempDir(tempDir); } }); -test("loading an existing token file tightens permissive directory and file modes", async () => { - if (process.platform === "win32") return; +test("loading an existing token file tightens permissive directory and file modes", { timeout: 20_000 }, async () => { + const started = Date.now(); const tempDir = createTempDir(); try { writeProfileConfig(tempDir); - chmodSync(tempDir, 0o755); - chmodSync(join(tempDir, "config.json"), 0o644); + if (process.platform !== "win32") { + chmodSync(tempDir, 0o755); + chmodSync(join(tempDir, "config.json"), 0o644); + } const manager = new ConfigManager(tempDir); await manager.init(); assert.equal(manager.getGithubToken(), "stored-token"); - assert.equal(lstatSync(tempDir).mode & 0o777, 0o700); - assert.equal(lstatSync(join(tempDir, "config.json")).mode & 0o777, 0o600); + if (process.platform !== "win32") { + assert.equal(lstatSync(tempDir).mode & 0o777, 0o700); + assert.equal(lstatSync(join(tempDir, "config.json")).mode & 0o777, 0o600); + } + assert.ok(Date.now() - started < 20_000, "configuration load exceeded its Windows aggregate deadline"); } finally { cleanupTempDir(tempDir); } diff --git a/packages/cli/src/config/ConfigManager.ts b/packages/cli/src/config/ConfigManager.ts index c6a79439a..b69c30d86 100644 --- a/packages/cli/src/config/ConfigManager.ts +++ b/packages/cli/src/config/ConfigManager.ts @@ -19,8 +19,11 @@ import { ensurePrivateDirectory, secureExistingPrivateDirectory, secureExistingPrivateFile, + validateExistingPrivateDirectory, + validateExistingPrivateFile, writePrivateFileAtomic, } from "../utils/privateFilesystem.js"; +import { canonicalRootKey } from "./rootKey.js"; /** * Default configuration directory name. @@ -62,6 +65,8 @@ export class ConfigManager { private configFilePath: string; private config: CLIConfig; private initialized: boolean = false; + private readonly warn: (message: string) => void; + private readonly readOnly: boolean; /** * Creates a new ConfigManager instance. @@ -69,10 +74,15 @@ export class ConfigManager { * @param customConfigDir - Optional custom configuration directory path. * Defaults to ~/.propr */ - constructor(customConfigDir?: string) { + constructor( + customConfigDir?: string, + options: { warn?: (message: string) => void; readOnly?: boolean } = {}, + ) { this.configDir = customConfigDir ?? path.join(os.homedir(), CONFIG_DIR_NAME); this.configFilePath = path.join(this.configDir, CONFIG_FILE_NAME); this.config = { ...DEFAULT_CONFIG }; + this.warn = options.warn ?? ((message) => console.warn(message)); + this.readOnly = options.readOnly ?? false; } /** @@ -99,15 +109,19 @@ export class ConfigManager { */ async load(): Promise { try { - if (secureExistingPrivateDirectory(this.configDir)) { - secureExistingPrivateFile(this.configFilePath); + const directoryExists = this.readOnly + ? validateExistingPrivateDirectory(this.configDir) + : await secureExistingPrivateDirectory(this.configDir); + if (directoryExists) { + if (this.readOnly) validateExistingPrivateFile(this.configFilePath); + else await secureExistingPrivateFile(this.configFilePath); } const data = await fs.promises.readFile(this.configFilePath, "utf-8"); const parsed = JSON.parse(data); // Validate that parsed data is an object if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - console.warn( + this.warn( `Warning: Configuration file at ${this.configFilePath} contains invalid data. Using defaults.` ); this.config = { ...DEFAULT_CONFIG }; @@ -132,7 +146,7 @@ export class ConfigManager { if (err instanceof SyntaxError) { // JSON parsing error - corrupted file - console.warn( + this.warn( `Warning: Configuration file at ${this.configFilePath} is corrupted (invalid JSON). Using defaults.` ); this.config = { ...DEFAULT_CONFIG }; @@ -140,7 +154,7 @@ export class ConfigManager { } // Other errors (permission issues, etc.) - console.warn( + this.warn( `Warning: Could not read configuration file at ${this.configFilePath}: ${err.message}. Using defaults.` ); this.config = { ...DEFAULT_CONFIG }; @@ -207,7 +221,7 @@ export class ConfigManager { ) { for (const [root, enabled] of Object.entries(data.tunnelEnabledByRoot as Record)) { if (path.isAbsolute(root) && typeof enabled === "boolean") { - tunnelEnabledByRoot[path.resolve(root)] = enabled; + tunnelEnabledByRoot[canonicalRootKey(root)] = enabled; } } } @@ -218,7 +232,7 @@ export class ConfigManager { // If no stackRoot was recorded, there is no safe root to associate with the // flag, so leave it unset and fall back to that stack's own .env default. if (typeof data.tunnelEnabled === "boolean" && typeof data.stackRoot === "string") { - const legacyRoot = path.resolve(data.stackRoot); + const legacyRoot = canonicalRootKey(path.resolve(data.stackRoot)); if (!(legacyRoot in tunnelEnabledByRoot)) { tunnelEnabledByRoot[legacyRoot] = data.tunnelEnabled; } @@ -274,7 +288,8 @@ export class ConfigManager { * @returns A promise that resolves when the configuration is saved. */ async save(): Promise { - ensurePrivateDirectory(this.configDir); + if (this.readOnly) throw new Error("Configuration manager is read-only"); + await ensurePrivateDirectory(this.configDir); // Only write non-undefined values const dataToWrite: Record = {}; @@ -285,7 +300,7 @@ export class ConfigManager { } const content = JSON.stringify(dataToWrite, null, 2); - writePrivateFileAtomic(this.configFilePath, content); + await writePrivateFileAtomic(this.configFilePath, content); } /** @@ -515,7 +530,7 @@ export class ConfigManager { * it to false. */ getTunnelEnabled(root: string): boolean | undefined { - return this.config.tunnelEnabledByRoot?.[path.resolve(root)]; + return this.config.tunnelEnabledByRoot?.[canonicalRootKey(path.resolve(root))]; } /** @@ -524,7 +539,7 @@ export class ConfigManager { * applies again (used to roll back a failed toggle). */ async setTunnelEnabled(root: string, enabled: boolean | undefined): Promise { - const normalizedRoot = path.resolve(root); + const normalizedRoot = canonicalRootKey(path.resolve(root)); const states = { ...(this.config.tunnelEnabledByRoot ?? {}) }; if (enabled === undefined) { delete states[normalizedRoot]; @@ -616,9 +631,10 @@ export class ConfigManager { * @returns A promise that resolves to an initialized ConfigManager. */ export async function createConfigManager( - customConfigDir?: string + customConfigDir?: string, + options: { warn?: (message: string) => void; readOnly?: boolean } = {}, ): Promise { - const manager = new ConfigManager(customConfigDir); + const manager = new ConfigManager(customConfigDir, options); await manager.init(); return manager; } diff --git a/packages/cli/src/config/rootKey.test.ts b/packages/cli/src/config/rootKey.test.ts new file mode 100644 index 000000000..f764a14d7 --- /dev/null +++ b/packages/cli/src/config/rootKey.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { canonicalRootKey } from "./rootKey.js"; + +test("Windows root keys keep distinct case-sensitive directory names separate", () => { + const upperCaseRoot = canonicalRootKey("C:\\Stacks\\CaseSensitive", "win32"); + const lowerCaseRoot = canonicalRootKey("C:\\Stacks\\casesensitive", "win32"); + + assert.equal(upperCaseRoot, "C:\\Stacks\\CaseSensitive"); + assert.equal(lowerCaseRoot, "C:\\Stacks\\casesensitive"); + assert.notEqual(upperCaseRoot, lowerCaseRoot); +}); diff --git a/packages/cli/src/config/rootKey.ts b/packages/cli/src/config/rootKey.ts new file mode 100644 index 000000000..30aa1d2de --- /dev/null +++ b/packages/cli/src/config/rootKey.ts @@ -0,0 +1,20 @@ +import path from "node:path"; + +/** Canonical key for persisted settings scoped to one exact stack root. */ +export function canonicalRootKey(root: string, platform: NodeJS.Platform = process.platform): string { + if (typeof root !== "string" || root.length === 0 || root.includes("\0")) { + throw new Error("Invalid stack root key"); + } + if (platform === "win32") { + if (!path.win32.isAbsolute(root)) throw new Error("Invalid stack root key"); + // Windows directories can opt into case-sensitive name lookup. Without a + // filesystem identity proving equivalence, folding case here can merge + // settings for two distinct roots. + return path.win32.normalize(path.win32.resolve(root)); + } + if (platform === "linux" || platform === "darwin") { + if (!path.posix.isAbsolute(root)) throw new Error("Invalid stack root key"); + return path.posix.normalize(path.posix.resolve(root)); + } + throw new Error("Invalid stack root key"); +} diff --git a/packages/cli/src/connectIdentity.ts b/packages/cli/src/connectIdentity.ts new file mode 100644 index 000000000..c0831d108 --- /dev/null +++ b/packages/cli/src/connectIdentity.ts @@ -0,0 +1,1040 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fchmodSync, + fstatSync, + linkSync, + lstatSync, + openSync, + readSync, + realpathSync, + unlinkSync, +} from "node:fs"; +import type { Stats } from "node:fs"; +import { basename, dirname, join, parse, resolve, sep } from "node:path"; +import { userInfo } from "node:os"; +import { + getOrCreatePublicInstanceIdentityPinned, + readPublicInstanceIdentityPinned, + type PinnedPublicIdentityDirectory, +} from "@propr/local-setup"; +import { + directoryDescriptorAccess, + mkdirAt, + lstatAt, + openAuthorityDirectoryNoFollow, + openAt, + renameAt, + unlinkAt, +} from "./utils/directoryDescriptor.js"; +import { + assertNativeEntryAuthority, + assertNativeWindowsEntriesAuthority, + nativeConnectRootAuthorityInspector, + WindowsAuthorityInspectionError, + WindowsAuthorityPolicyError, + type ConnectAuthorityEntryKind, + type ConnectRootAuthorityInspector, +} from "./connectRootAuthority.js"; +import { canonicalRootKey } from "./config/rootKey.js"; + +const MAX_ENV_FILE_BYTES = 1024 * 1024; +const MAX_CONNECT_CONFIG_BYTES = 1024 * 1024; + +export class ConnectRootError extends Error { + constructor(readonly reason = "INVALID_ROOT") { + super(`the explicit stack root is unavailable or is not owned by the caller [reason=${reason}]`); + this.name = "ConnectRootError"; + } +} + +export class PublicInstanceIdentityError extends Error { + constructor() { + super("the public instance identity is unavailable or invalid"); + this.name = "PublicInstanceIdentityError"; + } +} + +export class TrustedConnectConfigError extends Error { + constructor(readonly reason = "UNSAFE_CONFIG") { + super(`the persisted Connect configuration is unavailable or unsafe [reason=${reason}]`); + this.name = "TrustedConnectConfigError"; + } +} + +export interface TrustedConnectConfigOptions { + platform?: NodeJS.Platform; + authorityInspector?: ConnectRootAuthorityInspector; + /** Explicit only for deterministic/native tests; production uses OS userInfo. */ + trustedHome?: string; + onBoundary?: (boundary: + | "home-before-open" + | "home-opened" + | "config-directory-before-open" + | "config-directory-opened" + | "config-before-open" + | "config-opened" + | "config-read" + ) => void | Promise; +} + +export type ConnectRootSnapshotBoundary = "acquired" | "env-read" | "before-identity" | "identity-read"; + +export interface ConnectRootSnapshot { + /** Parsed bytes from the held, identity-checked .env file. */ + readonly envFileValues: Readonly>; + readonly identityDirectory: PinnedPublicIdentityDirectory; + /** Original caller input key; never treated as authority or reopened here. */ + readonly requestedRoot: string; + readonly authorityDiagnostic: "verified"; +} + +export interface ConnectRootSnapshotOptions { + platform?: NodeJS.Platform; + /** Structured native authority source; deterministic fixtures use this same policy path. */ + authorityInspector?: ConnectRootAuthorityInspector; + onBoundary?: (boundary: ConnectRootSnapshotBoundary) => void | Promise; + parseEnvFile?: (contents: string) => Record; +} + +interface HeldDirectory { + fd: number; + visiblePath: string; + openChild(name: string, flags: number, mode?: number): number; +} + +interface AcquiredRoot { + root: HeldDirectory; + ancestry: Array<{ path: string; stat: Stats; fd: number }>; +} + +class ConnectSnapshotOperationError extends Error { + constructor(readonly operationCause: unknown) { + super("Connect snapshot operation failed"); + } +} + +type IdentityValue = number | bigint | string; + +function exactIdentityValue(value: IdentityValue): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) throw new ConnectRootError(); + return BigInt(value); + } + if (!/^(?:0|[1-9]\d{0,19})$/.test(value)) throw new ConnectRootError(); + return BigInt(value); +} + +function sameIdentity( + left: { readonly dev?: IdentityValue; readonly ino?: IdentityValue; readonly device?: IdentityValue; readonly file?: IdentityValue }, + right: { readonly dev?: IdentityValue; readonly ino?: IdentityValue; readonly device?: IdentityValue; readonly file?: IdentityValue }, +): boolean { + const leftDevice = left.device ?? left.dev; + const leftFile = left.file ?? left.ino; + const rightDevice = right.device ?? right.dev; + const rightFile = right.file ?? right.ino; + if (leftDevice === undefined || leftFile === undefined || rightDevice === undefined || rightFile === undefined) { + throw new ConnectRootError(); + } + return exactIdentityValue(leftDevice) === exactIdentityValue(rightDevice) + && exactIdentityValue(leftFile) === exactIdentityValue(rightFile); +} + +function descriptorRoot(): string { + const root = "/proc/self/fd"; + if (!lstatSync(root).isDirectory()) throw new ConnectRootError(); + return root; +} + +function heldDirectory(fd: number, platform: NodeJS.Platform, visiblePath: string): HeldDirectory { + if (platform === "linux") { + const path = join(descriptorRoot(), String(fd)); + return { + fd, + visiblePath, + openChild: (name, flags, mode = 0) => openSync(join(path, name), flags, mode), + }; + } + if (platform === "darwin") { + return { + fd, + visiblePath, + openChild: (name, flags, mode = 0) => openAt(fd, name, flags, mode), + }; + } + if (platform === "win32") { + return { + fd, + visiblePath, + openChild: (name, flags, mode = 0) => openSync(join(visiblePath, name), flags, mode), + }; + } + throw new ConnectRootError(); +} + +function assertSafeAncestry(ancestry: Stats[], callerUid: number): void { + for (const stat of ancestry) { + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConnectRootError(); + if (stat.uid !== 0 && stat.uid !== callerUid) throw new ConnectRootError(); + const writableByOthers = (stat.mode & 0o022) !== 0; + const sticky = (stat.mode & 0o1000) !== 0; + if (writableByOthers && !sticky) throw new ConnectRootError(); + } +} + +function assertPrivateRoot(stat: Stats, callerUid: number | undefined, platform: NodeJS.Platform): void { + if ( + !stat.isDirectory() + || stat.isSymbolicLink() + || (platform !== "win32" && (stat.uid !== callerUid || (stat.mode & 0o022) !== 0)) + ) { + throw new ConnectRootError(); + } +} + +function assertPrivateData(stat: Stats, callerUid: number | undefined, platform: NodeJS.Platform): void { + if ( + !stat.isDirectory() + || stat.isSymbolicLink() + || (platform !== "win32" && (stat.uid !== callerUid || (stat.mode & 0o777) !== 0o700)) + ) throw new ConnectRootError(); +} + +function assertPrivateEnv(stat: Stats, callerUid: number | undefined, platform: NodeJS.Platform): void { + if ( + !stat.isFile() + || stat.isSymbolicLink() + || stat.nlink !== 1 + || (platform !== "win32" && (stat.uid !== callerUid || (stat.mode & 0o777) !== 0o600)) + || stat.size > MAX_ENV_FILE_BYTES + ) throw new ConnectRootError(); +} + +function openRootNoFollow(rootDir: string, platform: NodeJS.Platform): AcquiredRoot { + if (platform !== "win32") directoryDescriptorAccess(platform); + const parsed = parse(rootDir); + let fd = openAuthorityDirectoryNoFollow(parsed.root); + const ancestry: Array<{ path: string; stat: Stats; fd: number }> = []; + let visible = parsed.root; + try { + for (const component of rootDir.slice(parsed.root.length).split(sep).filter(Boolean)) { + const current = heldDirectory(fd, platform, visible); + const nextVisible = join(visible, component); + const next = openAuthorityDirectoryNoFollow( + nextVisible, + flags => current.openChild(component, flags), + ); + if (visible === parsed.root) closeSync(fd); + fd = next; + visible = nextVisible; + const named = lstatSync(visible); + const pinned = fstatSync(fd); + if (named.isSymbolicLink() || !sameIdentity(named, pinned)) throw new ConnectRootError(); + ancestry.push({ path: visible, stat: named, fd }); + } + return { root: heldDirectory(fd, platform, visible), ancestry }; + } catch (error) { + for (const descriptor of new Set([fd, ...ancestry.map((entry) => entry.fd)])) { + try { closeSync(descriptor); } catch { /* Preserve the authority error. */ } + } + throw error; + } +} + +function closeAcquired(acquired: AcquiredRoot): void { + for (const descriptor of new Set([acquired.root.fd, ...acquired.ancestry.map((entry) => entry.fd)])) closeSync(descriptor); +} + +function closeAcquiredAncestors(acquired: AcquiredRoot): void { + for (const entry of acquired.ancestry.slice(0, -1)) closeSync(entry.fd); +} + +function readHeldEnv(fd: number, platform: NodeJS.Platform): string { + const before = fstatSync(fd); + if (before.size < 0 || before.size > MAX_ENV_FILE_BYTES) throw new ConnectRootError(); + const bytes = Buffer.allocUnsafe(MAX_ENV_FILE_BYTES + 1); + let length = 0; + while (length < bytes.byteLength) { + const count = readSync(fd, bytes, length, bytes.byteLength - length, null); + if (count === 0) break; + length += count; + } + const after = fstatSync(fd); + assertPrivateEnv(after, before.uid, platform); + if ( + !sameIdentity(before, after) + || before.size !== after.size + || length !== before.size + || length > MAX_ENV_FILE_BYTES + ) { + throw new ConnectRootError(); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)); + } catch { + throw new ConnectRootError(); + } +} + +function readBoundedPrivateFile(fd: number, maximum: number, validate: (stat: Stats) => void): string { + const before = fstatSync(fd); + validate(before); + if (before.size <= 0 || before.size > maximum) throw new TrustedConnectConfigError(); + const bytes = Buffer.allocUnsafe(maximum + 1); + let length = 0; + while (length < bytes.byteLength) { + const count = readSync(fd, bytes, length, bytes.byteLength - length, null); + if (count === 0) break; + length += count; + } + const after = fstatSync(fd); + validate(after); + if (!sameIdentity(before, after) || before.size !== after.size || length !== before.size || length > maximum) { + throw new TrustedConnectConfigError(); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)); + } catch { + throw new TrustedConnectConfigError(); + } +} + +function parseTrustedTunnelOverride(contents: string, requestedRoot: string, platform: NodeJS.Platform): boolean | undefined { + let parsed: unknown; + try { parsed = JSON.parse(contents); } catch { throw new TrustedConnectConfigError(); } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new TrustedConnectConfigError(); + const data = parsed as Record; + const states = new Map(); + if (data.tunnelEnabledByRoot !== undefined) { + if (!data.tunnelEnabledByRoot || typeof data.tunnelEnabledByRoot !== "object" || Array.isArray(data.tunnelEnabledByRoot)) { + throw new TrustedConnectConfigError(); + } + for (const [root, enabled] of Object.entries(data.tunnelEnabledByRoot as Record)) { + if (typeof enabled !== "boolean") throw new TrustedConnectConfigError(); + let key: string; + try { key = canonicalRootKey(root, platform); } catch { throw new TrustedConnectConfigError(); } + const existing = states.get(key); + if (existing !== undefined && existing !== enabled) throw new TrustedConnectConfigError(); + states.set(key, enabled); + } + } + if (data.tunnelEnabled !== undefined) { + if (typeof data.tunnelEnabled !== "boolean" || typeof data.stackRoot !== "string") { + throw new TrustedConnectConfigError(); + } + let legacyKey: string; + try { legacyKey = canonicalRootKey(data.stackRoot, platform); } catch { throw new TrustedConnectConfigError(); } + const existing = states.get(legacyKey); + if (existing !== undefined && existing !== data.tunnelEnabled) throw new TrustedConnectConfigError(); + if (existing === undefined) states.set(legacyKey, data.tunnelEnabled); + } + let requestedKey: string; + try { requestedKey = canonicalRootKey(requestedRoot, platform); } catch { throw new TrustedConnectConfigError(); } + return states.get(requestedKey); +} + +/** + * Read only the root-specific tunnel intent from an OS-selected home. The + * directory and file stay pinned throughout a bounded synchronous read; no + * ambient HOME/cwd, profile, token, or unrelated setting is consumed. + */ +export async function readTrustedConnectTunnelOverride( + requestedRoot: string, + options: TrustedConnectConfigOptions = {}, +): Promise { + const platform = options.platform ?? process.platform; + const ioPlatform = platform === process.platform ? platform : process.platform; + if ( + (platform !== "linux" && platform !== "darwin" && platform !== "win32") + || (ioPlatform !== "linux" && ioPlatform !== "darwin" && ioPlatform !== "win32") + ) throw new TrustedConnectConfigError(); + const inspector = options.authorityInspector ?? nativeConnectRootAuthorityInspector; + const callerUid = process.getuid?.(); + const homePath = resolve(options.trustedHome ?? userInfo().homedir); + let home: AcquiredRoot | undefined; + let homeAncestorsClosed = false; + let configDir: HeldDirectory | undefined; + let configFd: number | undefined; + try { + if (!sameResolvedPath(realpathSync.native(homePath), homePath, platform)) { + throw new TrustedConnectConfigError("REPARSE_POINT"); + } + const namedHomeBefore = lstatSync(homePath); + if (namedHomeBefore.isSymbolicLink()) throw new TrustedConnectConfigError("REPARSE_POINT"); + await options.onBoundary?.("home-before-open"); + home = openRootNoFollow(homePath, ioPlatform); + await options.onBoundary?.("home-opened"); + if (!sameIdentity(namedHomeBefore, fstatSync(home.root.fd))) throw new TrustedConnectConfigError(); + if (platform !== "win32") { + await assertTrustedHomeAuthority(home, platform, inspector, callerUid); + closeAcquiredAncestors(home); + homeAncestorsClosed = true; + } + assertPrivateRoot(fstatSync(home.root.fd), callerUid, platform); + const verifyNamedHome = () => { + const held = fstatSync(home!.root.fd); + const named = lstatSync(homePath); + if (named.isSymbolicLink() || !sameIdentity(named, held)) throw new TrustedConnectConfigError(); + return held; + }; + + verifyNamedHome(); + let namedConfigDirectoryBefore: ReturnType | undefined; + try { + namedConfigDirectoryBefore = lstatSync(join(homePath, ".propr")); + if (namedConfigDirectoryBefore.isSymbolicLink()) { + throw new TrustedConnectConfigError("CONFIG_DIRECTORY_REPARSE"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + // The pathname precheck is not authoritative. Authenticate absence only + // through the child open anchored at the already-held home descriptor. + verifyNamedHome(); + } + await options.onBoundary?.("config-directory-before-open"); + let configDirectoryFd: number; + try { + configDirectoryFd = openAuthorityDirectoryNoFollow( + join(homePath, ".propr"), + flags => home!.root.openChild(".propr", flags), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + verifyNamedHome(); + if (namedConfigDirectoryBefore !== undefined) throw new TrustedConnectConfigError(); + if (platform === "win32") { + await authorityEntries(inspector, [ + ...home.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, + ]); + verifyNamedHome(); + closeAcquiredAncestors(home); + homeAncestorsClosed = true; + } + return undefined; + } + configDir = heldDirectory(configDirectoryFd, ioPlatform, join(homePath, ".propr")); + await options.onBoundary?.("config-directory-opened"); + verifyNamedHome(); + if ( + namedConfigDirectoryBefore === undefined + || !sameIdentity(namedConfigDirectoryBefore, fstatSync(configDir.fd)) + ) throw new TrustedConnectConfigError(); + const directoryStat = fstatSync(configDir.fd); + assertPrivateData(directoryStat, callerUid, platform); + assertNamedEntry(homePath, ".propr", directoryStat); + if (platform === "darwin") await authorityEntry(inspector, platform, configDir.visiblePath, "data", configDir.fd); + const verifyNamedConfigDirectory = () => { + verifyNamedHome(); + const held = fstatSync(configDir!.fd); + assertNamedEntry(homePath, ".propr", held); + return held; + }; + + verifyNamedConfigDirectory(); + let namedConfigBefore: ReturnType | undefined; + try { + namedConfigBefore = lstatSync(join(configDir.visiblePath, "config.json")); + if (namedConfigBefore.isSymbolicLink()) throw new TrustedConnectConfigError(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + // Do not decide absence from this precheck. Only the anchored child open + // below can authenticate an absent config entry. + verifyNamedConfigDirectory(); + } + await options.onBoundary?.("config-before-open"); + try { + configFd = configDir.openChild("config.json", constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // Absence is authoritative only for this exact child-open failure, and + // only while the already-held/named parent still denotes one object. + verifyNamedConfigDirectory(); + if (namedConfigBefore !== undefined) throw new TrustedConnectConfigError(); + if (platform === "win32") { + await authorityEntries(inspector, [ + ...home.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, + { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, + ]); + closeAcquiredAncestors(home); + homeAncestorsClosed = true; + } + return undefined; + } + throw error; + } + verifyNamedConfigDirectory(); + if (namedConfigBefore === undefined || !sameIdentity(namedConfigBefore, fstatSync(configFd))) { + throw new TrustedConnectConfigError(); + } + const validateConfig = (stat: Stats) => { + if ( + !stat.isFile() + || stat.isSymbolicLink() + || stat.nlink !== 1 + || (platform !== "win32" && (stat.uid !== callerUid || (stat.mode & 0o777) !== 0o600)) + ) throw new TrustedConnectConfigError(); + }; + validateConfig(fstatSync(configFd)); + assertNamedEntry(configDir.visiblePath, "config.json", fstatSync(configFd)); + if (platform === "darwin") { + await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); + } else if (platform === "win32") { + await authorityEntries(inspector, [ + ...home.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, + { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, + { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, + ]); + closeAcquiredAncestors(home); + homeAncestorsClosed = true; + } + verifyNamedConfigDirectory(); + await options.onBoundary?.("config-opened"); + const contents = readBoundedPrivateFile(configFd, MAX_CONNECT_CONFIG_BYTES, validateConfig); + await options.onBoundary?.("config-read"); + + const fileAfter = fstatSync(configFd); + assertNamedEntry(configDir.visiblePath, "config.json", fileAfter); + const directoryAfter = fstatSync(configDir.fd); + assertPrivateData(directoryAfter, callerUid, platform); + assertNamedEntry(homePath, ".propr", directoryAfter); + const homeAfter = fstatSync(home.root.fd); + assertPrivateRoot(homeAfter, callerUid, platform); + const namedHome = lstatSync(homePath); + if (namedHome.isSymbolicLink() || !sameIdentity(namedHome, homeAfter)) throw new TrustedConnectConfigError(); + if (platform === "darwin") { + await authorityEntry(inspector, platform, configDir.visiblePath, "data", configDir.fd); + await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); + } else if (platform === "win32") { + await authorityEntries(inspector, [ + { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, + { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, + { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, + ]); + } + return parseTrustedTunnelOverride(contents, requestedRoot, platform); + } catch (error) { + if (error instanceof TrustedConnectConfigError) throw error; + if (error instanceof WindowsAuthorityPolicyError) { + throw new TrustedConnectConfigError(`NATIVE_ENTRY_${error.entryIndex}_${error.policyReason}`); + } + if (error instanceof ConnectRootError) throw new TrustedConnectConfigError(error.reason); + throw new TrustedConnectConfigError(); + } finally { + if (configFd !== undefined) closeSync(configFd); + if (configDir !== undefined) closeSync(configDir.fd); + if (home !== undefined) { + if (!homeAncestorsClosed) closeAcquiredAncestors(home); + closeSync(home.root.fd); + } + } +} + +async function authorityEntry( + inspector: ConnectRootAuthorityInspector, + platform: NodeJS.Platform, + path: string, + kind: ConnectAuthorityEntryKind, + pinnedFd: number, +): Promise { + try { + await assertNativeEntryAuthority(inspector, platform, path, kind, pinnedFd); + } catch (error) { + if (error instanceof WindowsAuthorityInspectionError) throw error; + if (error instanceof WindowsAuthorityPolicyError) throw error; + throw new ConnectRootError(); + } +} + +async function authorityEntries( + inspector: ConnectRootAuthorityInspector, + entries: readonly { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }[], +): Promise { + await assertNativeWindowsEntriesAuthority(inspector, entries); +} + +async function assertPlatformAuthority( + acquired: AcquiredRoot, + platform: NodeJS.Platform, + inspector: ConnectRootAuthorityInspector, + callerUid: number | undefined, +): Promise { + if (platform === "win32") { + await authorityEntries(inspector, [ + ...acquired.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: acquired.root.visiblePath, kind: "root", pinnedFd: acquired.root.fd }, + ]); + return; + } + if (callerUid === undefined) throw new ConnectRootError(); + assertSafeAncestry(acquired.ancestry.slice(0, -1).map((entry) => entry.stat), callerUid); + assertPrivateRoot(fstatSync(acquired.root.fd), callerUid, platform); + if (platform === "darwin") { + for (const entry of acquired.ancestry.slice(0, -1)) { + await authorityEntry(inspector, platform, entry.path, "ancestor", entry.fd); + } + await authorityEntry(inspector, platform, acquired.root.visiblePath, "root", acquired.root.fd); + } +} + +async function assertTrustedHomeAuthority( + acquired: AcquiredRoot, + platform: NodeJS.Platform, + inspector: ConnectRootAuthorityInspector, + callerUid: number | undefined, +): Promise { + if (platform === "win32") { + await authorityEntries(inspector, [ + ...acquired.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: acquired.root.visiblePath, kind: "home", pinnedFd: acquired.root.fd }, + ]); + return; + } + if (callerUid === undefined) throw new TrustedConnectConfigError(); + assertSafeAncestry(acquired.ancestry.slice(0, -1).map((entry) => entry.stat), callerUid); + assertPrivateRoot(fstatSync(acquired.root.fd), callerUid, platform); + if (platform === "darwin") { + for (const entry of acquired.ancestry.slice(0, -1)) { + await authorityEntry(inspector, platform, entry.path, "ancestor", entry.fd); + } + await authorityEntry(inspector, platform, acquired.root.visiblePath, "home", acquired.root.fd); + } +} + +function sameResolvedPath(left: string, right: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function assertNamedEntry(rootDir: string, name: string, held: Stats): void { + const named = lstatSync(join(rootDir, name)); + if (named.isSymbolicLink() || !sameIdentity(named, held)) throw new ConnectRootError("NAMED_REPLACED"); +} + +function identifyHeldChild(directory: HeldDirectory, platform: NodeJS.Platform, name: string) { + if (platform === "darwin") { + const stat = lstatAt(directory.fd, name); + return { + device: exactIdentityValue(stat.dev).toString(10), + file: exactIdentityValue(stat.ino).toString(10), + kind: stat.kind, + }; + } + const stat = lstatSync(platform === "linux" + ? join(descriptorRoot(), String(directory.fd), name) + : join(directory.visiblePath, name), { bigint: true }); + return { + device: stat.dev.toString(10), + file: stat.ino.toString(10), + kind: stat.isFile() + ? "file" as const + : stat.isDirectory() + ? "directory" as const + : stat.isSymbolicLink() + ? "symbolic-link" as const + : "other" as const, + }; +} + +/** + * Run all root-dependent work inside one descriptor-anchored snapshot. + * No trusted pathname escapes the callback, and every named identity is checked again. + */ +export async function withOwnedConnectRootSnapshot( + flagRoot: string | undefined, + operation: (snapshot: ConnectRootSnapshot) => T | Promise, + options: ConnectRootSnapshotOptions, +): Promise { + if (!flagRoot || !options.parseEnvFile) throw new ConnectRootError(); + const platform = options.platform ?? process.platform; + if (platform !== "linux" && platform !== "darwin" && platform !== "win32") throw new ConnectRootError(); + const ioPlatform = platform === process.platform + ? platform + : (process.platform === "linux" || process.platform === "darwin") && options.authorityInspector + ? process.platform + : undefined; + if (!ioPlatform) throw new ConnectRootError(); + const inspector = options.authorityInspector ?? nativeConnectRootAuthorityInspector; + const callerUid = process.getuid?.(); + if (platform !== "win32" && callerUid === undefined) throw new ConnectRootError(); + const requestedRoot = resolve(flagRoot); + try { + if (!sameResolvedPath(realpathSync.native(requestedRoot), requestedRoot, platform)) { + throw new ConnectRootError("REPARSE_POINT"); + } + } catch (error) { + if (error instanceof ConnectRootError) throw error; + throw new ConnectRootError("REALPATH_UNAVAILABLE"); + } + + let root: HeldDirectory | undefined; + let data: HeldDirectory | undefined; + let envFd: number | undefined; + let acquiredRoot: AcquiredRoot | undefined; + let acquiredAncestorsClosed = false; + try { + const acquired = openRootNoFollow(requestedRoot, ioPlatform); + acquiredRoot = acquired; + root = acquired.root; + if (platform !== "win32") { + await assertPlatformAuthority(acquired, platform, inspector, callerUid); + closeAcquiredAncestors(acquired); + acquiredAncestorsClosed = true; + } + assertPrivateRoot(fstatSync(root.fd), callerUid, platform); + + const verifyNamedRoot = () => { + const held = fstatSync(root!.fd); + const named = lstatSync(requestedRoot); + if (named.isSymbolicLink() || !sameIdentity(named, held)) throw new ConnectRootError(); + return held; + }; + verifyNamedRoot(); + const dataFd = openAuthorityDirectoryNoFollow( + join(requestedRoot, "data"), + flags => root!.openChild("data", flags), + ); + data = heldDirectory(dataFd, ioPlatform, join(requestedRoot, "data")); + verifyNamedRoot(); + const initialDataStat = fstatSync(data.fd); + assertPrivateData(initialDataStat, callerUid, platform); + assertNamedEntry(requestedRoot, "data", initialDataStat); + if (platform === "darwin") await authorityEntry(inspector, platform, data.visiblePath, "data", data.fd); + verifyNamedRoot(); + envFd = root.openChild(".env", constants.O_RDONLY | constants.O_NOFOLLOW); + verifyNamedRoot(); + const initialEnvStat = fstatSync(envFd); + assertPrivateEnv(initialEnvStat, callerUid, platform); + assertNamedEntry(requestedRoot, ".env", initialEnvStat); + if (platform === "darwin") { + await authorityEntry(inspector, platform, join(requestedRoot, ".env"), "env", envFd); + } else if (platform === "win32") { + await authorityEntries(inspector, [ + ...acquired.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: root.visiblePath, kind: "root", pinnedFd: root.fd }, + { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, + { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ]); + closeAcquiredAncestors(acquired); + acquiredAncestorsClosed = true; + } + await options.onBoundary?.("acquired"); + + const envFileValues = options.parseEnvFile(readHeldEnv(envFd, platform)); + await options.onBoundary?.("env-read"); + const verifyNamedData = (): Stats => { + const held = fstatSync(data!.fd); + assertPrivateData(held, callerUid, platform); + // Unix child operations remain anchored to the held descriptor even if + // the visible name is concurrently replaced; final revalidation rejects + // the snapshot. Windows child operations are pathname-based and must + // therefore prove the visible data identity before every use. + if (platform === "win32") { + assertNamedEntry(requestedRoot, "data", held); + } + return held; + }; + const identityDirectory: PinnedPublicIdentityDirectory = { + fd: data.fd, + ownerUid: initialDataStat.uid, + open: (name, flags, mode = 0) => { + verifyNamedData(); + const childFd = data!.openChild(name, flags, mode); + if (platform === "win32") { + try { + verifyNamedData(); + const child = fstatSync(childFd); + const named = lstatSync(join(data!.visiblePath, name)); + if (named.isSymbolicLink() || !sameIdentity(named, child)) throw new ConnectRootError(); + verifyNamedData(); + } catch (error) { + closeSync(childFd); + throw error; + } + } + return childFd; + }, + identify: (name) => { + verifyNamedData(); + const identity = identifyHeldChild(data!, ioPlatform, name); + verifyNamedData(); + return identity; + }, + validateEntry: async (name, fd) => { + const entryPath = join(data!.visiblePath, name); + if (platform !== "linux") { + await authorityEntry(inspector, platform, entryPath, "env", fd); + } + }, + publishNoReplace: (oldName, newName) => { + verifyNamedData(); + if (platform === "win32") { + linkSync(join(data!.visiblePath, oldName), join(data!.visiblePath, newName)); + unlinkSync(join(data!.visiblePath, oldName)); + } else { + renameAt(data!.fd, oldName, newName); + } + verifyNamedData(); + }, + unlink: (name) => { + verifyNamedData(); + if (platform === "win32") unlinkSync(join(data!.visiblePath, name)); + else unlinkAt(data!.fd, name); + verifyNamedData(); + }, + }; + + let result: T | undefined; + let operationError: unknown; + try { + result = await operation({ + envFileValues, + identityDirectory, + requestedRoot, + authorityDiagnostic: "verified", + }); + } catch (error) { + operationError = error; + } + const namedRoot = lstatSync(requestedRoot); + const heldRootStat = fstatSync(root.fd); + if (namedRoot.isSymbolicLink() || !sameIdentity(namedRoot, heldRootStat)) throw new ConnectRootError(); + assertPrivateRoot(heldRootStat, callerUid, platform); + const heldDataStat = fstatSync(data.fd); + const heldEnvStat = fstatSync(envFd); + assertPrivateData(heldDataStat, callerUid, platform); + assertPrivateEnv(heldEnvStat, callerUid, platform); + assertNamedEntry(requestedRoot, "data", heldDataStat); + assertNamedEntry(requestedRoot, ".env", heldEnvStat); + if (platform === "darwin") { + await authorityEntry(inspector, platform, data.visiblePath, "data", data.fd); + await authorityEntry(inspector, platform, join(requestedRoot, ".env"), "env", envFd); + } + const reacquired = openRootNoFollow(requestedRoot, ioPlatform); + try { + const before = acquired.ancestry; + const after = reacquired.ancestry; + if ( + before.length !== after.length + || before.some((entry, index) => !sameIdentity(entry.stat, after[index].stat)) + ) throw new ConnectRootError(); + if (platform === "win32") { + await authorityEntries(inspector, [ + ...reacquired.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), + { path: reacquired.root.visiblePath, kind: "root", pinnedFd: reacquired.root.fd }, + { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, + { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ]); + } else { + await assertPlatformAuthority(reacquired, platform, inspector, callerUid); + } + } finally { + closeAcquired(reacquired); + } + if (operationError !== undefined) throw new ConnectSnapshotOperationError(operationError); + return result as T; + } catch (error) { + if (error instanceof ConnectSnapshotOperationError) throw error.operationCause; + if (error instanceof PublicInstanceIdentityError) throw error; + if (error instanceof WindowsAuthorityInspectionError) throw error; + if (error instanceof ConnectRootError) throw error; + if (error instanceof WindowsAuthorityPolicyError) { + throw new ConnectRootError(`NATIVE_ENTRY_${error.entryIndex}_${error.policyReason}`); + } + throw new ConnectRootError(); + } finally { + if (acquiredRoot !== undefined && !acquiredAncestorsClosed) closeAcquiredAncestors(acquiredRoot); + if (envFd !== undefined) closeSync(envFd); + if (data !== undefined) closeSync(data.fd); + if (root !== undefined) closeSync(root.fd); + } +} + +/** Host-side access used by stack initialization outside the Connect snapshot. */ +export async function getOrCreatePublicInstanceIdentity( + dataDir: string, + generate: () => string = randomUUID, +): Promise { + const platform = process.platform; + const requestedDataPath = resolve(dataDir); + const dataPath = platform === "win32" ? realpathSync.native(requestedDataPath) : requestedDataPath; + if (platform === "win32") { + let held: HeldDirectory | undefined; + try { + const acquired = openRootNoFollow(dataPath, platform); + held = acquired.root; + // Windows stack initialization and configuration persistence predate + // Connect discovery. Keep this mutation path independent from the + // read-only DACL diagnostic that is deferred to #1997. + closeAcquiredAncestors(acquired); + const terminal = fstatSync(held.fd); + assertPrivateData(terminal, undefined, platform); + const verifyVisible = () => { + const visible = lstatSync(dataPath); + const pinned = fstatSync(held!.fd); + if (visible.isSymbolicLink() || !sameIdentity(visible, pinned)) throw new PublicInstanceIdentityError(); + assertPrivateData(pinned, undefined, platform); + }; + const directory: PinnedPublicIdentityDirectory = { + fd: held.fd, + ownerUid: terminal.uid, + open: (name, flags, mode = 0) => { + verifyVisible(); + const fd = held!.openChild(name, flags, mode); + try { + verifyVisible(); + const opened = fstatSync(fd); + const named = lstatSync(join(dataPath, name)); + if (named.isSymbolicLink() || !sameIdentity(opened, named)) throw new PublicInstanceIdentityError(); + verifyVisible(); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } + }, + identify: (name) => { + verifyVisible(); + const identity = identifyHeldChild(held!, platform, name); + verifyVisible(); + return identity; + }, + validateEntry: () => undefined, + publishNoReplace: (oldName, newName) => { + verifyVisible(); + linkSync(join(dataPath, oldName), join(dataPath, newName)); + unlinkSync(join(dataPath, oldName)); + verifyVisible(); + }, + unlink: (name) => { + verifyVisible(); + unlinkSync(join(dataPath, name)); + verifyVisible(); + }, + }; + const identity = await getOrCreatePublicInstanceIdentityPinned(directory, { generate, role: "host" }); + verifyVisible(); + return identity; + } catch (error) { + if (error instanceof PublicInstanceIdentityError) throw error; + throw new PublicInstanceIdentityError(); + } finally { + if (held !== undefined) closeSync(held.fd); + } + } + if (platform !== "linux" && platform !== "darwin") throw new PublicInstanceIdentityError(); + let held: HeldDirectory | undefined; + try { + try { + lstatSync(dataPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parentPath = dirname(dataPath); + if (realpathSync.native(parentPath) !== parentPath) throw new PublicInstanceIdentityError(); + const callerUid = process.getuid?.(); + if (callerUid === undefined) throw new PublicInstanceIdentityError(); + const acquiredParent = openRootNoFollow(parentPath, platform); + try { + try { + await assertPlatformAuthority(acquiredParent, platform, nativeConnectRootAuthorityInspector, callerUid); + } finally { + closeAcquiredAncestors(acquiredParent); + } + assertPrivateRoot(fstatSync(acquiredParent.root.fd), callerUid, platform); + try { + mkdirAt(acquiredParent.root.fd, basename(dataPath), 0o700); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; + } + const createdFd = openAuthorityDirectoryNoFollow( + dataPath, + flags => acquiredParent.root.openChild(basename(dataPath), flags), + ); + try { + fchmodSync(createdFd, 0o700); + } finally { + closeSync(createdFd); + } + } finally { + closeSync(acquiredParent.root.fd); + } + } + if (realpathSync.native(dataPath) !== dataPath) throw new PublicInstanceIdentityError(); + const callerUid = process.getuid?.(); + if (callerUid === undefined) throw new PublicInstanceIdentityError(); + const acquired = openRootNoFollow(dataPath, platform); + held = acquired.root; + try { + await assertPlatformAuthority(acquired, platform, nativeConnectRootAuthorityInspector, callerUid); + } finally { + closeAcquiredAncestors(acquired); + } + assertPrivateData(fstatSync(held.fd), callerUid, platform); + const directory: PinnedPublicIdentityDirectory = { + fd: held.fd, + ownerUid: callerUid, + open: (name, flags, mode = 0) => held!.openChild(name, flags, mode), + identify: (name) => identifyHeldChild(held!, platform, name), + validateEntry: async (name, fd) => { + if (platform === "darwin") { + await authorityEntry(nativeConnectRootAuthorityInspector, platform, join(dataPath, name), "env", fd); + } + }, + publishNoReplace: (oldName, newName) => renameAt(held!.fd, oldName, newName), + unlink: (name) => unlinkAt(held!.fd, name), + }; + const identity = await getOrCreatePublicInstanceIdentityPinned(directory, { generate, role: "host" }); + const named = lstatSync(dataPath); + const pinned = fstatSync(held.fd); + if (named.isSymbolicLink() || !sameIdentity(named, pinned)) throw new PublicInstanceIdentityError(); + assertPrivateData(pinned, callerUid, platform); + return identity; + } catch (error) { + if (error instanceof PublicInstanceIdentityError) throw error; + throw new PublicInstanceIdentityError(); + } finally { + if (held !== undefined) closeSync(held.fd); + } +} + +export async function getOrCreateSnapshotPublicInstanceIdentity( + directory: PinnedPublicIdentityDirectory, + generate: () => string = randomUUID, +): Promise { + try { + return await getOrCreatePublicInstanceIdentityPinned(directory, { generate, role: "host" }); + } catch (error) { + if (error instanceof WindowsAuthorityInspectionError) throw error; + if (error instanceof PublicInstanceIdentityError) throw error; + throw new PublicInstanceIdentityError(); + } +} + +export async function readSnapshotPublicInstanceIdentity( + directory: PinnedPublicIdentityDirectory, +): Promise { + try { + return await readPublicInstanceIdentityPinned(directory); + } catch (error) { + if (error instanceof WindowsAuthorityInspectionError) throw error; + if (error instanceof PublicInstanceIdentityError) throw error; + throw new PublicInstanceIdentityError(); + } +} diff --git a/packages/cli/src/connectRootAuthority.test.ts b/packages/cli/src/connectRootAuthority.test.ts new file mode 100644 index 000000000..7839adbaf --- /dev/null +++ b/packages/cli/src/connectRootAuthority.test.ts @@ -0,0 +1,498 @@ +import assert from "node:assert/strict"; +import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + assertNativeWindowsEntriesAuthority, + assertSafeWindowsAuthority, + assertWindowsInspectionShape, + isConnectAuthorityBrokerModeSafe, + parseWindowsInspectionDocument, + stableAuthorityIdentity, + WindowsAuthorityInspectionError, + WindowsAuthorityPolicyError, + type ConnectRootAuthorityInspector, + type WindowsAuthorityInspection, +} from "./connectRootAuthority.js"; +import { + parseWindowsNativeProbeOutput, + WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + WINDOWS_INSPECTION_SOURCE, + WINDOWS_INSPECTION_TIMEOUT_MS, + WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, + WINDOWS_INSPECTOR_TRANSPORT, + WINDOWS_INSPECTOR_WRITES_FILESYSTEM, + WINDOWS_NATIVE_TIMING_PROBE_SOURCE, + WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, + WINDOWS_NATIVE_STAGE_CODES, + WINDOWS_UINT64_COMPOSER_SOURCE, + WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, + windowsBrokerFailureStage, + windowsInspectionTimeoutForElapsed, + WindowsNativeStageError, + windowsNativeTimingBucket, + windowsPowerShellEnvironment, +} from "./connectWindowsAuthority.js"; + +const USER = "S-1-5-21-100-200-300-1001"; +const SYSTEM = "S-1-5-18"; +const ADMINISTRATORS = "S-1-5-32-544"; + +test("unpackaged Connect authority brokers reject group/other-writable modes", () => { + assert.equal(isConnectAuthorityBrokerModeSafe(0o644n, false), true); + assert.equal(isConnectAuthorityBrokerModeSafe(0o755n, false), true); + assert.equal(isConnectAuthorityBrokerModeSafe(0o775n, false), false); + assert.equal(isConnectAuthorityBrokerModeSafe(0o757n, false), false); + assert.equal(isConnectAuthorityBrokerModeSafe(0o644n, true), false); + assert.equal(isConnectAuthorityBrokerModeSafe(0o755n, true), true); + assert.equal(isConnectAuthorityBrokerModeSafe(0o775n, true), false); +}); + +function inspection(overrides: Partial = {}): WindowsAuthorityInspection { + return { + index: 0, + kind: "directory", + authorityKind: "root", + currentUserSid: USER, + ownerSid: USER, + daclProtected: true, + reparsePoint: false, + volumeSerialNumber: "1", + fileId: "2", + verifiedVolumeSerialNumber: "1", + verifiedFileId: "2", + rules: [ + { identitySid: USER, inherited: false, accessType: "allow", appliesToSelf: true, rights: "2032127" }, + { identitySid: SYSTEM, inherited: false, accessType: "allow", appliesToSelf: true, rights: "2032127" }, + { identitySid: ADMINISTRATORS, inherited: false, accessType: "allow", appliesToSelf: true, rights: "2032127" }, + ], + ...overrides, + }; +} + +function policyFailure( + value: WindowsAuthorityInspection, + kind: Parameters[1], + reason: string, +): void { + assert.throws( + () => assertSafeWindowsAuthority(value, kind), + (error) => error instanceof WindowsAuthorityPolicyError && error.policyReason === reason, + ); +} + +test("Windows protected entries allow only explicit trusted mutation authority", () => { + assert.doesNotThrow(() => assertSafeWindowsAuthority(inspection(), "root")); + policyFailure(inspection({ + rules: [{ identitySid: "S-1-1-0", inherited: false, accessType: "allow", appliesToSelf: true, rights: "2" }], + }), "root", "BROAD_WRITE"); + policyFailure(inspection({ + rules: [{ identitySid: USER, inherited: true, accessType: "allow", appliesToSelf: true, rights: "2" }], + }), "root", "INHERITED_WRITE"); + policyFailure(inspection({ daclProtected: false }), "data", "DACL_NOT_PROTECTED"); + policyFailure(inspection({ ownerSid: SYSTEM }), "env", "OWNER_MISMATCH"); + policyFailure(inspection({ reparsePoint: true }), "root", "REPARSE_POINT"); + policyFailure(inspection({ + rules: [{ identitySid: USER, inherited: false, accessType: "deny", appliesToSelf: true, rights: "4294967295" }], + }), "root", "UNKNOWN_RIGHTS"); +}); + +test("Windows ancestors narrowly allow OS ownership and inherited traversal", () => { + assert.doesNotThrow(() => assertSafeWindowsAuthority(inspection({ + authorityKind: "ancestor", + ownerSid: SYSTEM, + daclProtected: false, + rules: [{ identitySid: "S-1-5-32-545", inherited: true, accessType: "allow", appliesToSelf: true, rights: "1179785" }], + }), "ancestor")); + assert.doesNotThrow(() => assertSafeWindowsAuthority(inspection({ + authorityKind: "home", + ownerSid: ADMINISTRATORS, + daclProtected: false, + rules: [{ identitySid: USER, inherited: true, accessType: "allow", appliesToSelf: true, rights: "2032127" }], + }), "home")); + policyFailure(inspection({ + authorityKind: "ancestor", + ownerSid: SYSTEM, + daclProtected: false, + rules: [{ identitySid: "S-1-5-32-545", inherited: true, accessType: "allow", appliesToSelf: true, rights: "2" }], + }), "ancestor", "BROAD_WRITE"); + policyFailure(inspection({ authorityKind: "ancestor", ownerSid: "S-1-5-80-123" }), "ancestor", "OWNER_MISMATCH"); +}); + +test("Windows broker JSON is canonical, exact-keyed, and bounded", () => { + const valid = JSON.stringify({ version: 1, entries: [inspection()] }); + assert.deepEqual(parseWindowsInspectionDocument(valid), [inspection()]); + assertWindowsInspectionShape(parseWindowsInspectionDocument(valid)[0]); + const stageFailure = (document: string, stage: string): void => assert.throws( + () => parseWindowsInspectionDocument(document), + (error) => error instanceof WindowsNativeStageError && error.stage === stage, + ); + stageFailure("{", "parent:json-parse"); + stageFailure(`${valid}\n`, "parent:json-canonical"); + stageFailure(`{"version":1,"version":1,"entries":[]}`, "parent:json-canonical"); + for (const malformed of [ + "[]", + JSON.stringify({ version: 1, entries: [], extra: true }), + JSON.stringify({ version: 2, entries: [] }), + JSON.stringify({ version: 1, entries: {} }), + JSON.stringify({ version: 1, entries: Array.from({ length: 33 }, () => inspection()) }), + ]) stageFailure(malformed, "parent:document-shape"); + assert.throws( + () => parseWindowsInspectionDocument("x".repeat(128 * 1024 + 1)), + (error) => error instanceof WindowsNativeStageError && error.stage === "parent:utf8", + ); + assert.throws(() => assertWindowsInspectionShape({ ...inspection(), extra: true })); + assert.throws(() => assertWindowsInspectionShape({ ...inspection(), rules: [ + { identitySid: USER, inherited: false, accessType: "audit", appliesToSelf: true, rights: "1" }, + ] })); +}); + +test("Windows native timing milestones are strict, ordered, bounded, and redacted", () => { + const valid = [ + "PROPR_NATIVE_PROBE_V1|entry-ps51-desktop-x64|under-5s", + "PROPR_NATIVE_PROBE_V1|constant-json|under-5s", + "PROPR_NATIVE_PROBE_V1|reflection-emit|5-to-15s", + "PROPR_NATIVE_PROBE_V1|harmless-win32|5-to-15s", + "PROPR_NATIVE_PROBE_V1|standard-handle-identity|15-to-30s", + "", + ].join("\r\n"); + assert.deepEqual(parseWindowsNativeProbeOutput(valid), [ + { milestone: "entry-ps51-desktop-x64", timingBucket: "under-5s" }, + { milestone: "constant-json", timingBucket: "under-5s" }, + { milestone: "reflection-emit", timingBucket: "5-to-15s" }, + { milestone: "harmless-win32", timingBucket: "5-to-15s" }, + { milestone: "standard-handle-identity", timingBucket: "15-to-30s" }, + ]); + assert.deepEqual(parseWindowsNativeProbeOutput(valid.split("\r\n").slice(0, 3).join("\r\n") + "\r\n"), [ + { milestone: "entry-ps51-desktop-x64", timingBucket: "under-5s" }, + { milestone: "constant-json", timingBucket: "under-5s" }, + { milestone: "reflection-emit", timingBucket: "5-to-15s" }, + ]); + assert.deepEqual(parseWindowsNativeProbeOutput( + "PROPR_NATIVE_PROBE_V1|entry-ps51-desktop-x64|under-5s\r\npartial-SENTINEL", + true, + ), [{ milestone: "entry-ps51-desktop-x64", timingBucket: "under-5s" }]); + for (const hostile of [ + "PROPR_NATIVE_PROBE_V1|constant-json|under-5s\r\n", + "PROPR_NATIVE_PROBE_V1|entry-ps51-desktop-x64|arbitrary-12345ms\r\n", + "C:\\private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL\r\n", + "PROPR_NATIVE_PROBE_V1|entry-ps51-desktop-x64|under-5s", + "x".repeat(2 * 1024 + 1), + ]) assert.throws( + () => parseWindowsNativeProbeOutput(hostile), + (error) => error instanceof WindowsNativeStageError + && error.stage === "probe:output" + && !error.message.includes("SENTINEL"), + ); +}); + +test("Windows native timing uses only coarse fixed buckets", () => { + assert.deepEqual([ + 0, 4_999, 5_000, 14_999, 15_000, 29_999, 30_000, 44_999, 45_000, 59_999, 60_000, + ].map(windowsNativeTimingBucket), [ + "under-5s", "under-5s", "5-to-15s", "5-to-15s", "15-to-30s", "15-to-30s", + "30-to-45s", "30-to-45s", "45-to-60s", "45-to-60s", "at-least-60s", + ]); + assert.throws(() => windowsNativeTimingBucket(Number.NaN), WindowsNativeStageError); +}); + +test("Windows production inspection has one cold-start deadline and a cumulative batch cap", () => { + assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); + assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); + assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); + assert.notEqual( + WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, + 32, + ); + assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); + assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); + assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); + assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); + assert.throws( + () => windowsInspectionTimeoutForElapsed(240_000), + (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + ); + assert.throws( + () => windowsInspectionTimeoutForElapsed(240_001), + (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + ); +}); + +test("Windows production isolates entry fields and retains private handle lifetime", () => { + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:fd-duplicate")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-initial")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:current-user-sid")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-revalidation")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-decode")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-compose")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:entry-format")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:entry-flags")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:entry-rules")); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:entry-build")); + assert.equal((WINDOWS_NATIVE_STAGE_CODES as readonly string[]).includes("broker:index-info"), false); + assert.equal(windowsBrokerFailureStage(79), "broker:index-info-revalidation"); + assert.equal(windowsBrokerFailureStage(81), "broker:index-info-decode"); + assert.equal(windowsBrokerFailureStage(82), "broker:index-info-compose"); + assert.equal(windowsBrokerFailureStage(83), "broker:entry-build"); + assert.equal(windowsBrokerFailureStage(84), "broker:entry-format"); + assert.equal(windowsBrokerFailureStage(85), "broker:entry-flags"); + assert.equal(windowsBrokerFailureStage(86), "broker:entry-rules"); + + const duplicate = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=80"); + const initial = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=74"); + const sid = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=78"); + const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79"); + const decode = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=81", revalidation); + const compose = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=82", decode); + const entryFormat = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose); + const entryFlags = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=85", entryFormat); + const entryRules = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=86", entryFlags); + const entryBuild = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=83", entryRules); + const json = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=77", entryBuild); + assert.ok(duplicate >= 0 && duplicate < initial && initial < sid && sid < revalidation + && revalidation < decode && decode < compose && compose < entryFormat + && entryFormat < entryFlags && entryFlags < entryRules && entryRules < entryBuild + && entryBuild < json); + assert.match(WINDOWS_INSPECTION_SOURCE.slice(duplicate, initial), + /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); + assert.match(WINDOWS_INSPECTION_SOURCE.slice(initial, sid), + /^\$stage=74\n \$before=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}\n $/s); + assert.match(WINDOWS_INSPECTION_SOURCE.slice(sid, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=75", sid)), + /^\$stage=78\n \$current=.*WindowsIdentity\]::GetCurrent\(\)\.User\n if\(\$null-eq \$current\)\{exit \$stage\}\n \$currentSid=\$current\.Value\n $/s); + assert.match(WINDOWS_INSPECTION_SOURCE.slice(revalidation, decode), + /^\$stage=79\n \$after=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}\n $/s); + const decodedIdentity = WINDOWS_INSPECTION_SOURCE.slice(decode, compose); + assert.match(decodedIdentity, /^\$stage=81\n \$beforeVolume=/); + for (const [field, structure, offset] of [ + ["beforeVolume", "before", 28], ["afterVolume", "after", 28], + ["beforeHigh", "before", 44], ["beforeLow", "before", 48], + ["afterHigh", "after", 44], ["afterLow", "after", 48], + ] as const) { + assert.match(decodedIdentity, new RegExp(`\\$${field}=Read-ProprUInt32 \\$${structure} ${offset}`)); + } + assert.equal(WINDOWS_INSPECTION_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); + assert.equal(WINDOWS_INSPECTION_SOURCE.match(/Read-ProprUInt32 \$(?:before|after) (?:28|44|48)/g)?.length, 6); + assert.match(decodedIdentity, + /\$afterHigh=Read-ProprUInt32 \$after 44;\$afterLow=Read-ProprUInt32 \$after 48\n $/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, + /\[uint32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32/); + assert.match(WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, + /if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); + const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice( + compose, entryFormat, + ); + assert.match(composedIdentity, + /^\$stage=82\n \$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n if\(\$beforeId-isnot \[uint64\]\)\{exit \$stage\}\n \$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n if\(\$afterId-isnot \[uint64\]\)\{exit \$stage\}\n $/); + const formattedIdentity = WINDOWS_INSPECTION_SOURCE.slice(entryFormat, entryFlags); + assert.equal(formattedIdentity, [ + "$stage=84", + " $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", + " $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", + " $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture)", + " $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture)", + " if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " ", + ].join("\n")); + assert.equal(formattedIdentity.match(/\.ToString\(\[Globalization\.CultureInfo\]::InvariantCulture\)/g)?.length, 4); + assert.doesNotMatch(formattedIdentity, /\$entry=|Console|Write-|Out\./); + const entryFlagValidation = WINDOWS_INSPECTION_SOURCE.slice(entryFlags, entryRules); + assert.equal(entryFlagValidation, [ + "$stage=85", + " $daclProtected=[bool](($control-band 0x1000)-ne 0)", + " $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0)", + " if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage}", + " ", + ].join("\n")); + assert.doesNotMatch(entryFlagValidation, /Console|Write-|Out\./); + const entryRuleValidation = WINDOWS_INSPECTION_SOURCE.slice(entryRules, entryBuild); + assert.equal(entryRuleValidation, [ + "$stage=86", + " [object[]]$rulesArray=$rules.ToArray()", + " if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage}", + " for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){", + " if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage}", + " }", + " ", + ].join("\n")); + assert.equal(WINDOWS_INSPECTION_SOURCE.match(/\[object\[\]\]\$rulesArray=\$rules\.ToArray\(\)/g)?.length, 1); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /@\(\s*\$rules\s*\)/); + assert.doesNotMatch(entryRuleValidation, /ConvertTo-Json|\.ToString|Console|Write-|Out\./); + const entryConstruction = WINDOWS_INSPECTION_SOURCE.slice(entryBuild, json); + assert.equal(entryConstruction, [ + "$stage=83", + " $entry=[pscustomobject][ordered]@{", + " index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid", + " daclProtected=$daclProtected;reparsePoint=$reparsePoint", + " volumeSerialNumber=$beforeVolumeDecimal", + " fileId=$beforeIdDecimal", + " verifiedVolumeSerialNumber=$afterVolumeDecimal", + " verifiedFileId=$afterIdDecimal;rules=$rulesArray", + " }", + " ", + ].join("\n")); + assert.doesNotMatch(entryConstruction, + /Marshal|\.ToString|InvariantCulture|@\(\$rules\)|ReferenceEquals|-band|\bfor\s*\(/); + assert.doesNotMatch(composedIdentity, /ToString|\$entry=/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /4294967296|\[uint64\]\$(?:before|after)High\*/); + assert.match(WINDOWS_UINT64_COMPOSER_SOURCE, + /function Join-ProprUInt64\(\[uint32\]\$low,\[uint32\]\$high\)\{\n if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$bytes=New-Object byte\[\] 8\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$low\),0,\$bytes,0,4\)\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$high\),0,\$bytes,4,4\)\n \[BitConverter\]::ToUInt64\(\$bytes,0\)\n\}/); + const unsignedDecimal = (value: number): string => { + const bytes = Buffer.alloc(4); + bytes.writeInt32LE(value, 0); + return bytes.readUInt32LE(0).toString(10); + }; + const highBit = unsignedDecimal(-2_147_483_648); + const allBits = unsignedDecimal(-1); + assert.equal(highBit, "2147483648"); + assert.equal(allBits, "4294967295"); + const composedDecimal = (low: number, high: number): string => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32LE(low, 0); + bytes.writeUInt32LE(high, 4); + return bytes.readBigUInt64LE(0).toString(10); + }; + const highBitFileId = composedDecimal(Number(allBits), Number(highBit)); + const allBitsFileId = composedDecimal(Number(allBits), Number(allBits)); + assert.equal(highBitFileId, "9223372041149743103"); + assert.equal(allBitsFileId, "18446744073709551615"); + assert.match(JSON.stringify({ highBit, allBits, highBitFileId, allBitsFileId }), + /^\{"highBit":"\d+","allBits":"\d+","highBitFileId":"\d+","allBitsFileId":"\d+"\}$/); + assert.match(WINDOWS_INSPECTION_SOURCE, + /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); + assert.equal(WINDOWS_INSPECTION_SOURCE.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); + assert.match(WINDOWS_INSPECTION_SOURCE, + /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /CloseHandle\(\$originalHandle\)/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE.slice(initial), /\$originalHandle/); +}); + +test("Windows PowerShell boundary retains a derived minimal environment and no filesystem writes", () => { + assert.deepEqual(windowsPowerShellEnvironment("C:\\Windows"), { + SystemRoot: "C:\\Windows", + WINDIR: "C:\\Windows", + }); + assert.throws(() => windowsPowerShellEnvironment("relative\\Windows"), WindowsNativeStageError); + for (const forbidden of [ + "PATH", "PATHEXT", "PSModulePath", "TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", + ]) assert.equal(forbidden in windowsPowerShellEnvironment("C:\\Windows"), false); + assert.equal(WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, false); + assert.equal(WINDOWS_INSPECTOR_WRITES_FILESYSTEM, false); + assert.equal(WINDOWS_INSPECTOR_TRANSPORT, "inherited-standard-handle"); + for (const source of [WINDOWS_INSPECTION_SOURCE, WINDOWS_NATIVE_TIMING_PROBE_SOURCE]) { + assert.doesNotMatch(source, /Add-Type|Start-Process|Set-Content|Out-File|New-Item|Remove-Item|Invoke-Expression/i); + } +}); + +test("Windows timing probe isolates baseline, Reflection.Emit, Win32, and standard-handle identity", () => { + const milestones = [ + "Write-ProprMilestone 'entry-ps51-desktop-x64'", + "Write-ProprMilestone 'constant-json'", + "Write-ProprMilestone 'reflection-emit'", + "Write-ProprMilestone 'harmless-win32'", + "Write-ProprMilestone 'standard-handle-identity'", + ].map((token) => WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf(token)); + assert.ok(milestones.every((offset) => offset >= 0)); + assert.deepEqual([...milestones].sort((left, right) => left - right), milestones); + assert.ok(milestones[1] < WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("DefineDynamicAssembly")); + assert.ok(milestones[2] < WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("::GetCurrentProcessId()")); + assert.ok(milestones[3] < WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("::GetStdHandle(-10)")); + assert.ok(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("::GetFileInformationByHandle") < milestones[4]); + const populated = WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("GetFileInformationByHandle($handle,$info)"); + const probeDecode = WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("Read-ProprUInt32 $info", populated); + const probeCompose = WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf( + "Join-ProprUInt64 $probeLow $probeHigh", probeDecode, + ); + const probeFormat = WINDOWS_NATIVE_TIMING_PROBE_SOURCE.indexOf("$probeVolumeDecimal=", probeCompose); + assert.ok(populated >= 0 && populated < probeDecode && probeDecode < probeCompose + && probeCompose < probeFormat && probeFormat < milestones[4]); + assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); + assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/Read-ProprUInt32 \$info (?:28|44|48)/g)?.length, 3); + assert.match(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeCompose, probeFormat), + /^Join-ProprUInt64 \$probeLow \$probeHigh\n if\(\$probeId-isnot \[uint64\]\)\{exit \$stage\}\n $/); + assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeFormat, milestones[4]), [ + "$probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", + " $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture)", + " if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " ", + ].join("\n")); +}); + +test("Windows batch results remain bound to descriptor index, kind, identity, and user", async () => { + const directory = mkdtempSync(join(tmpdir(), "propr-windows-authority-test-")); + const firstPath = join(directory, "first"); + const secondPath = join(directory, "second"); + writeFileSync(firstPath, "a"); + writeFileSync(secondPath, "b"); + const firstFd = openSync(firstPath, "r"); + const secondFd = openSync(secondPath, "r"); + const firstIdentity = stableAuthorityIdentity(firstFd); + const secondIdentity = stableAuthorityIdentity(secondFd); + const entries = [ + { path: firstPath, kind: "env" as const, pinnedFd: firstFd }, + { path: secondPath, kind: "env" as const, pinnedFd: secondFd }, + ]; + const validEntries = [firstIdentity, secondIdentity].map((identity, index) => inspection({ + index, + kind: "file", + authorityKind: "env", + volumeSerialNumber: identity.device, + verifiedVolumeSerialNumber: identity.device, + fileId: identity.file, + verifiedFileId: identity.file, + })); + const inspector = (results: readonly WindowsAuthorityInspection[]): ConnectRootAuthorityInspector => ({ + inspectDarwinAcl: () => { throw new Error("unused"); }, + inspectWindowsAcl: async () => { throw new Error("unused"); }, + inspectWindowsAcls: async () => results, + }); + const diagnosticSymbol = Symbol.for("propr.test.windowsNativeDiagnostic"); + const globals = globalThis as Record; + const originalDiagnostic = globals[diagnosticSymbol]; + const diagnosticStages: string[] = []; + globals[diagnosticSymbol] = (stage: string): void => { diagnosticStages.push(stage); }; + try { + await assertNativeWindowsEntriesAuthority(inspector(validEntries), entries); + const noEntries = parseWindowsInspectionDocument('{"version":1,"entries":[]}'); + await assert.rejects( + assertNativeWindowsEntriesAuthority(inspector(noEntries), entries), + WindowsAuthorityInspectionError, + ); + assert.equal(diagnosticStages.pop(), "parent:entry-count"); + const malformedEntries = parseWindowsInspectionDocument('{"version":1,"entries":[{},{}]}'); + await assert.rejects( + assertNativeWindowsEntriesAuthority( + inspector(malformedEntries as readonly WindowsAuthorityInspection[]), entries, + ), + WindowsAuthorityInspectionError, + ); + assert.equal(diagnosticStages.pop(), "parent:entry-shape"); + for (const bad of [ + [{ ...validEntries[0], index: 1 }, validEntries[1]], + [{ ...validEntries[0], kind: "directory" as const }, validEntries[1]], + [{ ...validEntries[0], authorityKind: "data" as const }, validEntries[1]], + [{ ...validEntries[0], fileId: (BigInt(validEntries[0].fileId) + 1n).toString() }, validEntries[1]], + [validEntries[0], { ...validEntries[1], currentUserSid: "S-1-5-21-9" }], + ]) { + await assert.rejects( + assertNativeWindowsEntriesAuthority(inspector(bad), entries), + WindowsAuthorityInspectionError, + ); + } + } finally { + if (originalDiagnostic === undefined) delete globals[diagnosticSymbol]; + else globals[diagnosticSymbol] = originalDiagnostic; + closeSync(firstFd); + closeSync(secondFd); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/connectRootAuthority.ts b/packages/cli/src/connectRootAuthority.ts new file mode 100644 index 000000000..0f21ab92e --- /dev/null +++ b/packages/cli/src/connectRootAuthority.ts @@ -0,0 +1,663 @@ +import { spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fsyncSync, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readSync, + rmSync, + writeSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + parseWindowsInspectionDocument, + reportWindowsNativeStage, + runWindowsReadOnlyInspection, + WindowsNativeStageError, + windowsInspectionEntryKind, +} from "./connectWindowsAuthority.js"; +import { + assertCanonicalNativeArtifactParents, + isPackagedNativeArtifactResolution, + physicalNativeArtifactCandidate, +} from "./utils/nativeArtifact.js"; + +const NATIVE_INSPECTION_MAX_BYTES = 128 * 1024; +const WINDOWS_SID = /^S-\d(?:-\d+)+$/; +const WINDOWS_TRUSTED_MUTATORS = new Set([ + "S-1-5-18", // NT AUTHORITY\\SYSTEM + "S-1-5-32-544", // BUILTIN\\Administrators +]); + +// FileSystemRights values which can alter an entry, its children, or its ACL. +const WINDOWS_MUTATING_RIGHTS = BigInt( + 0x00000002 // WriteData / CreateFiles + | 0x00000004 // AppendData / CreateDirectories + | 0x00000010 // WriteExtendedAttributes + | 0x00000040 // DeleteSubdirectoriesAndFiles + | 0x00000100 // WriteAttributes + | 0x00010000 // Delete + | 0x00040000 // ChangePermissions + | 0x00080000 // TakeOwnership +); +const WINDOWS_GENERIC_MUTATING_RIGHTS = 0x50000000n; // GENERIC_WRITE | GENERIC_ALL +const WINDOWS_KNOWN_ALLOW_RIGHTS = 0xf01f01ffn; +const WINDOWS_AUTHORITY_MAX_ENTRIES = 32; +const WINDOWS_AUTHORITY_MAX_ACES_PER_ENTRY = 128; +const WINDOWS_AUTHORITY_MAX_TOTAL_ACES = 512; + +export const WINDOWS_AUTHORITY_REQUIRED_CODE = "WINDOWS_AUTHORITY_REQUIRED" as const; + +/** + * Windows mutation/protection is intentionally deferred to #1997. Callers must + * surface this result; there is no package broker, service, elevation, or + * best-effort fallback in this discovery-only change. + */ +export class WindowsAuthorityRequiredError extends Error { + readonly code = WINDOWS_AUTHORITY_REQUIRED_CODE; + + constructor() { + super("Windows authority is required for this operation and is not available yet; use discovery-only status or retry after #1997 lands"); + this.name = "WindowsAuthorityRequiredError"; + } +} + +export type ConnectAuthorityEntryKind = "ancestor" | "home" | "root" | "data" | "env"; + +export interface WindowsAclRuleInspection { + readonly identitySid: string; + readonly inherited: boolean; + readonly accessType: "allow" | "deny"; + readonly appliesToSelf: boolean; + /** Canonical base-10 representation of the unsigned 32-bit access mask. */ + readonly rights: string; +} + +export interface WindowsAuthorityInspection { + readonly index: number; + readonly kind: "directory" | "file"; + readonly authorityKind: ConnectAuthorityEntryKind; + readonly currentUserSid: string; + readonly ownerSid: string; + readonly daclProtected: boolean; + readonly reparsePoint: boolean; + readonly volumeSerialNumber: string; + readonly fileId: string; + readonly verifiedVolumeSerialNumber: string; + readonly verifiedFileId: string; + readonly rules: readonly WindowsAclRuleInspection[]; +} + +export interface DarwinAuthorityInspection { + readonly version: 1; + readonly device: string; + readonly file: string; + readonly acl: string; +} + +export interface StableAuthorityIdentity { + readonly device: string; + readonly file: string; +} + +export interface WindowsAuthorityTarget { + readonly path: string; + readonly kind: ConnectAuthorityEntryKind; + readonly expectedIdentity: StableAuthorityIdentity; + readonly pinnedFd: number; +} + +export interface ConnectRootAuthorityInspector { + inspectDarwinAcl( + path: string, + pinnedFd: number, + expectedIdentity: StableAuthorityIdentity, + ): DarwinAuthorityInspection; + inspectWindowsAcl( + path: string, + expectedIdentity: StableAuthorityIdentity, + pinnedFd?: number, + kind?: ConnectAuthorityEntryKind, + ): Promise; + inspectWindowsAcls?(entries: readonly WindowsAuthorityTarget[]): Promise; +} + +export type WindowsAuthorityPolicyReason = + | "OWNER_MISMATCH" + | "DACL_NOT_PROTECTED" + | "REPARSE_POINT" + | "UNKNOWN_RIGHTS" + | "BROAD_WRITE" + | "INHERITED_WRITE"; + +/** Redacted policy diagnostic used by deterministic authority fixtures. */ +export class WindowsAuthorityPolicyError extends Error { + constructor( + readonly entryIndex: number, + readonly policyReason: WindowsAuthorityPolicyReason, + ) { + super(`Windows native authority rejected entry ${entryIndex}: ${policyReason}`); + this.name = "WindowsAuthorityPolicyError"; + } +} + +/** Fixed, redacted boundary for a failed read-only Windows ACL inspection. */ +export class WindowsAuthorityInspectionError extends Error { + constructor() { + super("Windows ACL authority inspection is unavailable"); + this.name = "WindowsAuthorityInspectionError"; + } +} + +export function stableAuthorityIdentity(fd: number): StableAuthorityIdentity { + const stat = fstatSync(fd, { bigint: true }); + return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; +} + +function decodeBoundedUtf8(value: Buffer | string | null | undefined): string { + const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : (value ?? Buffer.alloc(0)); + if (bytes.byteLength > NATIVE_INSPECTION_MAX_BYTES) throw new Error("native authority inspection exceeded its limit"); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + +const DARWIN_AUTHORITY_BROKER_SHA256: Readonly> = { + arm64: "75fda2624bf093555e726b968401321fef61ea7ae0479f4c1892be0dfc6554c0", + x64: "e5a49be0db85655b9ff1d0614de9d61defd41a0a1b2eff8f11571407f10d809b", +}; + +/** Writable rejection is universal; execution is required only at the packaged source boundary. */ +export function isConnectAuthorityBrokerModeSafe(mode: bigint, packaged: boolean): boolean { + return (mode & 0o022n) === 0n && (!packaged || (mode & 0o111n) !== 0n); +} + +function readExactDescriptor(fd: number, size: number): Buffer { + if (!Number.isSafeInteger(size) || size <= 0 || size > 512 * 1024) { + throw new Error("packaged native authority broker failed integrity verification"); + } + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + const count = readSync(fd, bytes, offset, size - offset, offset); + if (count <= 0) throw new Error("packaged native authority broker failed integrity verification"); + offset += count; + } + return bytes; +} + +function darwinAuthorityBrokerArtifact(): { + path: string; + fd: number; + identity: StableAuthorityIdentity; + digest: string; + bytes: Buffer; +} { + const expected = DARWIN_AUTHORITY_BROKER_SHA256[process.arch]; + if (!expected) throw new Error(`native authority inspection is not packaged for darwin-${process.arch}`); + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + const relative = join("prebuilds", `darwin-${process.arch}`, "connect-authority-broker"); + const candidates = [ + join(moduleDirectory, "native", relative), + join(moduleDirectory, "..", "native", relative), + join(moduleDirectory, "..", "..", "native", relative), + ].map((logicalPath) => { + const path = physicalNativeArtifactCandidate(logicalPath); + return { path, packaged: isPackagedNativeArtifactResolution(logicalPath, path) }; + }); + for (const { path, packaged } of candidates) { + let fd: number | undefined; + try { + if (packaged) assertCanonicalNativeArtifactParents(path); + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const stat = fstatSync(fd, { bigint: true }); + const named = lstatSync(path, { bigint: true }); + if ( + !stat.isFile() + || named.isSymbolicLink() + || stat.dev !== named.dev + || stat.ino !== named.ino + || stat.size <= 0n + || stat.size > BigInt(512 * 1024) + || (typeof process.getuid === "function" && stat.uid !== 0n && stat.uid !== BigInt(process.getuid())) + || !isConnectAuthorityBrokerModeSafe(stat.mode, packaged) + ) { + closeSync(fd); + fd = undefined; + continue; + } + const bytes = readExactDescriptor(fd, Number(stat.size)); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== expected) throw new Error("packaged native authority broker failed integrity verification"); + return { + path, + fd, + identity: { device: stat.dev.toString(10), file: stat.ino.toString(10) }, + digest, + bytes, + }; + } catch (error) { + if (fd !== undefined) closeSync(fd); + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + throw new Error(`packaged native authority broker is missing for darwin-${process.arch}`); +} + +function revalidateDarwinAuthorityBroker( + artifact: { fd: number; identity: StableAuthorityIdentity; digest: string; bytes: Buffer }, +): void { + const stat = fstatSync(artifact.fd, { bigint: true }); + if ( + !stat.isFile() + || stat.dev.toString(10) !== artifact.identity.device + || stat.ino.toString(10) !== artifact.identity.file + || Number(stat.size) !== artifact.bytes.byteLength + || createHash("sha256").update(readExactDescriptor(artifact.fd, artifact.bytes.byteLength)).digest("hex") !== artifact.digest + ) throw new Error("packaged native authority broker was replaced"); +} + +function stageDarwinAuthorityBroker(artifact: ReturnType): { + path: string; + fd: number; + directory: string; +} { + const directory = mkdtempSync(join(tmpdir(), "propr-authority-capability-")); + try { + chmodSync(directory, 0o700); + const path = join(directory, `broker-${randomUUID()}`); + const writableFd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR, 0o500); + try { + let offset = 0; + while (offset < artifact.bytes.byteLength) { + const count = writeSync(writableFd, artifact.bytes, offset, artifact.bytes.byteLength - offset, offset); + if (count <= 0) throw new Error("Darwin ACL authority inspection is unavailable"); + offset += count; + } + fsyncSync(writableFd); + fchmodSync(writableFd, 0o500); + const staged = fstatSync(writableFd, { bigint: true }); + if (!staged.isFile() || staged.size !== BigInt(artifact.bytes.byteLength)) { + throw new Error("Darwin ACL authority inspection is unavailable"); + } + closeSync(writableFd); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const readable = fstatSync(fd, { bigint: true }); + if (readable.dev !== staged.dev || readable.ino !== staged.ino) { + closeSync(fd); + throw new Error("Darwin ACL authority inspection is unavailable"); + } + return { path, fd, directory }; + } catch (error) { + try { closeSync(writableFd); } catch { /* It was closed before the read-only reopen. */ } + throw error; + } + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} + +function exactKeys(value: object, expected: readonly string[]): boolean { + return Object.keys(value).sort().join(",") === [...expected].sort().join(","); +} + +function canonicalUint64(value: unknown): value is string { + return typeof value === "string" + && /^(?:0|[1-9]\d{0,19})$/.test(value) + && BigInt(value) <= 0xffffffffffffffffn; +} + +function assertDarwinInspectionShape(value: unknown): asserts value is DarwinAuthorityInspection { + if ( + !value + || typeof value !== "object" + || Array.isArray(value) + || !exactKeys(value, ["version", "device", "file", "acl"]) + ) throw new Error("Darwin ACL authority inspection was malformed"); + const record = value as Record; + if ( + record.version !== 1 + || !canonicalUint64(record.device) + || !canonicalUint64(record.file) + || typeof record.acl !== "string" + || Buffer.byteLength(record.acl, "utf8") > 24 * 1024 + ) throw new Error("Darwin ACL authority inspection was malformed"); +} + +function nativeDarwinAcl( + _path: string, + pinnedFd: number, + _expectedIdentity: StableAuthorityIdentity, +): DarwinAuthorityInspection { + if (!Number.isInteger(pinnedFd) || pinnedFd < 0) throw new Error("Darwin ACL authority inspection is unavailable"); + const artifact = darwinAuthorityBrokerArtifact(); + let capability: ReturnType; + try { + capability = stageDarwinAuthorityBroker(artifact); + } catch (error) { + closeSync(artifact.fd); + throw error; + } + let result: ReturnType; + try { + result = spawnSync(capability.path, [], { + shell: false, + windowsHide: true, + encoding: "buffer", + env: {}, + timeout: 5000, + maxBuffer: NATIVE_INSPECTION_MAX_BYTES, + stdio: ["ignore", "pipe", "pipe", pinnedFd], + }); + const staged = fstatSync(capability.fd, { bigint: true }); + if ( + !staged.isFile() + || staged.size !== BigInt(artifact.bytes.byteLength) + || createHash("sha256").update(readExactDescriptor(capability.fd, artifact.bytes.byteLength)).digest("hex") !== artifact.digest + ) throw new Error("packaged native authority broker was replaced"); + revalidateDarwinAuthorityBroker(artifact); + } finally { + closeSync(capability.fd); + rmSync(capability.directory, { recursive: true, force: true }); + closeSync(artifact.fd); + } + if (result.status !== 0 || result.error || result.signal || decodeBoundedUtf8(result.stderr).length !== 0) { + throw new Error("Darwin ACL authority inspection is unavailable"); + } + let parsed: unknown; + try { + parsed = JSON.parse(decodeBoundedUtf8(result.stdout).trim()); + } catch { + throw new Error("Darwin ACL authority inspection was malformed"); + } + assertDarwinInspectionShape(parsed); + return parsed; +} + +async function nativeWindowsAcls( + entries: readonly WindowsAuthorityTarget[], +): Promise { + try { + return runWindowsReadOnlyInspection(entries); + } catch (error) { + if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); + throw new WindowsAuthorityInspectionError(); + } +} + +async function nativeWindowsAcl( + path: string, + expectedIdentity: StableAuthorityIdentity, + pinnedFd?: number, + kind: ConnectAuthorityEntryKind = "root", +): Promise { + if (pinnedFd === undefined) throw new WindowsAuthorityInspectionError(); + const inspections = await nativeWindowsAcls([{ path, expectedIdentity, pinnedFd, kind }]); + if (inspections.length !== 1) { + reportWindowsNativeStage("parent:entry-count"); + throw new WindowsAuthorityInspectionError(); + } + return inspections[0]; +} + +export const nativeConnectRootAuthorityInspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: nativeDarwinAcl, + inspectWindowsAcl: nativeWindowsAcl, + inspectWindowsAcls: nativeWindowsAcls, +}; + +/** Windows mutation is unsupported until the separately reviewed authority work lands. */ +export async function protectWindowsSetupEntry(_path: string, _kind: "directory" | "file"): Promise { + if (process.platform === "win32") throw new WindowsAuthorityRequiredError(); +} + +/** Windows mutation is unsupported until the separately reviewed authority work lands. */ +export async function protectWindowsSetupEntries( + entries: readonly { readonly path: string; readonly kind: "directory" | "file" }[], +): Promise { + if (process.platform === "win32" && entries.length > 0) throw new WindowsAuthorityRequiredError(); +} + +export function assertWindowsInspectionShape(value: unknown): asserts value is WindowsAuthorityInspection { + if ( + !value + || typeof value !== "object" + || Array.isArray(value) + || !exactKeys(value, [ + "index", "kind", "authorityKind", "currentUserSid", "ownerSid", "daclProtected", "reparsePoint", + "volumeSerialNumber", "fileId", "verifiedVolumeSerialNumber", "verifiedFileId", "rules", + ]) + ) throw new Error("Windows ACL authority inspection was malformed"); + const record = value as Record; + if ( + !Number.isInteger(record.index) + || (record.index as number) < 0 + || (record.index as number) >= WINDOWS_AUTHORITY_MAX_ENTRIES + || (record.kind !== "directory" && record.kind !== "file") + || !["ancestor", "home", "root", "data", "env"].includes(record.authorityKind as string) + || typeof record.currentUserSid !== "string" || !WINDOWS_SID.test(record.currentUserSid) + || typeof record.ownerSid !== "string" || !WINDOWS_SID.test(record.ownerSid) + || typeof record.daclProtected !== "boolean" + || typeof record.reparsePoint !== "boolean" + || !canonicalUint64(record.volumeSerialNumber) + || typeof record.fileId !== "string" || !/^(?:0|[1-9]\d{0,38})$/.test(record.fileId) + || BigInt(record.fileId) > 0xffffffffffffffffffffffffffffffffn + || !canonicalUint64(record.verifiedVolumeSerialNumber) + || typeof record.verifiedFileId !== "string" || !/^(?:0|[1-9]\d{0,38})$/.test(record.verifiedFileId) + || BigInt(record.verifiedFileId) > 0xffffffffffffffffffffffffffffffffn + || !Array.isArray(record.rules) || record.rules.length > WINDOWS_AUTHORITY_MAX_ACES_PER_ENTRY + ) throw new Error("Windows ACL authority inspection was malformed"); + for (const rule of record.rules) { + if ( + !rule || typeof rule !== "object" || Array.isArray(rule) + || !exactKeys(rule, ["identitySid", "inherited", "accessType", "appliesToSelf", "rights"]) + ) throw new Error("Windows ACL authority inspection was malformed"); + const item = rule as Record; + if ( + typeof item.identitySid !== "string" || !WINDOWS_SID.test(item.identitySid) + || typeof item.inherited !== "boolean" + || (item.accessType !== "allow" && item.accessType !== "deny") + || typeof item.appliesToSelf !== "boolean" + || typeof item.rights !== "string" || !/^(?:0|[1-9]\d{0,9})$/.test(item.rights) + || BigInt(item.rights) > 0xffffffffn + ) throw new Error("Windows ACL authority inspection was malformed"); + } +} + +/** Apply the fail-closed policy to deterministic Windows ACL fixtures. */ +export function assertSafeWindowsAuthority( + inspection: WindowsAuthorityInspection, + kind: ConnectAuthorityEntryKind, +): void { + assertWindowsInspectionShape(inspection); + const protectedEntry = kind === "root" || kind === "data" || kind === "env"; + const trustedOwner = WINDOWS_TRUSTED_MUTATORS.has(inspection.ownerSid) + || inspection.ownerSid === "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; + if (inspection.ownerSid !== inspection.currentUserSid + && !((kind === "ancestor" || kind === "home") && trustedOwner)) { + throw new WindowsAuthorityPolicyError(inspection.index, "OWNER_MISMATCH"); + } + if (inspection.reparsePoint) throw new WindowsAuthorityPolicyError(inspection.index, "REPARSE_POINT"); + for (const rule of inspection.rules) { + const rights = BigInt(rule.rights); + if ((rights & ~WINDOWS_KNOWN_ALLOW_RIGHTS) !== 0n) { + throw new WindowsAuthorityPolicyError(inspection.index, "UNKNOWN_RIGHTS"); + } + if (rule.accessType !== "allow" || !rule.appliesToSelf) continue; + const mutating = (rights & (WINDOWS_MUTATING_RIGHTS | WINDOWS_GENERIC_MUTATING_RIGHTS)) !== 0n; + if (!mutating) continue; + if (rule.identitySid !== inspection.currentUserSid && !WINDOWS_TRUSTED_MUTATORS.has(rule.identitySid)) { + throw new WindowsAuthorityPolicyError(inspection.index, "BROAD_WRITE"); + } + if (rule.inherited && protectedEntry) { + throw new WindowsAuthorityPolicyError(inspection.index, "INHERITED_WRITE"); + } + } + if (protectedEntry && !inspection.daclProtected) { + throw new WindowsAuthorityPolicyError(inspection.index, "DACL_NOT_PROTECTED"); + } +} + +const DARWIN_READ_ONLY_ACL_PERMISSIONS = new Set([ + "execute", "list", "read", "readattr", "readextattr", "readsecurity", "search", "synchronize", +]); +const DARWIN_MUTATING_ACL_PERMISSIONS = new Set([ + "write", "append", "delete", "delete_child", "add_file", "add_subdirectory", + "writeattr", "writeextattr", "writesecurity", "chown", +]); +const DARWIN_ACL_FLAGS = new Set(["directory_inherit", "file_inherit", "inherited", "limit_inherit", "only_inherit"]); + +/** Reject malformed ACL output and every ACL allow entry carrying mutation authority. */ +export function assertSafeDarwinAclOutput(output: string): void { + // acl_to_text() may represent a valid empty extended ACL as an empty string + // on APFS. Canonicalize only that exact representation to the audited empty + // document; every non-empty malformed spelling remains rejected. + const canonicalOutput = output === "" ? "!#acl 1\n" : output; + if (Buffer.byteLength(canonicalOutput, "utf8") > 24 * 1024 || canonicalOutput.includes("\0")) { + throw new Error("Darwin ACL authority inspection was malformed"); + } + const lines = canonicalOutput.replace(/\n$/, "").split("\n"); + if (!/^!#acl 1(?: (?:defer_inherit|no_inherit)(?:,(?:defer_inherit|no_inherit))*)?$/.test(lines[0])) { + throw new Error("Darwin ACL authority inspection was malformed"); + } + for (const line of lines.slice(1)) { + const fields = line.split(":"); + if ( + fields.length !== 6 + || (fields[0] !== "user" && fields[0] !== "group") + || !/^[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}$/.test(fields[1]) + || fields[2].length > 255 + || !/^(?:|0|[1-9]\d{0,9})$/.test(fields[3]) + ) throw new Error("Darwin ACL authority inspection was malformed"); + const disposition = fields[4].split(","); + if (disposition[0] !== "allow" && disposition[0] !== "deny") { + throw new Error("Darwin ACL authority inspection was malformed"); + } + if (disposition.slice(1).some((flag) => !DARWIN_ACL_FLAGS.has(flag))) { + throw new Error("Darwin ACL authority inspection was malformed"); + } + for (const permission of fields[5].split(",")) { + if (disposition[0] === "allow" && DARWIN_MUTATING_ACL_PERMISSIONS.has(permission)) { + throw new Error("Darwin ACL grants unexpected write authority"); + } + if (!DARWIN_READ_ONLY_ACL_PERMISSIONS.has(permission) && !DARWIN_MUTATING_ACL_PERMISSIONS.has(permission)) { + throw new Error("Darwin ACL authority inspection was malformed"); + } + } + } +} + +export async function assertNativeEntryAuthority( + inspector: ConnectRootAuthorityInspector, + platform: NodeJS.Platform, + path: string, + kind: ConnectAuthorityEntryKind, + pinnedFd: number, +): Promise { + const before = stableAuthorityIdentity(pinnedFd); + if (platform === "darwin") { + const inspection = inspector.inspectDarwinAcl(path, pinnedFd, before); + assertDarwinInspectionShape(inspection); + if (inspection.device !== before.device || inspection.file !== before.file) { + throw new Error("Darwin authority inspection did not match the pinned object"); + } + assertSafeDarwinAclOutput(inspection.acl); + } else if (platform === "win32") { + const inspection = await inspector.inspectWindowsAcl(path, before, pinnedFd, kind); + try { + assertWindowsInspectionShape(inspection); + } catch { + reportWindowsNativeStage("parent:entry-shape"); + throw new WindowsAuthorityInspectionError(); + } + try { + if ( + inspection.index !== 0 + || inspection.authorityKind !== kind + || inspection.kind !== windowsInspectionEntryKind(kind) + || inspection.currentUserSid.length === 0 + || BigInt(inspection.volumeSerialNumber) !== BigInt(before.device) + || BigInt(inspection.fileId) !== BigInt(before.file) + || BigInt(inspection.volumeSerialNumber) !== BigInt(inspection.verifiedVolumeSerialNumber) + || BigInt(inspection.fileId) !== BigInt(inspection.verifiedFileId) + ) throw new Error(); + } catch { + throw new WindowsAuthorityInspectionError(); + } + assertSafeWindowsAuthority(inspection, kind); + } + const after = stableAuthorityIdentity(pinnedFd); + if (before.device !== after.device || before.file !== after.file) { + throw new Error("native authority target changed during inspection"); + } +} + +/** Inspect and bind one Windows descriptor batch before applying entry policy. */ +export async function assertNativeWindowsEntriesAuthority( + inspector: ConnectRootAuthorityInspector, + entries: readonly { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }[], +): Promise { + const targets = entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + expectedIdentity: stableAuthorityIdentity(entry.pinnedFd), + pinnedFd: entry.pinnedFd, + })); + const batched = inspector.inspectWindowsAcls !== undefined; + const inspections = inspector.inspectWindowsAcls + ? await inspector.inspectWindowsAcls(targets) + : await Promise.all(targets.map((target) => inspector.inspectWindowsAcl( + target.path, target.expectedIdentity, target.pinnedFd, target.kind, + ))); + if (inspections.length !== targets.length) { + reportWindowsNativeStage("parent:entry-count"); + throw new WindowsAuthorityInspectionError(); + } + for (let index = 0; index < targets.length; index += 1) { + const after = stableAuthorityIdentity(entries[index].pinnedFd); + if (after.device !== targets[index].expectedIdentity.device || after.file !== targets[index].expectedIdentity.file) { + reportWindowsNativeStage("parent:post-bind"); + throw new WindowsAuthorityInspectionError(); + } + } + let currentUserSid: string | undefined; + let totalAces = 0; + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const inspection = inspections[index]; + try { + assertWindowsInspectionShape(inspection); + } catch { + reportWindowsNativeStage("parent:entry-shape"); + throw new WindowsAuthorityInspectionError(); + } + try { + totalAces += inspection.rules.length; + if ( + inspection.index !== (batched ? index : 0) + || inspection.authorityKind !== target.kind + || inspection.kind !== windowsInspectionEntryKind(target.kind) + || (currentUserSid !== undefined && inspection.currentUserSid !== currentUserSid) + || BigInt(inspection.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) + || BigInt(inspection.fileId) !== BigInt(target.expectedIdentity.file) + || BigInt(inspection.volumeSerialNumber) !== BigInt(inspection.verifiedVolumeSerialNumber) + || BigInt(inspection.fileId) !== BigInt(inspection.verifiedFileId) + || totalAces > WINDOWS_AUTHORITY_MAX_TOTAL_ACES + ) throw new Error(); + currentUserSid = inspection.currentUserSid; + } catch { + reportWindowsNativeStage("parent:descriptor-bind"); + throw new WindowsAuthorityInspectionError(); + } + assertSafeWindowsAuthority(inspection, target.kind); + } +} + +export { parseWindowsInspectionDocument }; diff --git a/packages/cli/src/connectWindowsAuthority.ts b/packages/cli/src/connectWindowsAuthority.ts new file mode 100644 index 000000000..0ff26064e --- /dev/null +++ b/packages/cli/src/connectWindowsAuthority.ts @@ -0,0 +1,663 @@ +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + realpathSync, +} from "node:fs"; +import { win32 } from "node:path"; +import { performance } from "node:perf_hooks"; +import type { + ConnectAuthorityEntryKind, + WindowsAuthorityInspection, + WindowsAuthorityTarget, +} from "./connectRootAuthority.js"; + +// Hosted alternate-user Windows can spend more than fifteen seconds entering +// the fixed PowerShell/Reflection.Emit boundary. Each production call gets one +// bounded cold-start allowance. The cumulative cap is a fixed four-process +// proof ceiling and is independent of the 32-entry input-schema bound. +export const WINDOWS_INSPECTION_TIMEOUT_MS = 60_000; +export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000; +export const WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS = 60_000; +const WINDOWS_INSPECTION_MAX_BYTES = 128 * 1024; +const WINDOWS_NATIVE_PROBE_MAX_BYTES = 2 * 1024; +const WINDOWS_INSPECTION_MAX_ENTRIES = 32; +const GLOBAL_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`; + +export const WINDOWS_NATIVE_STAGE_CODES = Object.freeze([ + "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", + "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", + "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", + "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", + "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", +] as const); + +export type WindowsNativeStageCode = (typeof WINDOWS_NATIVE_STAGE_CODES)[number]; + +const WINDOWS_NATIVE_STAGE_SET: ReadonlySet = new Set(WINDOWS_NATIVE_STAGE_CODES); +const WINDOWS_NATIVE_DIAGNOSTIC_HOOK = Symbol.for("propr.test.windowsNativeDiagnostic"); + +export class WindowsNativeStageError extends Error { + constructor(readonly stage: WindowsNativeStageCode) { + super("Windows native authority inspection failed"); + this.name = "WindowsNativeStageError"; + } +} + +export function reportWindowsNativeStage(stage: WindowsNativeStageCode): void { + if (!WINDOWS_NATIVE_STAGE_SET.has(stage)) return; + const hook = (globalThis as Record)[WINDOWS_NATIVE_DIAGNOSTIC_HOOK]; + if (typeof hook !== "function") return; + try { (hook as (value: string) => void)(stage); } catch { /* Diagnostics never alter production status. */ } +} + +function stageError(stage: WindowsNativeStageCode): WindowsNativeStageError { + return new WindowsNativeStageError(stage); +} + +// Each production inspector receives exactly one already-open target as its +// standard-input HANDLE. Unlike Node extra stdio slots, STARTF_USESTDHANDLES is +// a documented Windows process boundary and GetStdHandle returns the inherited +// HANDLE directly. The script contains no process-creation API or external +// command; terminating powershell.exe therefore terminates the complete tree. +export const WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false; +export const WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false; +export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-standard-handle" as const; + +export const WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE = String.raw` +function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ + if(-not [BitConverter]::IsLittleEndian){exit $stage} + $signed=[int32][Runtime.InteropServices.Marshal]::ReadInt32($pointer,$offset) + $bytes=[BitConverter]::GetBytes($signed) + [BitConverter]::ToUInt32($bytes,0) +}`; + +export const WINDOWS_UINT64_COMPOSER_SOURCE = String.raw` +function Join-ProprUInt64([uint32]$low,[uint32]$high){ + if(-not [BitConverter]::IsLittleEndian){exit $stage} + $bytes=New-Object byte[] 8 + [Array]::Copy([BitConverter]::GetBytes([uint32]$low),0,$bytes,0,4) + [Array]::Copy([BitConverter]::GetBytes([uint32]$high),0,$bytes,4,4) + [BitConverter]::ToUInt64($bytes,0) +}`; + +// Reflection.Emit keeps the fixed P/Invoke surface in memory. Add-Type and its +// writable compiler workspace are deliberately absent. +export const WINDOWS_INSPECTION_SOURCE = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +Set-StrictMode -Version 2 +${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} +${WINDOWS_UINT64_COMPOSER_SOURCE} +$stage=71 +$privateHandle=[IntPtr]::Zero +$privateHandleOwned=$false +try { + if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( + (New-Object Reflection.AssemblyName('ProprReadOnlyAuthorityAssembly')), + [Reflection.Emit.AssemblyBuilderAccess]::Run) + $module=$assembly.DefineDynamicModule('ProprReadOnlyAuthorityModule') + $builder=$module.DefineType('ProprReadOnlyAuthority',[Reflection.TypeAttributes]'Public,Abstract,Sealed') + function Add-NativeMethod($name,$library,$returnType,[Type[]]$parameters,$nativeConvention){ + $method=$builder.DefinePInvokeMethod($name,$library, + [Reflection.MethodAttributes]'Public,Static,PinvokeImpl',[Reflection.CallingConventions]::Standard, + $returnType,$parameters,$nativeConvention,[Runtime.InteropServices.CharSet]::Unicode) + $method.SetImplementationFlags($method.GetMethodImplementationFlags()-bor [Reflection.MethodImplAttributes]::PreserveSig) + } + $winapi=[Runtime.InteropServices.CallingConvention]::Winapi + $intptr=[IntPtr];$intptrRef=$intptr.MakeByRefType();$uint=[uint32];$uintRef=$uint.MakeByRefType();$ushortRef=([uint16]).MakeByRefType();$boolRef=([bool]).MakeByRefType() + Add-NativeMethod 'GetStdHandle' 'kernel32.dll' $intptr @([int]) $winapi + Add-NativeMethod 'DuplicateHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr,$intptr,$intptrRef,$uint,[bool],$uint) $winapi + Add-NativeMethod 'CloseHandle' 'kernel32.dll' ([bool]) @($intptr) $winapi + Add-NativeMethod 'GetFileInformationByHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr) $winapi + Add-NativeMethod 'GetSecurityInfo' 'advapi32.dll' $uint @($intptr,$uint,$uint,$intptrRef,$intptrRef,$intptrRef,$intptrRef,$intptrRef) $winapi + Add-NativeMethod 'GetSecurityDescriptorControl' 'advapi32.dll' ([bool]) @($intptr,$ushortRef,$uintRef) $winapi + Add-NativeMethod 'GetAclInformation' 'advapi32.dll' ([bool]) @($intptr,$intptr,$uint,$uint) $winapi + Add-NativeMethod 'GetAce' 'advapi32.dll' ([bool]) @($intptr,$uint,$intptrRef) $winapi + Add-NativeMethod 'LocalFree' 'kernel32.dll' $intptr @($intptr) $winapi + Add-NativeMethod 'IsProcessInJob' 'kernel32.dll' ([bool]) @($intptr,$intptr,$boolRef) $winapi + Add-NativeMethod 'GetCurrentProcess' 'kernel32.dll' $intptr @() $winapi + $null=$builder.CreateType() + $stage=72 + $inJob=$false + if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){exit $stage} + $stage=73 + $originalHandle=[ProprReadOnlyAuthority]::GetStdHandle(-10) + if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){exit $stage} + $stage=80 + if(-not [ProprReadOnlyAuthority]::DuplicateHandle( + [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, + [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){exit $stage} + $privateHandleOwned=$true + if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){exit $stage} + $stage=74 + $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){exit $stage} + $stage=78 + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null-eq $current){exit $stage} + $currentSid=$current.Value + $stage=75 + $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero;$descriptor=[IntPtr]::Zero + try { + if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){exit $stage} + if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){exit $stage} + $ownerSid=(New-Object Security.Principal.SecurityIdentifier($owner)).Value + $control=[uint16]0;$revision=[uint32]0 + if(-not [ProprReadOnlyAuthority]::GetSecurityDescriptorControl($descriptor,[ref]$control,[ref]$revision)){exit $stage} + $stage=76 + $aclInfo=[Runtime.InteropServices.Marshal]::AllocHGlobal(12) + if(-not [ProprReadOnlyAuthority]::GetAclInformation($dacl,$aclInfo,12,2)){exit $stage} + $aceCount=Read-ProprUInt32 $aclInfo 0 + $aclBytes=Read-ProprUInt32 $aclInfo 4 + if($aceCount-gt 128-or $aclBytes-lt 8-or $aclBytes-gt 65535){exit $stage} + $aclRevision=[Runtime.InteropServices.Marshal]::ReadByte($dacl,0) + if(($aclRevision-ne 2-and $aclRevision-ne 4)-or [Runtime.InteropServices.Marshal]::ReadByte($dacl,1)-ne 0){exit $stage} + $rules=New-Object Collections.Generic.List[object] + for($aceIndex=0;$aceIndex-lt $aceCount;$aceIndex++){ + $ace=[IntPtr]::Zero + if(-not [ProprReadOnlyAuthority]::GetAce($dacl,$aceIndex,[ref]$ace)-or $ace-eq [IntPtr]::Zero){exit $stage} + $aceType=[Runtime.InteropServices.Marshal]::ReadByte($ace,0);$flags=[Runtime.InteropServices.Marshal]::ReadByte($ace,1) + $aceSize=[uint16][Runtime.InteropServices.Marshal]::ReadInt16($ace,2) + if(($aceType-ne 0-and $aceType-ne 1)-or ($flags-band 0xE0)-ne 0-or $aceSize-lt 16-or $aceSize-gt 4096){exit $stage} + $mask=Read-ProprUInt32 $ace 4 + $sidPointer=[IntPtr]::Add($ace,8);$sid=New-Object Security.Principal.SecurityIdentifier($sidPointer) + if($sid.BinaryLength-gt ($aceSize-8)){exit $stage} + $rules.Add([pscustomobject][ordered]@{ + identitySid=$sid.Value;inherited=[bool](($flags-band 0x10)-ne 0) + accessType=$(if($aceType-eq 0){'allow'}else{'deny'});appliesToSelf=[bool](($flags-band 8)-eq 0) + rights=$mask.ToString([Globalization.CultureInfo]::InvariantCulture) + }) + } + } finally {if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)}} + $stage=79 + $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){exit $stage} + $stage=81 + $beforeVolume=Read-ProprUInt32 $before 28 + $afterVolume=Read-ProprUInt32 $after 28 + $beforeHigh=Read-ProprUInt32 $before 44;$beforeLow=Read-ProprUInt32 $before 48 + $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 + $stage=82 + $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh + if($beforeId-isnot [uint64]){exit $stage} + $afterId=Join-ProprUInt64 $afterLow $afterHigh + if($afterId-isnot [uint64]){exit $stage} + $stage=84 + $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) + if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + $stage=85 + $daclProtected=[bool](($control-band 0x1000)-ne 0) + $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) + if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage} + $stage=86 + [object[]]$rulesArray=$rules.ToArray() + if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage} + for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ + if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage} + } + $stage=83 + $entry=[pscustomobject][ordered]@{ + index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid + daclProtected=$daclProtected;reparsePoint=$reparsePoint + volumeSerialNumber=$beforeVolumeDecimal + fileId=$beforeIdDecimal + verifiedVolumeSerialNumber=$afterVolumeDecimal + verifiedFileId=$afterIdDecimal;rules=$rulesArray + } + $stage=77 + $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=@($entry)}) -Compress -Depth 5 + if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){exit $stage} + [Console]::OutputEncoding=New-Object Text.UTF8Encoding($false,$true) + [Console]::Out.Write($json) + exit 0 +}catch{exit $stage} +finally {if($privateHandleOwned){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)}} +`; + +export const WINDOWS_NATIVE_PROBE_MILESTONES = Object.freeze([ + "entry-ps51-desktop-x64", + "constant-json", + "reflection-emit", + "harmless-win32", + "standard-handle-identity", +] as const); + +export type WindowsNativeProbeMilestone = (typeof WINDOWS_NATIVE_PROBE_MILESTONES)[number]; + +export const WINDOWS_NATIVE_TIMING_BUCKETS = Object.freeze([ + "under-5s", "5-to-15s", "15-to-30s", "30-to-45s", "45-to-60s", "at-least-60s", +] as const); + +export type WindowsNativeTimingBucket = (typeof WINDOWS_NATIVE_TIMING_BUCKETS)[number]; + +export const WINDOWS_NATIVE_TIMING_PROBE_SOURCE = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +Set-StrictMode -Version 2 +${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} +${WINDOWS_UINT64_COMPOSER_SOURCE} +$clock=[Diagnostics.Stopwatch]::StartNew() +function Write-ProprMilestone([string]$name){ + $elapsed=$clock.ElapsedMilliseconds + $bucket=if($elapsed-lt 5000){'under-5s'}elseif($elapsed-lt 15000){'5-to-15s'}elseif($elapsed-lt 30000){'15-to-30s'}elseif($elapsed-lt 45000){'30-to-45s'}elseif($elapsed-lt 60000){'45-to-60s'}else{'at-least-60s'} + [Console]::Out.WriteLine(('PROPR_NATIVE_PROBE_V1|{0}|{1}' -f $name,$bucket)) + [Console]::Out.Flush() +} +$stage=91 +try { + if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + Write-ProprMilestone 'entry-ps51-desktop-x64' + $stage=92 + $baseline='{"version":1,"baseline":"constant"}' + if($baseline-ne '{"version":1,"baseline":"constant"}'){exit $stage} + Write-ProprMilestone 'constant-json' + $stage=93 + $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( + (New-Object Reflection.AssemblyName('ProprNativeTimingProbeAssembly')), + [Reflection.Emit.AssemblyBuilderAccess]::Run) + $module=$assembly.DefineDynamicModule('ProprNativeTimingProbeModule') + $builder=$module.DefineType('ProprNativeTimingProbe',[Reflection.TypeAttributes]'Public,Abstract,Sealed') + function Add-ProprNativeMethod($name,$returnType,[Type[]]$parameters){ + $method=$builder.DefinePInvokeMethod($name,'kernel32.dll', + [Reflection.MethodAttributes]'Public,Static,PinvokeImpl',[Reflection.CallingConventions]::Standard, + $returnType,$parameters,[Runtime.InteropServices.CallingConvention]::Winapi,[Runtime.InteropServices.CharSet]::Unicode) + $method.SetImplementationFlags($method.GetMethodImplementationFlags()-bor [Reflection.MethodImplAttributes]::PreserveSig) + } + $intptr=[IntPtr] + Add-ProprNativeMethod 'GetCurrentProcessId' ([uint32]) @() + Add-ProprNativeMethod 'GetStdHandle' $intptr @([int]) + Add-ProprNativeMethod 'GetFileInformationByHandle' ([bool]) @($intptr,$intptr) + $null=$builder.CreateType() + Write-ProprMilestone 'reflection-emit' + $stage=94 + if([ProprNativeTimingProbe]::GetCurrentProcessId()-eq 0){exit $stage} + Write-ProprMilestone 'harmless-win32' + $stage=95 + $handle=[ProprNativeTimingProbe]::GetStdHandle(-10) + if($handle-eq [IntPtr](-1)-or $handle-eq [IntPtr](-2)-or $handle-eq [IntPtr]::Zero){exit $stage} + $info=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprNativeTimingProbe]::GetFileInformationByHandle($handle,$info)){exit $stage} + $probeVolume=Read-ProprUInt32 $info 28 + $probeHigh=Read-ProprUInt32 $info 44;$probeLow=Read-ProprUInt32 $info 48 + $probeId=Join-ProprUInt64 $probeLow $probeHigh + if($probeId-isnot [uint64]){exit $stage} + $probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture) + if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + Write-ProprMilestone 'standard-handle-identity' + exit 0 +}catch{exit $stage} +`; + +interface HeldExecutable { + readonly path: string; + readonly systemRoot: string; + readonly fd: number; + readonly device: string; + readonly file: string; +} + +function sameWindowsPath(left: string, right: string): boolean { + return win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase(); +} + +function ordinaryDosPath(value: string): boolean { + return value.length >= 4 + && value.length < 32_768 + && /^[A-Za-z]:\\[^\0\r\n]+$/.test(value) + && !value.split("\\").some((part) => part === "." || part === ".."); +} + +function resolveWindowsPowerShell(): HeldExecutable { + if (process.platform !== "win32" || process.arch === "ia32") throw stageError("resolver:env"); + const suppliedRoot = process.env.SystemRoot; + const suppliedWindir = process.env.WINDIR; + if (!suppliedRoot || !suppliedWindir || !ordinaryDosPath(suppliedRoot) || !ordinaryDosPath(suppliedWindir)) { + throw stageError("resolver:env"); + } + let canonicalSupplied: string; + let canonicalWindir: string; + try { + canonicalSupplied = realpathSync.native(suppliedRoot); + canonicalWindir = realpathSync.native(suppliedWindir); + } catch { throw stageError("resolver:canonical"); } + if ( + !ordinaryDosPath(canonicalSupplied) + || !sameWindowsPath(canonicalSupplied, canonicalWindir) + || !sameWindowsPath(suppliedRoot, canonicalSupplied) + || !sameWindowsPath(suppliedWindir, canonicalWindir) + ) throw stageError("resolver:canonical"); + const path = win32.join(canonicalSupplied, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + let canonicalPath: string; + try { canonicalPath = realpathSync.native(path); } catch { throw stageError("resolver:canonical"); } + let named: ReturnType; + try { named = lstatSync(path, { bigint: true }); } catch { throw stageError("resolver:canonical"); } + if (!sameWindowsPath(path, canonicalPath) || !named.isFile() || named.isSymbolicLink()) { + throw stageError("resolver:canonical"); + } + let fd: number; + try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch { throw stageError("resolver:canonical"); } + let globalFd: number | undefined; + try { + const held = fstatSync(fd, { bigint: true }); + try { + globalFd = openSync( + `${GLOBAL_SYSTEM_ROOT}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + } catch { throw stageError("resolver:global-open"); } + const global = fstatSync(globalFd, { bigint: true }); + if (!held.isFile() || !global.isFile() || held.dev !== named.dev || held.ino !== named.ino + || held.dev !== global.dev || held.ino !== global.ino) throw stageError("resolver:global-id"); + return { path, systemRoot: canonicalSupplied, fd, device: held.dev.toString(10), file: held.ino.toString(10) }; + } catch (error) { + closeSync(fd); + throw error; + } finally { + if (globalFd !== undefined) closeSync(globalFd); + } +} + +function revalidateWindowsPowerShell(executable: HeldExecutable): void { + let namedFd: number | undefined; + try { + try { namedFd = openSync(executable.path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch { + throw stageError("resolver:global-id"); + } + const held = fstatSync(executable.fd, { bigint: true }); + const named = fstatSync(namedFd, { bigint: true }); + if ( + !held.isFile() || !named.isFile() + || held.dev.toString(10) !== executable.device || held.ino.toString(10) !== executable.file + || named.dev.toString(10) !== executable.device || named.ino.toString(10) !== executable.file + ) throw stageError("resolver:global-id"); + } finally { + if (namedFd !== undefined) closeSync(namedFd); + } +} + +function strictUtf8(value: Buffer | string | null | undefined): string { + const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : (value ?? Buffer.alloc(0)); + if (bytes.byteLength === 0 || bytes.byteLength > WINDOWS_INSPECTION_MAX_BYTES) { + throw stageError("parent:utf8"); + } + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { + throw stageError("parent:utf8"); + } +} + +export function parseWindowsInspectionDocument(value: Buffer | string): readonly WindowsAuthorityInspection[] { + const text = strictUtf8(value); + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { throw stageError("parent:json-parse"); } + if (JSON.stringify(parsed) !== text) throw stageError("parent:json-canonical"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw stageError("parent:document-shape"); + } + const document = parsed as Record; + if (Object.keys(document).sort().join(",") !== "entries,version" || document.version !== 1 + || !Array.isArray(document.entries) || document.entries.length > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("parent:document-shape"); + } + return document.entries as WindowsAuthorityInspection[]; +} + +export function windowsBrokerFailureStage(status: number | null): WindowsNativeStageCode { + const stages: Readonly> = { + 71: "broker:ps-version", 72: "broker:job", 73: "broker:fd", 74: "broker:index-info-initial", + 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", + 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", + 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", + 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", + }; + return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); +} + +function inspectionSource(target: WindowsAuthorityTarget, index: number): string { + const entryKind = target.kind === "env" ? "file" : "directory"; + return WINDOWS_INSPECTION_SOURCE + .replace("__PROPR_INDEX__", String(index)) + .replace("__PROPR_ENTRY_KIND__", entryKind) + .replace("__PROPR_AUTHORITY_KIND__", target.kind); +} + +/** The fixed inspector receives no caller-controlled executable/module/profile/temp authority. */ +export function windowsPowerShellEnvironment(systemRoot: string): Readonly> { + if (!ordinaryDosPath(systemRoot)) throw stageError("resolver:env"); + return Object.freeze({ SystemRoot: systemRoot, WINDIR: systemRoot }); +} + +function spawnPowerShell( + executable: HeldExecutable, + source: string, + stdin: "ignore" | number, + timeout = WINDOWS_INSPECTION_TIMEOUT_MS, + maxBuffer = WINDOWS_INSPECTION_MAX_BYTES, +) { + const encoded = Buffer.from(source, "utf16le").toString("base64"); + if (encoded.length > 28_000) throw stageError("spawn:create"); + try { + return spawnSync(executable.path, [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, + ], { + shell: false, + windowsHide: true, + encoding: "buffer", + cwd: win32.dirname(executable.path), + env: windowsPowerShellEnvironment(executable.systemRoot), + timeout, + killSignal: "SIGKILL", + maxBuffer, + stdio: [stdin, "pipe", "pipe"], + }); + } catch { throw stageError("spawn:create"); } +} + +export interface WindowsNativeProbeRecord { + readonly milestone: WindowsNativeProbeMilestone; + readonly timingBucket: WindowsNativeTimingBucket; +} + +export interface WindowsNativeTimingProof { + readonly version: 1; + readonly outcome: "complete" | "timeout"; + readonly lastMilestone: WindowsNativeProbeMilestone | "none"; + readonly timingBucket: WindowsNativeTimingBucket; + /** Present only after complete strict-prefix validation; timeout diagnostics retain only the last token. */ + readonly milestones: readonly WindowsNativeProbeRecord[]; +} + +export function windowsNativeTimingBucket(elapsedMs: number): WindowsNativeTimingBucket { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) throw stageError("probe:output"); + if (elapsedMs < 5_000) return "under-5s"; + if (elapsedMs < 15_000) return "5-to-15s"; + if (elapsedMs < 30_000) return "15-to-30s"; + if (elapsedMs < 45_000) return "30-to-45s"; + if (elapsedMs < 60_000) return "45-to-60s"; + return "at-least-60s"; +} + +export function parseWindowsNativeProbeOutput( + value: Buffer | string | null | undefined, + allowTruncatedFinalToken = false, +): readonly WindowsNativeProbeRecord[] { + const bytes = typeof value === "string" + ? Buffer.from(value, "utf8") + : (value ?? Buffer.alloc(0)); + if (bytes.byteLength > WINDOWS_NATIVE_PROBE_MAX_BYTES) throw stageError("probe:output"); + let text: string; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { + throw stageError("probe:output"); + } + if (text.length === 0) return []; + const lines = text.split(/\r?\n/); + if (lines.at(-1) === "") lines.pop(); + else if (allowTruncatedFinalToken) lines.pop(); + else throw stageError("probe:output"); + if (lines.length > WINDOWS_NATIVE_PROBE_MILESTONES.length) throw stageError("probe:output"); + const records: WindowsNativeProbeRecord[] = []; + let priorBucket = -1; + for (let index = 0; index < lines.length; index += 1) { + const milestone = WINDOWS_NATIVE_PROBE_MILESTONES[index]; + const prefix = `PROPR_NATIVE_PROBE_V1|${milestone}|`; + if (!lines[index].startsWith(prefix)) throw stageError("probe:output"); + const timingBucket = lines[index].slice(prefix.length); + const bucketIndex = (WINDOWS_NATIVE_TIMING_BUCKETS as readonly string[]).indexOf(timingBucket); + if (bucketIndex < priorBucket || bucketIndex < 0) throw stageError("probe:output"); + priorBucket = bucketIndex; + records.push({ milestone, timingBucket: timingBucket as WindowsNativeTimingBucket }); + } + return records; +} + +function assertSpawnSuccess(result: ReturnType): void { + if (result.error) { + if ((result.error as NodeJS.ErrnoException).code === "ETIMEDOUT") throw stageError("spawn:timeout"); + throw stageError("spawn:error"); + } + if (result.signal) throw stageError(result.signal === "SIGKILL" ? "spawn:timeout" : "spawn:status"); + if (result.status !== 0) throw stageError(windowsBrokerFailureStage(result.status)); + const stderrBytes = typeof result.stderr === "string" + ? Buffer.byteLength(result.stderr, "utf8") + : (result.stderr?.byteLength ?? 0); + if (stderrBytes !== 0) throw stageError("spawn:stderr"); +} + +export function windowsInspectionTimeoutForElapsed(elapsedMs: number): number { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) throw stageError("spawn:cumulative-timeout"); + const remaining = WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS - Math.floor(elapsedMs); + if (remaining <= 0) throw stageError("spawn:cumulative-timeout"); + return Math.min(WINDOWS_INSPECTION_TIMEOUT_MS, remaining); +} + +export function runWindowsReadOnlyInspection( + targets: readonly WindowsAuthorityTarget[], +): readonly WindowsAuthorityInspection[] { + if (targets.length < 1 || targets.length > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("parent:entry-count"); + } + const executable = resolveWindowsPowerShell(); + const inspections: WindowsAuthorityInspection[] = []; + let totalOutputBytes = 0; + const inspectionStarted = performance.now(); + try { + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const timeout = windowsInspectionTimeoutForElapsed(performance.now() - inspectionStarted); + const result = spawnPowerShell(executable, inspectionSource(target, index), target.pinnedFd, timeout); + assertSpawnSuccess(result); + totalOutputBytes += typeof result.stdout === "string" + ? Buffer.byteLength(result.stdout, "utf8") + : (result.stdout?.byteLength ?? 0); + if (totalOutputBytes > WINDOWS_INSPECTION_MAX_BYTES) throw stageError("parent:utf8"); + const entries = parseWindowsInspectionDocument(result.stdout ?? Buffer.alloc(0)); + if (entries.length !== 1) throw stageError("parent:entry-count"); + const entry = entries[0]; + try { + if ( + entry.index !== index + || entry.kind !== (target.kind === "env" ? "file" : "directory") + || entry.authorityKind !== target.kind + || BigInt(entry.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) + || BigInt(entry.fileId) !== BigInt(target.expectedIdentity.file) + || BigInt(entry.volumeSerialNumber) !== BigInt(entry.verifiedVolumeSerialNumber) + || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) + ) throw new Error(); + } catch { throw stageError("parent:descriptor-bind"); } + const after = fstatSync(target.pinnedFd, { bigint: true }); + if (after.dev.toString(10) !== target.expectedIdentity.device || after.ino.toString(10) !== target.expectedIdentity.file) { + throw stageError("parent:post-bind"); + } + inspections.push(entry); + } + revalidateWindowsPowerShell(executable); + return inspections; + } finally { + closeSync(executable.fd); + } +} + +function probeFailureStage(status: number | null): WindowsNativeStageCode { + const stages: Readonly> = { + 91: "probe:entry", + 92: "probe:baseline", + 93: "probe:reflection-emit", + 94: "probe:win32", + 95: "probe:standard-handle", + }; + return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); +} + +export function runWindowsNativeTimingProbe(targetFd: number): WindowsNativeTimingProof { + const executable = resolveWindowsPowerShell(); + try { + const started = performance.now(); + const result = spawnPowerShell( + executable, + WINDOWS_NATIVE_TIMING_PROBE_SOURCE, + targetFd, + WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, + WINDOWS_NATIVE_PROBE_MAX_BYTES, + ); + const elapsed = performance.now() - started; + const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; + const records = parseWindowsNativeProbeOutput(result.stdout, timedOut); + const stderrBytes = typeof result.stderr === "string" + ? Buffer.byteLength(result.stderr, "utf8") + : (result.stderr?.byteLength ?? 0); + if (stderrBytes !== 0) throw stageError("spawn:stderr"); + if (timedOut) { + const proof: WindowsNativeTimingProof = { + version: 1, + outcome: "timeout", + lastMilestone: records.at(-1)?.milestone ?? "none", + timingBucket: windowsNativeTimingBucket(elapsed), + milestones: [], + }; + revalidateWindowsPowerShell(executable); + return proof; + } + if (result.error) throw stageError("spawn:error"); + if (result.signal) throw stageError("spawn:status"); + if (result.status !== 0) throw stageError(probeFailureStage(result.status)); + if ( + records.length !== WINDOWS_NATIVE_PROBE_MILESTONES.length + || records.some((record, index) => record.milestone !== WINDOWS_NATIVE_PROBE_MILESTONES[index]) + ) throw stageError("probe:output"); + const proof: WindowsNativeTimingProof = { + version: 1, + outcome: "complete", + lastMilestone: "standard-handle-identity", + // Script buckets separate the in-process stages; this parent bucket also + // includes executable startup before the first token can be written. + timingBucket: windowsNativeTimingBucket(elapsed), + milestones: records, + }; + revalidateWindowsPowerShell(executable); + return proof; + } finally { + closeSync(executable.fd); + } +} + +export function windowsInspectionEntryKind(kind: ConnectAuthorityEntryKind): "directory" | "file" { + return kind === "env" ? "file" : "directory"; +} diff --git a/packages/cli/src/desktopDiscovery.test.ts b/packages/cli/src/desktopDiscovery.test.ts new file mode 100644 index 000000000..f1ad66039 --- /dev/null +++ b/packages/cli/src/desktopDiscovery.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { ConfigManager } from './config/ConfigManager.js'; +import { discoverConfiguredConnect } from './desktopDiscovery.js'; +import type { DesktopConnectDiscoverySmokeDiagnostic } from './desktopDiscovery.js'; +import type { ConnectStatusDocument } from './commands/connectCommand.js'; + +const directories: string[] = []; + +after(async () => { + await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); +}); + +describe('fixed desktop Connect discovery entry point', () => { + test('Linux configured discovery executes the target-native directory authority addon', { + skip: process.platform !== 'linux' || (process.arch !== 'x64' && process.arch !== 'arm64') + ? 'requires a packaged Linux native addon target' + : false, + }, async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-desktop-discovery-linux-')); + directories.push(parent); + const configRoot = join(parent, '.propr'); + const nativeRoot = join(parent, 'stack'); + const config = new ConfigManager(configRoot, { warn: () => undefined }); + await config.init(); + await config.setStackRoot(nativeRoot); + let receivedRoot: string | undefined; + const status: ConnectStatusDocument = { + schemaVersion: 1, + status: 'notReady', + canonicalEndpoint: null, + publicInstanceIdentity: null, + configured: false, + enabled: false, + sidecarRunning: false, + apiReady: false, + restartRequired: false, + compatibility: null, + version: null, + reasonCodes: ['NOT_CONFIGURED'], + }; + const diagnostics: DesktopConnectDiscoverySmokeDiagnostic[] = []; + + assert.equal(await discoverConfiguredConnect({ + configRoot, + platform: 'linux', + reportSmokeDiagnostic: diagnostic => diagnostics.push(diagnostic), + readStatus: async root => { + receivedRoot = root; + return status; + }, + }), status); + assert.equal(receivedRoot, nativeRoot); + assert.deepEqual(diagnostics, [ + { phase: 'config-read', code: 'STARTED' }, + { phase: 'config-read', code: 'PASSED' }, + { phase: 'addon-integrity-type', code: 'STARTED' }, + { phase: 'addon-integrity-type', code: 'PASSED' }, + { phase: 'addon-load', code: 'STARTED' }, + { phase: 'addon-load', code: 'PASSED' }, + { phase: 'descriptor-operation', code: 'STARTED' }, + { phase: 'descriptor-operation', code: 'PASSED' }, + { phase: 'status-resolution', code: 'STARTED' }, + { phase: 'status-resolution', code: 'PASSED' }, + ]); + }); + + test('ordinary Windows discovery reads only the saved native root from fixed config', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-desktop-discovery-')); + directories.push(parent); + const configRoot = join(parent, '.propr'); + const nativeRoot = String.raw`C:\Users\standard\propr-stack`; + const config = new ConfigManager(configRoot, { warn: () => undefined }); + await config.init(); + await config.setStackRoot(nativeRoot); + let receivedRoot: string | undefined; + const status: ConnectStatusDocument = { + schemaVersion: 1, + status: 'notReady', + canonicalEndpoint: null, + publicInstanceIdentity: null, + configured: false, + enabled: false, + sidecarRunning: false, + apiReady: false, + restartRequired: false, + compatibility: null, + version: null, + reasonCodes: ['NOT_CONFIGURED'], + }; + + assert.equal(await discoverConfiguredConnect({ + configRoot, + platform: 'win32', + readStatus: async root => { + receivedRoot = root; + return status; + }, + }), status); + assert.equal(receivedRoot, nativeRoot); + }); +}); diff --git a/packages/cli/src/desktopDiscovery.ts b/packages/cli/src/desktopDiscovery.ts new file mode 100644 index 000000000..c189fd678 --- /dev/null +++ b/packages/cli/src/desktopDiscovery.ts @@ -0,0 +1,98 @@ +import { + getLocalConnectStatus, + type ConnectStatusDocument, + type LocalConnectStatusDependencies, +} from './commands/connectCommand.js'; +import { createConfigManager } from './config/index.js'; +import { + assertNativeDirectoryEntry, + type NativeDirectorySmokeFailureCategory, + type NativeDirectorySmokeSubstep, +} from './utils/directoryDescriptor.js'; + +export const DESKTOP_CONNECT_DISCOVERY_PLATFORMS: ReadonlySet = new Set([ + 'darwin', + 'linux', + 'win32', +]); + +export interface FixedConnectDiscoveryOptions { + /** Fixed CLI configuration directory selected by the trusted desktop main process. */ + configRoot: string; + platform?: NodeJS.Platform; + readStatus?: (root: string | undefined) => Promise; + /** @internal Packaged smoke keeps native authority real while replacing external network/process probes. */ + statusDependencies?: LocalConnectStatusDependencies; + /** @internal Packaged smoke emits only these fixed phase/code pairs. */ + reportSmokeDiagnostic?: (diagnostic: DesktopConnectDiscoverySmokeDiagnostic) => void; +} + +export type DesktopConnectDiscoverySmokePhase = + | 'config-read' + | 'addon-integrity-type' + | 'addon-load' + | 'descriptor-operation' + | 'authority-inspection' + | 'status-resolution'; + +export interface DesktopConnectDiscoverySmokeDiagnostic { + readonly phase: DesktopConnectDiscoverySmokePhase; + readonly code: 'STARTED' | 'PASSED' | 'FAILED'; + readonly substep?: NativeDirectorySmokeSubstep; + readonly category?: NativeDirectorySmokeFailureCategory; +} + +/** + * Read the configured native stack root from the fixed private CLI config and + * run the same authority-checked, secret-free discovery used by `propr connect + * status`. Neither root is returned to the caller. + */ +export async function discoverConfiguredConnect({ + configRoot, + platform = process.platform, + readStatus, + statusDependencies, + reportSmokeDiagnostic, +}: FixedConnectDiscoveryOptions): Promise { + if (!DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(platform)) { + throw new Error('Connect discovery is unavailable on this host'); + } + reportSmokeDiagnostic?.({ phase: 'config-read', code: 'STARTED' }); + let root: string | undefined; + try { + const config = await createConfigManager(configRoot, { + readOnly: true, + warn: () => undefined, + }); + root = config.getStackRoot(); + reportSmokeDiagnostic?.({ phase: 'config-read', code: 'PASSED' }); + } catch (error) { + reportSmokeDiagnostic?.({ phase: 'config-read', code: 'FAILED' }); + throw error; + } + if (platform === 'linux' && root !== undefined) { + assertNativeDirectoryEntry(configRoot, 'config.json', 'file', (phase, code, failure) => { + reportSmokeDiagnostic?.({ phase, code, ...failure }); + }); + } + if (readStatus) { + reportSmokeDiagnostic?.({ phase: 'status-resolution', code: 'STARTED' }); + try { + const result = await readStatus(root); + reportSmokeDiagnostic?.({ phase: 'status-resolution', code: 'PASSED' }); + return result; + } catch (error) { + reportSmokeDiagnostic?.({ phase: 'status-resolution', code: 'FAILED' }); + throw error; + } + } + return getLocalConnectStatus(root, { + ...statusDependencies, + reportSmokeDiagnostic: (phase, code) => { + statusDependencies?.reportSmokeDiagnostic?.(phase, code); + reportSmokeDiagnostic?.({ phase, code }); + }, + }); +} + +export type { ConnectStatusDocument } from './commands/connectCommand.js'; diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index aa1ae5f11..cd449d234 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1,8 +1,49 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { + hasExactlyOneExplicitConnectStatusRoot, + isExplicitConnectStatusInvocation, +} from './index.js'; + +test('every Connect status argument shape is identified before dotenv or option validation', () => { + for (const args of [ + ['connect', 'status', '--json'], + ['connect', 'status', '--json', '--root'], + ['connect', 'status', '--json', '--root='], + ['connect', 'status', '--root', '/one', '--root', '/two', '--json'], + ['--project', 'owner/repo', 'connect', 'status', '--root=/one', '-j'], + ['connect', 'status', '--json', '--', '--root', '/ignored'], + ['connect', 'status', '--root=/one', '--', '--root=/ignored'], + ]) assert.equal(isExplicitConnectStatusInvocation(['node', 'propr', ...args]), true, args.join(' ')); + + for (const args of [ + ['connect', '--', 'status', '--json', '--root=/ignored'], + ['--', 'connect', 'status', '--json', '--root=/ignored'], + ]) assert.equal(isExplicitConnectStatusInvocation(['node', 'propr', ...args]), false, args.join(' ')); + + for (const args of [ + ['connect', 'status', '--json'], + ['connect', 'status', '--json', '--root'], + ['connect', 'status', '--json', '--root='], + ['connect', 'status', '--json', '--root', ''], + ['connect', 'status', '--root', '/one', '--root', '/two', '--json'], + ['connect', 'status', '--root=/one', '--root=/two', '--json'], + ['connect', 'status', '--json', '--', '--root', '/ignored'], + ['connect', 'status', '--json', '--', '--root=/ignored'], + ]) assert.equal(hasExactlyOneExplicitConnectStatusRoot(['node', 'propr', ...args]), false, args.join(' ')); + + for (const args of [ + ['connect', 'status', '--json', '--root', '/one'], + ['--project', 'owner/repo', 'connect', 'status', '--root=/one', '-j'], + ['connect', 'status', '--json', '--root', '/one', '--', '--root', '/ignored'], + ['connect', 'status', '--root=/one', '--', '--root=/ignored', '--help'], + ]) assert.equal(hasExactlyOneExplicitConnectStatusRoot(['node', 'propr', ...args]), true, args.join(' ')); +}); test('direct CLI execution is not disabled by test environment variables', () => { const entryPoint = fileURLToPath(new URL('./index.ts', import.meta.url)); @@ -18,4 +59,38 @@ test('direct CLI execution is not disabled by test environment variables', () => assert.equal(result.status, 0, result.stderr); const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; assert.equal(result.stdout.trim(), packageVersion); + + const builtEntryPoint = fileURLToPath(new URL('../dist/index.js', import.meta.url)); + const hostileCwd = mkdtempSync(join(tmpdir(), 'propr-connect-help-')); + writeFileSync(join(hostileCwd, '.env'), [ + 'PROPR_STACK=help-cwd-stack-SENTINEL', + 'HOST_DATA_DIR=${HELP_CWD_SECRET_SENTINEL}', + ].join('\n')); + try { + for (const args of [ + ['connect', 'status', '--help'], + ['connect', 'status', '-h'], + ['connect', 'status', '--help', '--json', '--root'], + ['connect', 'status', '--json', '--root', '--help'], + ['connect', 'status', '--root=/one', '-h', '--root=/two', '--json'], + ['--project', 'owner/repo', 'connect', 'status', '--root=', '--json', '-h'], + ['connect', 'status', '--json', '--help', '--', '--root=/ignored'], + ]) { + const help = spawnSync(process.execPath, [builtEntryPoint, ...args], { + cwd: hostileCwd, + encoding: 'utf8', + env: { ...process.env, HELP_CWD_SECRET_SENTINEL: 'never-print-this-SENTINEL' }, + }); + assert.equal(help.status, 0, `${args.join(' ')}\n${help.stderr}`); + assert.equal(help.stderr, '', args.join(' ')); + assert.match(help.stdout, /^Usage: propr connect status \[options\]$/m, args.join(' ')); + assert.match(help.stdout, /Print the versioned secret-free desktop discovery contract/, args.join(' ')); + assert.match(help.stdout, /-h, --help\s+display help for command/, args.join(' ')); + assert.equal(help.stdout.includes('"schemaVersion"'), false, args.join(' ')); + assert.equal(help.stdout.includes('INVALID_ROOT'), false, args.join(' ')); + assert.equal(help.stdout.includes('SENTINEL'), false, args.join(' ')); + } + } finally { + rmSync(hostileCwd, { recursive: true, force: true }); + } }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e88da6066..d80c4a96d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -37,6 +37,7 @@ import { createUiCommand, createDocsCommand, createTunnelCommand, + createConnectCommand, createTankCommand, createRelayCommand, createRuntimeCommand, @@ -44,6 +45,10 @@ import { printChecks, STACK_CONFIG_CHECK_NAME, } from "./commands/index.js"; +import { + CONNECT_STATUS_EXIT, + invalidConnectRootStatus, +} from "./commands/connectCommand.js"; // Re-export completion generation for programmatic use export { completionScript, buildCompletionMetadata } from "./completion.js"; @@ -111,8 +116,72 @@ export type { FormatOutputOptions, } from "./utils/index.js"; -// Load environment variables -config(); +/** Return only raw CLI arguments which precede the POSIX end-of-options marker. */ +function argsBeforeEndOfOptions(argv: readonly string[]): readonly string[] { + const args = argv.slice(2); + const delimiterIndex = args.indexOf("--"); + return delimiterIndex === -1 ? args : args.slice(0, delimiterIndex); +} + +/** Parse the discovery shape without depending on option order or spelling. */ +export function isExplicitConnectStatusInvocation(argv: readonly string[]): boolean { + const args = argsBeforeEndOfOptions(argv); + const positionals: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--root") { + const value = args[index + 1]; + if (value !== undefined && value !== "" && !value.startsWith("-")) { + index += 1; + } + continue; + } + if (arg.startsWith("--root=")) { + continue; + } + if (arg === "--project" || arg === "-p") { + index += 1; + continue; + } + if (arg.startsWith("--project=") || arg === "--json" || arg === "-j") continue; + if (!arg.startsWith("-")) positionals.push(arg); + } + return positionals[0] === "connect" && positionals[1] === "status"; +} + +/** Require one non-empty raw root option before Commander can reject or overwrite it. */ +export function hasExactlyOneExplicitConnectStatusRoot(argv: readonly string[]): boolean { + if (!isExplicitConnectStatusInvocation(argv)) return false; + const args = argsBeforeEndOfOptions(argv); + let rootCount = 0; + let rootIsValid = true; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--root") { + rootCount += 1; + const value = args[index + 1]; + if (value === undefined || value === "" || value.startsWith("-")) { + rootIsValid = false; + } else { + index += 1; + } + } else if (arg.startsWith("--root=")) { + rootCount += 1; + if (arg.slice("--root=".length).length === 0) rootIsValid = false; + } + } + return rootCount === 1 && rootIsValid; +} + +// Identify the command shape before Commander validates required, malformed, or +// duplicate root options. Every Connect status invocation (and therefore every +// --json failure shape) must avoid pre-reading a replaceable cwd/.env. +const connectStatusInvocation = isExplicitConnectStatusInvocation(process.argv); +const connectStatusHelpRequested = connectStatusInvocation + && argsBeforeEndOfOptions(process.argv).some((arg) => arg === "--help" || arg === "-h"); +const malformedConnectStatusRoot = connectStatusInvocation + && !hasExactlyOneExplicitConnectStatusRoot(process.argv); +if (!connectStatusInvocation) config(); const packageJson = JSON.parse( readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8") @@ -342,6 +411,7 @@ program.addCommand(createStopCommand()); program.addCommand(createUiCommand()); program.addCommand(createDocsCommand()); program.addCommand(createTunnelCommand()); +program.addCommand(createConnectCommand()); program.addCommand(createTankCommand()); program.addCommand(createRelayCommand()); program.addCommand(createRuntimeCommand()); @@ -394,6 +464,15 @@ if (isCliEntryPoint() && !process.argv.slice(2).length) { process.exit(1); } })(); +} else if (isCliEntryPoint() && connectStatusHelpRequested) { + // Parse a canonical help shape so a malformed `--root` cannot consume the + // help flag as its required value. Commander remains the help authority. + program.parse([...process.argv.slice(0, 2), "connect", "status", "--help"]); +} else if (isCliEntryPoint() && malformedConnectStatusRoot) { + const document = invalidConnectRootStatus(); + process.stdout.write(`${JSON.stringify(document)}\n`); + process.stderr.write(`ProPR Connect discovery: ${document.status}.\n`); + process.exitCode = CONNECT_STATUS_EXIT[document.status]; } else if (isCliEntryPoint()) { program.parse(); } diff --git a/packages/cli/src/orchestrator/index.test.ts b/packages/cli/src/orchestrator/index.test.ts index a079ec281..129adb49b 100644 --- a/packages/cli/src/orchestrator/index.test.ts +++ b/packages/cli/src/orchestrator/index.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { ConfigManager } from "../config/ConfigManager.js"; -import { getHostConfig } from "./index.js"; +import { connectExecutionEnvironment, getHostConfig } from "./index.js"; function createStackRoot(parent: string, name: string): string { const root = join(parent, name); @@ -47,3 +47,68 @@ test("explicit new root does not inherit legacy tunnel intent during start prefl rmSync(tempDir, { recursive: true, force: true }); } }); + +test("Connect forwards only validated Docker transport and process bootstrap variables", () => { + const windows = process.platform === "win32"; + const platform = windows ? "win32" : process.platform; + const path = windows ? "C:\\trusted\\bin" : "/trusted/bin"; + const certPath = windows ? "C:\\private\\certs" : "/private/certs"; + const configPath = windows ? "C:\\private\\docker-config" : "/private/docker-config"; + const sshSocket = windows ? "\\\\.\\pipe\\trusted-ssh-agent" : "/trusted/ssh-agent"; + const platformHome = windows ? { USERPROFILE: "C:\\Users\\trusted" } : { HOME: "/trusted/home" }; + const environment = connectExecutionEnvironment({ + PATH: path, + DOCKER_HOST: "ssh://docker.example.test", + DOCKER_CONTEXT: "remote-context", + DOCKER_TLS: "1", + DOCKER_TLS_VERIFY: "1", + DOCKER_CERT_PATH: certPath, + DOCKER_CONFIG: configPath, + PROPR_UI_TUNNEL_TOKEN: "must-not-cross", + ...platformHome, + HOME: windows ? "/must/not/cross" : platformHome.HOME, + SSH_AUTH_SOCK: sshSocket, + DOCKER_AUTH_CONFIG: "must-not-cross", + NODE_OPTIONS: "must-not-cross", + HTTPS_PROXY: "must-not-cross", + }, platform); + assert.deepEqual(environment, { + PATH: path, + DOCKER_HOST: "ssh://docker.example.test", + DOCKER_CONTEXT: "remote-context", + DOCKER_TLS: "1", + DOCKER_TLS_VERIFY: "1", + DOCKER_CERT_PATH: certPath, + DOCKER_CONFIG: configPath, + ...platformHome, + SSH_AUTH_SOCK: sshSocket, + }); + for (const invalid of [ + { DOCKER_HOST: "x".repeat(4097) }, + { DOCKER_CONTEXT: "x".repeat(256) }, + { DOCKER_CONTEXT: "é".repeat(128) }, + { DOCKER_TLS: "" }, + { DOCKER_TLS: "x".repeat(17) }, + { DOCKER_CERT_PATH: "private\0path" }, + { DOCKER_CONFIG: 42 }, + { DOCKER_TLS_VERIFY: "" }, + ]) assert.throws(() => connectExecutionEnvironment(invalid, platform), /environment/); + + assert.deepEqual(connectExecutionEnvironment({ + PATH: "C:\\trusted\\bin", + HOMEDRIVE: "C:", + HOMEPATH: "\\Users\\trusted", + HOME: "/must/not/cross", + }, "win32"), { + PATH: "C:\\trusted\\bin", + HOMEDRIVE: "C:", + HOMEPATH: "\\Users\\trusted", + }); + for (const invalidHome of [ + { USERPROFILE: "relative" }, + { HOMEDRIVE: "C:" }, + { HOMEPATH: "\\Users\\trusted" }, + { HOMEDRIVE: "relative", HOMEPATH: "\\Users\\trusted" }, + { HOMEDRIVE: "C:", HOMEPATH: "relative" }, + ]) assert.throws(() => connectExecutionEnvironment(invalidHome, "win32"), /platform environment/); +}); diff --git a/packages/cli/src/orchestrator/index.ts b/packages/cli/src/orchestrator/index.ts index 5d1a9b86a..0cdaffea7 100644 --- a/packages/cli/src/orchestrator/index.ts +++ b/packages/cli/src/orchestrator/index.ts @@ -9,7 +9,7 @@ import { existsSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, join, resolve } from "node:path"; +import { delimiter, dirname, join, posix, resolve, win32 } from "node:path"; import type { OrchestratorConfig, OrchestratorModule } from "./types.js"; import type { ConfigManager } from "../config/index.js"; @@ -130,3 +130,191 @@ export async function getHostConfig(opts: { const cfg = orch.resolveHostConfig({ rootDir, env: process.env, manifestPath, cliOverrides }); return { orch, cfg, rootDir }; } + +export interface ConnectHostConfigSnapshotInput { + requestedRoot: string; + envFileValues: Readonly>; +} + +/** + * Load all code/manifest state before Connect acquires root authority. The + * returned resolver is synchronous so authorized root bytes never cross an + * await boundary. + */ +const CONNECT_DOCKER_ENV_LIMITS = { + DOCKER_HOST: 4096, + DOCKER_CONTEXT: 255, + // Docker treats any non-empty value as enabling TLS. Keep the value bounded + // while preserving that documented transport-selection behavior. + DOCKER_TLS: 16, + DOCKER_TLS_VERIFY: 16, + DOCKER_CERT_PATH: 4096, + DOCKER_CONFIG: 4096, +} as const; + +function environmentString(source: Readonly>, name: string, maximum = 4096): string | undefined { + const value = source[name]; + if (value === undefined) return undefined; + if ( + typeof value !== "string" + || value.length === 0 + || value.includes("\0") + || /[\r\n]/.test(value) + || Buffer.byteLength(value, "utf8") > maximum + ) throw new Error("Connect process environment is invalid"); + return value; +} + +function platformPath(value: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); +} + +function validateSearchPath(value: string, platform: NodeJS.Platform): void { + const separator = platform === "win32" ? ";" : delimiter; + const entries = value.split(separator); + if (entries.length === 0 || entries.some((entry) => !entry || !platformPath(entry, platform))) { + throw new Error("Connect executable search environment is invalid"); + } +} + +function validateDockerHost(value: string, platform: NodeJS.Platform): void { + try { + const parsed = new URL(value); + if (parsed.password || parsed.search || parsed.hash) throw new Error(); + if (parsed.protocol === "unix:") { + if (platform === "win32" || parsed.hostname || !posix.isAbsolute(parsed.pathname)) throw new Error(); + return; + } + if (parsed.protocol === "npipe:") { + if (platform !== "win32" || !/^\/\/\.\/pipe\/[A-Za-z0-9_.-]+$/.test(parsed.pathname)) throw new Error(); + return; + } + if (parsed.protocol === "tcp:" || parsed.protocol === "http:" || parsed.protocol === "https:") { + if (parsed.username || !parsed.hostname || (parsed.pathname !== "" && parsed.pathname !== "/")) throw new Error(); + return; + } + if (parsed.protocol === "ssh:") { + if (!parsed.hostname || parsed.pathname !== "" && parsed.pathname !== "/") throw new Error(); + return; + } + } catch { + // Fall through to the single fixed redacted validation error below. + } + throw new Error("Connect Docker transport environment is invalid"); +} + +/** + * Discovery needs executable lookup, OS bootstrap variables, and the trusted + * parent process's documented Docker transport selection. ProPR/configuration + * variables remain absent: the explicit root snapshot is their sole authority. + */ +export function connectExecutionEnvironment( + source: Readonly>, + platform: NodeJS.Platform = process.platform, +): NodeJS.ProcessEnv { + if (platform !== "linux" && platform !== "darwin" && platform !== "win32") { + throw new Error("Connect process environment is invalid"); + } + const allowed: NodeJS.ProcessEnv = {}; + const bootstrap = platform === "win32" + ? ["PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TMP", "TEMP"] as const + : ["PATH", "TMPDIR", "TMP", "TEMP", "HOME"] as const; + for (const name of bootstrap) { + const value = environmentString(source, name); + if (value === undefined) continue; + if (name === "PATH") validateSearchPath(value, platform); + else if (name === "PATHEXT") { + if (!value.split(";").every((entry) => /^\.[A-Za-z0-9]{1,16}$/.test(entry))) { + throw new Error("Connect executable search environment is invalid"); + } + } else if (!platformPath(value, platform)) { + throw new Error("Connect platform environment is invalid"); + } + allowed[name] = value; + } + if (platform === "win32") { + const userProfile = environmentString(source, "USERPROFILE"); + const homeDrive = environmentString(source, "HOMEDRIVE"); + const homePath = environmentString(source, "HOMEPATH"); + if (userProfile !== undefined) { + if (!win32.isAbsolute(userProfile)) throw new Error("Connect platform environment is invalid"); + allowed.USERPROFILE = userProfile; + } else if (homeDrive !== undefined || homePath !== undefined) { + if (!homeDrive || !/^[A-Za-z]:$/.test(homeDrive) || !homePath || !win32.isAbsolute(homePath)) { + throw new Error("Connect platform environment is invalid"); + } + allowed.HOMEDRIVE = homeDrive; + allowed.HOMEPATH = homePath; + } + } + for (const [name, maximum] of Object.entries(CONNECT_DOCKER_ENV_LIMITS)) { + const value = environmentString(source, name, maximum); + if (value === undefined) continue; + if (name === "DOCKER_HOST") validateDockerHost(value, platform); + else if (name === "DOCKER_CONTEXT" && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/.test(value)) { + throw new Error("Connect Docker transport environment is invalid"); + } else if (name === "DOCKER_TLS_VERIFY" && value !== "0" && value !== "1") { + throw new Error("Connect Docker transport environment is invalid"); + } else if ((name === "DOCKER_CERT_PATH" || name === "DOCKER_CONFIG") && !platformPath(value, platform)) { + throw new Error("Connect Docker transport environment is invalid"); + } + allowed[name] = value; + } + // A named context may itself select an ssh endpoint; without opening Docker's + // config here, preserving the socket when a context is explicit is the + // narrowest way to keep those documented contexts functional. + if (allowed.DOCKER_HOST?.startsWith("ssh://") || allowed.DOCKER_CONTEXT !== undefined) { + const socket = environmentString(source, "SSH_AUTH_SOCK"); + if (socket !== undefined) { + const valid = platform === "win32" + ? win32.isAbsolute(socket) || /^\\\\\.\\pipe\\[A-Za-z0-9_.-]+$/.test(socket) + : posix.isAbsolute(socket); + if (!valid) throw new Error("Connect SSH transport environment is invalid"); + allowed.SSH_AUTH_SOCK = socket; + } + } + return allowed; +} + +export async function prepareConnectHostConfig(): Promise<{ + orch: OrchestratorModule; + parseEnvFile(contents: string): Record; + resolveSnapshot(input: ConnectHostConfigSnapshotInput): OrchestratorConfig; + inspectTunnel(cfg: OrchestratorConfig): { kind: "ok"; running: boolean } | { kind: "internalFailure" }; +}> { + const orch = await loadOrchestrator(); + const orchPath = cachedPath ?? resolveOrchestratorPath(); + const manifestPath = resolveManifestPath(orchPath); + if (!manifestPath) { + throw new Error("Connect host configuration manifest is unavailable"); + } + const executionEnv = connectExecutionEnvironment(process.env); + return { + orch, + parseEnvFile: (contents) => orch.parseEnvFileContents(contents), + resolveSnapshot: ({ requestedRoot, envFileValues }) => { + return orch.resolveConfig(executionEnv, { + envFileValues, + stack: envFileValues.PROPR_STACK || "propr", + network: envFileValues.PROPR_NETWORK + || `${envFileValues.PROPR_STACK || "propr"}-net`, + envFileLocal: join(requestedRoot, ".env"), + envFileHost: join(requestedRoot, ".env"), + hostData: join(requestedRoot, "data"), + hostLogs: join(requestedRoot, "logs"), + hostRepos: join(requestedRoot, "repos"), + managedCredentialsDir: join(requestedRoot, "data", "agent-credentials"), + validateHostPaths: true, + manifestPath, + }); + }, + inspectTunnel: (cfg) => { + const inspection = orch.inspectStackStatus(cfg, { timeout: 3000, env: executionEnv }); + if (!inspection.status) return { kind: "internalFailure" }; + return { + kind: "ok", + running: Boolean(inspection.status.services.find((service) => service.service === "tunnel")?.running), + }; + }, + }; +} diff --git a/packages/cli/src/orchestrator/types.ts b/packages/cli/src/orchestrator/types.ts index 2a1160d7c..b4e660fe6 100644 --- a/packages/cli/src/orchestrator/types.ts +++ b/packages/cli/src/orchestrator/types.ts @@ -54,12 +54,15 @@ export interface OrchestratorConfig { readonly cloudflaredImage: string; /** Immediate socket peers whose forwarded client/protocol headers the API trusts. */ readonly trustedProxyPeers?: string; + /** Public origin injected into the running API container. */ + readonly apiPublicUrl: string; /** * Hosted UI origin allowed by CORS/redirects. Always resolves to a value: * an explicit FRONTEND_URL, the hosted origin in tunnel mode, or the * localhost UI default for local development. */ readonly frontendUrl: string; + readonly ghOauthCallbackUrl: string; readonly mistralApiKey?: string; readonly vibeConfigPath?: string; readonly manifest: { version: string; images: Record } & Record; @@ -116,6 +119,8 @@ export type ImageFreshnessResult = export interface DockerCommandOptions { capture?: boolean; timeout?: number; + env?: NodeJS.ProcessEnv; + maxBuffer?: number; } export interface DockerCommandResult { @@ -126,6 +131,11 @@ export interface DockerCommandResult { signal?: NodeJS.Signals | null; } +export interface StackStatusInspection { + result: DockerCommandResult; + status?: StackStatus; +} + export interface ResolveHostConfigOptions { rootDir?: string; env?: NodeJS.ProcessEnv; @@ -133,6 +143,10 @@ export interface ResolveHostConfigOptions { cliOverrides?: Record; } +export interface ResolveConfigOverrides extends Partial { + envFileValues?: Readonly>; +} + export interface OnLogOption { onLog?: (line: string) => void; pull?: boolean; @@ -141,9 +155,10 @@ export interface OnLogOption { /** Public surface of orchestrator.mjs consumed by the CLI. */ export interface OrchestratorModule { - resolveConfig(env?: NodeJS.ProcessEnv, overrides?: Partial): OrchestratorConfig; + resolveConfig(env?: NodeJS.ProcessEnv, overrides?: ResolveConfigOverrides): OrchestratorConfig; resolveHostConfig(opts?: ResolveHostConfigOptions): OrchestratorConfig; readEnvFile(envFilePath: string): Record; + parseEnvFileContents(contents: string): Record; validateEnv(cfg: OrchestratorConfig): ValidationResult; validateDockerBindPath(name: string, value?: string, opts?: { containerPath?: boolean }): string | null; @@ -195,12 +210,16 @@ export interface OrchestratorModule { opts?: { remove?: boolean; removeNetwork?: boolean; onLog?: (line: string) => void } ): { failed: string[] }; - getStackStatus(cfg: OrchestratorConfig): StackStatus; + getStackStatus(cfg: OrchestratorConfig, opts?: { timeout?: number }): StackStatus; + inspectStackStatus( + cfg: OrchestratorConfig, + opts?: { timeout?: number; env?: NodeJS.ProcessEnv } + ): StackStatusInspection; getStackStatusAsync(cfg: OrchestratorConfig): Promise; /** Pure parse of `docker ps` tab-separated output into per-service state. */ parseStackStatus(cfg: OrchestratorConfig, stdout: string): StackStatus; getTunnelStatus(cfg: OrchestratorConfig, stackStatus?: StackStatus): Promise; - getServiceState(cfg: OrchestratorConfig, service: string): ServiceState | undefined; + getServiceState(cfg: OrchestratorConfig, service: string, opts?: { timeout?: number }): ServiceState | undefined; getServiceLogs( cfg: OrchestratorConfig, service: string, diff --git a/packages/cli/src/utils/directoryDescriptor.ts b/packages/cli/src/utils/directoryDescriptor.ts index aa5a78515..3b564fa7c 100644 --- a/packages/cli/src/utils/directoryDescriptor.ts +++ b/packages/cli/src/utils/directoryDescriptor.ts @@ -1,8 +1,13 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + assertCanonicalNativeArtifactParents, + isPackagedNativeArtifactResolution, + physicalNativeArtifactCandidate, +} from "./nativeArtifact.js"; export type DirectoryDescriptorAccess = "child-paths" | "native-at"; @@ -32,26 +37,82 @@ export interface NativeDirectoryOperationTestEvent { result?: number; } +export type NativeDirectorySmokePhase = "addon-integrity-type" | "addon-load" | "descriptor-operation"; +export type NativeDirectorySmokeCode = "STARTED" | "PASSED" | "FAILED"; +export type NativeDirectorySmokeSubstep = "directory-open" | "addon-open" | "fstat-type"; +export type NativeDirectorySmokeFailureCategory = + | "access-denied" + | "invalid-argument" + | "io-failure" + | "missing-entry" + | "not-directory" + | "symlink-refused" + | "type-mismatch" + | "unexpected"; +export type NativeDirectorySmokeDiagnostic = ( + phase: NativeDirectorySmokePhase, + code: NativeDirectorySmokeCode, + failure?: Readonly<{ + substep: NativeDirectorySmokeSubstep; + category: NativeDirectorySmokeFailureCategory; + }>, +) => void; + type NativeDirectoryOperationTestHook = (event: NativeDirectoryOperationTestEvent) => void; +export type NativeDirectoryOpenTestPhase = + | "before-primary-open" + | "after-fallback-before-lstat" + | "before-directory-fallback-open" + | "before-readonly-fallback-open" + | "after-fallback-open" + | "after-fallback-fstat" + | "after-fallback-after-lstat"; + +type NativeDirectoryOpenTestHook = (phase: NativeDirectoryOpenTestPhase, directory: string) => void; + export const DARWIN_DIRECTORY_OPERATION_SHA256: Readonly> = { arm64: "88f07c0c7a4371f4fb227a4691009d09517de582ba49297d28d03ac94e586615", x64: "62183c0f4083cb8c98e09e2d2c688f8f81703e12b0f22320c335b51e927eaf53", }; export const LINUX_DIRECTORY_OPERATION_SHA256: Readonly> = { - arm64: "29b28b76ed8781f2567897ad9ba576798bbb669937048218e0416601788e0f1c", + arm64: "916679f413251c4b23c51167987a874bbbdd9d96991882bfac9093e0ea5fa051", x64: "7199378f1c7b443a05c596eae7c66f9a77cc01b4a493c07748df0df1083950f6", }; let nativeOperations: NativeDirectoryOperations | undefined; let nativeOperationTestHook: NativeDirectoryOperationTestHook | undefined; +let nativeDirectoryOpenTestHook: NativeDirectoryOpenTestHook | undefined; +let nativeDirectoryOpenFallbackTestEnabled = false; + +function smokeFailureCategory(error: unknown): NativeDirectorySmokeFailureCategory { + const code = error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (code === "EACCES" || code === "EPERM") return "access-denied"; + if (code === "EINVAL") return "invalid-argument"; + if (code === "EIO") return "io-failure"; + if (code === "ENOENT") return "missing-entry"; + if (code === "ENOTDIR") return "not-directory"; + if (code === "ELOOP") return "symlink-refused"; + return "unexpected"; +} /** Install a deterministic race injector around a native descriptor-operation boundary. */ export function setNativeDirectoryOperationTestHook(hook?: NativeDirectoryOperationTestHook): void { nativeOperationTestHook = hook; } +/** Install a deterministic test-only injector around the native authority directory open. */ +export function setNativeDirectoryOpenTestHook( + hook?: NativeDirectoryOpenTestHook, + enableLinuxArm64Fallback = false, +): void { + nativeDirectoryOpenTestHook = hook; + nativeDirectoryOpenFallbackTestEnabled = hook !== undefined && enableLinuxArm64Fallback; +} + /** Linux has traversable procfs dirfds; Darwin uses the packaged *at addon. */ export function directoryDescriptorAccess(platform: NodeJS.Platform = process.platform): DirectoryDescriptorAccess { if (platform === "linux") return "child-paths"; @@ -72,11 +133,19 @@ function nativeArtifactPath(platform: NodeJS.Platform, arch: string): string { const candidates = [ join(moduleDirectory, "..", "native", relativeArtifact), join(moduleDirectory, "..", "..", "native", relativeArtifact), - ]; - const artifact = candidates.find((candidate) => existsSync(candidate)); + ].map((logicalPath) => { + const path = physicalNativeArtifactCandidate(logicalPath); + return { path, packaged: isPackagedNativeArtifactResolution(logicalPath, path) }; + }); + const artifact = candidates.find((candidate) => existsSync(candidate.path)); if (!artifact) throw new Error(`packaged ${platform} directory-operations artifact is missing for ${arch}`); - verifyDirectoryOperationArtifact(artifact, expected, `${platform}-${arch}`); - return artifact; + if (artifact.packaged) assertCanonicalNativeArtifactParents(artifact.path); + const named = lstatSync(artifact.path); + if (!named.isFile() || named.isSymbolicLink() || (artifact.packaged && (named.mode & 0o022) !== 0)) { + throw new Error(`packaged directory-operations artifact failed type verification for ${platform}-${arch}`); + } + verifyDirectoryOperationArtifact(artifact.path, expected, `${platform}-${arch}`); + return artifact.path; } export function verifyDirectoryOperationArtifact(artifact: string, expected: string, arch: string): void { @@ -86,14 +155,160 @@ export function verifyDirectoryOperationArtifact(artifact: string, expected: str } } -function hostOperations(): NativeDirectoryOperations { +function hostOperations(reportSmokeDiagnostic?: NativeDirectorySmokeDiagnostic): NativeDirectoryOperations { if (process.platform !== "darwin" && process.platform !== "linux") { throw new Error(`native directory operations were requested on unsupported platform ${process.platform}`); } - nativeOperations ??= createRequire(import.meta.url)(nativeArtifactPath(process.platform, process.arch)) as NativeDirectoryOperations; + reportSmokeDiagnostic?.("addon-integrity-type", "STARTED"); + let artifact: string; + try { + artifact = nativeArtifactPath(process.platform, process.arch); + reportSmokeDiagnostic?.("addon-integrity-type", "PASSED"); + } catch (error) { + reportSmokeDiagnostic?.("addon-integrity-type", "FAILED"); + throw error; + } + reportSmokeDiagnostic?.("addon-load", "STARTED"); + try { + nativeOperations ??= createRequire(import.meta.url)(artifact) as NativeDirectoryOperations; + reportSmokeDiagnostic?.("addon-load", "PASSED"); + } catch (error) { + reportSmokeDiagnostic?.("addon-load", "FAILED"); + throw error; + } return nativeOperations; } +function errorCode(error: unknown): unknown { + return error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; +} + +function sameDirectoryIdentity( + left: Readonly<{ dev: number | bigint; ino: number | bigint }>, + right: Readonly<{ dev: number | bigint; ino: number | bigint }>, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +/** + * Open and pin the authority directory. Some Linux ARM64 hosts reject the + * strict directory/no-follow flag combination with EINVAL, and some also + * reject O_DIRECTORY before inspecting the authority. Only those consecutive + * EINVAL failures on Linux ARM64 may progressively drop O_NOFOLLOW and then + * O_DIRECTORY. Every compatibility descriptor must identify the exact same + * non-link directory before and after it is opened. + */ +export function openAuthorityDirectoryNoFollow( + directory: string, + openDirectory: (flags: number) => number = flags => openSync(directory, flags), +): number { + try { + nativeDirectoryOpenTestHook?.("before-primary-open", directory); + return openDirectory(constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch (error) { + const isLinuxArm64 = process.platform === "linux" && process.arch === "arm64"; + if ((!isLinuxArm64 && !nativeDirectoryOpenFallbackTestEnabled) || errorCode(error) !== "EINVAL") throw error; + } + + nativeDirectoryOpenTestHook?.("after-fallback-before-lstat", directory); + const before = lstatSync(directory, { bigint: true }); + if (!before.isDirectory() || before.isSymbolicLink()) { + throw new Error("directory authority entry was not a non-link directory before open"); + } + + let directoryFd: number | undefined; + try { + try { + nativeDirectoryOpenTestHook?.("before-directory-fallback-open", directory); + directoryFd = openDirectory(constants.O_RDONLY | constants.O_DIRECTORY); + } catch (error) { + if (errorCode(error) !== "EINVAL") throw error; + nativeDirectoryOpenTestHook?.("before-readonly-fallback-open", directory); + directoryFd = openDirectory(constants.O_RDONLY); + } + nativeDirectoryOpenTestHook?.("after-fallback-open", directory); + const opened = fstatSync(directoryFd, { bigint: true }); + nativeDirectoryOpenTestHook?.("after-fallback-fstat", directory); + const after = lstatSync(directory, { bigint: true }); + nativeDirectoryOpenTestHook?.("after-fallback-after-lstat", directory); + if (!opened.isDirectory() + || !after.isDirectory() + || after.isSymbolicLink() + || !sameDirectoryIdentity(before, opened) + || !sameDirectoryIdentity(before, after) + || !sameDirectoryIdentity(opened, after)) { + throw new Error("directory authority entry changed during descriptor fallback"); + } + const result = directoryFd; + directoryFd = undefined; + return result; + } finally { + if (directoryFd !== undefined) closeSync(directoryFd); + } +} + +/** + * Load the integrity-pinned host addon and perform one descriptor-relative + * operation. Packaged desktop discovery uses this on Linux so acceptance binds + * the selected native artifact to the running main process, rather than merely + * inspecting a file copied into the package. + */ +export function assertNativeDirectoryEntry( + directory: string, + name: string, + expectedKind: DirectoryEntryIdentity['kind'], + reportSmokeDiagnostic?: NativeDirectorySmokeDiagnostic, +): void { + if (!/^[A-Za-z0-9._-]{1,128}$/.test(name) || name === '.' || name === '..') { + throw new Error('native directory authority entry name is invalid'); + } + const operations = hostOperations(reportSmokeDiagnostic); + reportSmokeDiagnostic?.("descriptor-operation", "STARTED"); + let directoryFd: number | undefined; + let entryFd: number | undefined; + let substep: NativeDirectorySmokeSubstep = "directory-open"; + let failureReported = false; + try { + directoryFd = openAuthorityDirectoryNoFollow(directory); + // Pin through the addon's descriptor-relative open, then let the host + // runtime inspect that descriptor. This avoids architecture-specific C + // stat ABI wrappers while retaining no-follow and exact-type authority. + substep = "addon-open"; + entryFd = operations.openAt(directoryFd, name, constants.O_RDONLY | constants.O_NOFOLLOW, 0); + substep = "fstat-type"; + const entry = fstatSync(entryFd); + const kind = entry.isFile() + ? "file" + : entry.isDirectory() + ? "directory" + : entry.isSymbolicLink() + ? "symbolic-link" + : "other"; + if (kind !== expectedKind) { + reportSmokeDiagnostic?.("descriptor-operation", "FAILED", { + substep, + category: "type-mismatch", + }); + failureReported = true; + throw new Error('native directory authority entry type did not match'); + } + reportSmokeDiagnostic?.("descriptor-operation", "PASSED"); + } catch (error) { + if (!failureReported) { + reportSmokeDiagnostic?.("descriptor-operation", "FAILED", { + substep, + category: smokeFailureCategory(error), + }); + } + throw error; + } finally { + if (entryFd !== undefined) closeSync(entryFd); + if (directoryFd !== undefined) closeSync(directoryFd); + } +} + export function openAt(dirfd: number, name: string, flags: number, mode = 0): number { const operations = hostOperations(); nativeOperationTestHook?.({ operation: "openAt", phase: "before", dirfd, name, flags, mode }); diff --git a/packages/cli/src/utils/nativeArtifact.test.ts b/packages/cli/src/utils/nativeArtifact.test.ts new file mode 100644 index 000000000..327559bfb --- /dev/null +++ b/packages/cli/src/utils/nativeArtifact.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + assertCanonicalNativeArtifactParents, + isPackagedNativeArtifactResolution, + physicalNativeArtifactCandidate, +} from './nativeArtifact.js'; + +test('packaged native artifact candidates resolve to the physical non-ASAR resource', () => { + const logical = join('/Applications/ProPR.app/Contents/Resources/app.asar', '.vite/native/broker'); + const physical = join('/Applications/ProPR.app/Contents/Resources/app.asar.unpacked', '.vite/native/broker'); + assert.equal( + physicalNativeArtifactCandidate(logical), + physical, + ); + assert.equal(isPackagedNativeArtifactResolution(logical, physical), true); + assert.equal(isPackagedNativeArtifactResolution(physical, physical), false); + assert.equal(isPackagedNativeArtifactResolution(join('/workspace', 'native', 'broker'), join('/workspace', 'native', 'broker')), false); +}); + +test('packaged native artifact candidates require canonical non-link parent ancestry', () => { + const fixture = mkdtempSync(join(realpathSync.native(tmpdir()), 'propr-native-artifact-')); + try { + const canonical = join(fixture, 'native', 'prebuilds', 'darwin-arm64'); + mkdirSync(canonical, { recursive: true }); + const artifact = join(canonical, 'broker'); + writeFileSync(artifact, 'fixture'); + assert.doesNotThrow(() => assertCanonicalNativeArtifactParents(artifact)); + + const linked = join(fixture, 'linked'); + symlinkSync(join(fixture, 'native'), linked, 'dir'); + assert.throws( + () => assertCanonicalNativeArtifactParents(join(linked, 'prebuilds', 'darwin-arm64', 'broker')), + /ancestry failed verification/, + ); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/utils/nativeArtifact.ts b/packages/cli/src/utils/nativeArtifact.ts new file mode 100644 index 000000000..04192d733 --- /dev/null +++ b/packages/cli/src/utils/nativeArtifact.ts @@ -0,0 +1,32 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import { dirname, resolve, sep } from 'node:path'; + +/** Resolve an ASAR-relative native path to the physical, executable unpacked resource. */ +export function physicalNativeArtifactCandidate(candidate: string): string { + const marker = `${sep}app.asar${sep}`; + const index = candidate.indexOf(marker); + if (index === -1) return candidate; + return `${candidate.slice(0, index)}${sep}app.asar.unpacked${sep}${candidate.slice(index + marker.length)}`; +} + +/** True only when an ASAR logical path was remapped to its physical unpacked resource. */ +export function isPackagedNativeArtifactResolution(logicalCandidate: string, physicalCandidate: string): boolean { + const marker = `${sep}app.asar${sep}`; + return logicalCandidate.includes(marker) + && physicalCandidate !== logicalCandidate + && physicalCandidate === physicalNativeArtifactCandidate(logicalCandidate); +} + +/** Require every existing parent of a packaged native candidate to be canonical and non-link. */ +export function assertCanonicalNativeArtifactParents(candidate: string): void { + let parent = dirname(resolve(candidate)); + while (true) { + const named = lstatSync(parent); + if (!named.isDirectory() || named.isSymbolicLink() || realpathSync.native(parent) !== parent) { + throw new Error('packaged native artifact ancestry failed verification'); + } + const next = dirname(parent); + if (next === parent) return; + parent = next; + } +} diff --git a/packages/cli/src/utils/privateFilesystem.ts b/packages/cli/src/utils/privateFilesystem.ts index e1dd10146..b63372721 100644 --- a/packages/cli/src/utils/privateFilesystem.ts +++ b/packages/cli/src/utils/privateFilesystem.ts @@ -33,7 +33,7 @@ function assertOwned(stat: Stats, targetPath: string): void { } } -export function secureExistingPrivateDirectory(directoryPath: string): boolean { +export async function secureExistingPrivateDirectory(directoryPath: string): Promise { const stat = lstatIfPresent(directoryPath); if (!stat) return false; if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link directory ${directoryPath}`); @@ -45,14 +45,33 @@ export function secureExistingPrivateDirectory(directoryPath: string): boolean { return true; } -export function ensurePrivateDirectory(directoryPath: string): void { +/** + * Validate an existing private directory without changing it. Read-only + * consumers use this so inspecting configuration cannot repair or otherwise + * mutate the authority boundary as a side effect. + */ +export function validateExistingPrivateDirectory(directoryPath: string): boolean { + const stat = lstatIfPresent(directoryPath); + if (!stat) return false; + if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link directory ${directoryPath}`); + if (!stat.isDirectory()) throw new Error(`Expected a directory at ${directoryPath}`); + assertOwned(stat, directoryPath); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_DIRECTORY_MODE) { + throw new Error(`Refusing to use non-private directory ${directoryPath}`); + } + return true; +} + +export async function ensurePrivateDirectory( + directoryPath: string, +): Promise { if (!lstatIfPresent(directoryPath)) { mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); } - secureExistingPrivateDirectory(directoryPath); + await secureExistingPrivateDirectory(directoryPath); } -export function secureExistingPrivateFile(filePath: string): boolean { +export async function secureExistingPrivateFile(filePath: string): Promise { const stat = lstatIfPresent(filePath); if (!stat) return false; if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link file ${filePath}`); @@ -64,17 +83,30 @@ export function secureExistingPrivateFile(filePath: string): boolean { return true; } +/** Validate an existing private file without chmod or any other mutation. */ +export function validateExistingPrivateFile(filePath: string): boolean { + const stat = lstatIfPresent(filePath); + if (!stat) return false; + if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link file ${filePath}`); + if (!stat.isFile()) throw new Error(`Expected a regular file at ${filePath}`); + assertOwned(stat, filePath); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_FILE_MODE) { + throw new Error(`Refusing to use non-private file ${filePath}`); + } + return true; +} + export interface PrivateFileWriteOptions { secureParent?: boolean; } -export function writePrivateFileAtomic( +export async function writePrivateFileAtomic( filePath: string, content: string | Buffer, options: PrivateFileWriteOptions = {}, -): void { - if (options.secureParent !== false) ensurePrivateDirectory(dirname(filePath)); - secureExistingPrivateFile(filePath); +): Promise { + if (options.secureParent !== false) await ensurePrivateDirectory(dirname(filePath)); + await secureExistingPrivateFile(filePath); const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; let descriptor: number | undefined; try { @@ -83,8 +115,8 @@ export function writePrivateFileAtomic( fsyncSync(descriptor); closeSync(descriptor); descriptor = undefined; + if (process.platform !== "win32") chmodSync(tempPath, PRIVATE_FILE_MODE); renameSync(tempPath, filePath); - if (process.platform !== "win32") chmodSync(filePath, PRIVATE_FILE_MODE); } finally { if (descriptor !== undefined) closeSync(descriptor); try { unlinkSync(tempPath); } catch { /* Best-effort cleanup after success or failure. */ } diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 000000000..d9d6f0fb1 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,34 @@ +{ + "name": "@propr/client", + "version": "0.8.15", + "description": "Shared REST and Socket.IO client for ProPR instances", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "build": "tsc", + "test": "tsx --test test/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@propr/shared": "^0.8.15", + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/client/src/baseUrl.ts b/packages/client/src/baseUrl.ts new file mode 100644 index 000000000..34628d058 --- /dev/null +++ b/packages/client/src/baseUrl.ts @@ -0,0 +1,105 @@ +import { + canonicalProprHttpUrlOrigin, + isProprConnectReservedHostAttempt, + isProprLoopbackHostname, + MAX_PROPR_API_BASE_URL_LENGTH, + parseProprConnectEndpoint, +} from '@propr/shared'; +import { ProprClientError } from './errors.js'; + +declare const normalizedApiBaseUrl: unique symbol; + +/** Empty means browser same-origin; non-empty values are normalized HTTP(S) origins. */ +export type ProprApiBaseUrl = string & { readonly [normalizedApiBaseUrl]: true }; + +export interface NormalizeApiBaseUrlOptions { + /** Permit plain HTTP for a non-loopback host. Disabled by default. */ + allowInsecureHttp?: boolean; +} + +export type ProprApiEndpointKind = 'same-origin' | 'loopback' | 'remote' | 'propr-connect'; + +export interface ProprApiEndpointClassification { + baseUrl: ProprApiBaseUrl; + kind: ProprApiEndpointKind; + /** Present only after exact ProPR Connect hostname verification. */ + connectInstanceId?: string; +} + +const configurationError = (message: string): never => { + throw new ProprClientError(message, { kind: 'configuration', code: 'INVALID_API_BASE_URL' }); +}; + +const invalidApiBaseUrl = (): never => + configurationError('The configured ProPR API URL is invalid.'); + +/** Validate and normalize a REST/Socket.IO endpoint without retaining credentials. */ +export const normalizeApiBaseUrl = ( + value?: string | null, + options: NormalizeApiBaseUrlOptions = {} +): ProprApiBaseUrl => { + if (typeof value === 'string' && value.length > MAX_PROPR_API_BASE_URL_LENGTH) { + return invalidApiBaseUrl(); + } + const candidate = value?.trim() ?? ''; + if (!candidate) return '' as ProprApiBaseUrl; + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return invalidApiBaseUrl(); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return invalidApiBaseUrl(); + } + if (parsed.username || parsed.password) { + return invalidApiBaseUrl(); + } + if (parsed.search || parsed.hash) { + return invalidApiBaseUrl(); + } + if (parsed.pathname.replace(/\//g, '') !== '') { + return invalidApiBaseUrl(); + } + if (isProprConnectReservedHostAttempt(value) && !parseProprConnectEndpoint(value)) { + return invalidApiBaseUrl(); + } + + const normalized = canonicalProprHttpUrlOrigin(candidate, { + allowInsecureHttp: options.allowInsecureHttp, + }); + if (!normalized) { + return invalidApiBaseUrl(); + } + return normalized as ProprApiBaseUrl; +}; + +/** Normalize an API origin and identify only the exact ProPR Connect shape. */ +export const classifyApiBaseUrl = ( + value?: string | null, + options: NormalizeApiBaseUrlOptions = {} +): ProprApiEndpointClassification => { + const baseUrl = normalizeApiBaseUrl(value, options); + if (!baseUrl) return { baseUrl, kind: 'same-origin' }; + + // Classify the original spelling, not the normalized origin. Otherwise an + // encoded or Unicode authority could acquire the trusted Connect label only + // after WHATWG URL canonicalization. + const connect = parseProprConnectEndpoint(value); + if (connect) { + return { baseUrl, kind: 'propr-connect', connectInstanceId: connect.instanceId }; + } + return { + baseUrl, + kind: isProprLoopbackHostname(new URL(baseUrl).hostname) ? 'loopback' : 'remote', + }; +}; + +export const apiUrl = (baseUrl: ProprApiBaseUrl, path: string): string => { + if (!path.startsWith('/') || path.startsWith('//')) { + return configurationError('ProPR API request paths must start with exactly one slash.'); + } + return baseUrl ? `${baseUrl}${path}` : path; +}; diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 000000000..2eb48f10d --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,667 @@ +import { + evaluateProprApiCompatibility, + parseProprDesktopDiscoveryJson, + PROPR_CONNECT_DISCOVERY_MAX_BYTES, + type ProprApiCompatibilityResult, + type ProprCompatibilityMetadata, +} from '@propr/shared'; +import { + apiUrl, + normalizeApiBaseUrl, + type NormalizeApiBaseUrlOptions, + type ProprApiBaseUrl, +} from './baseUrl.js'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClientError, +} from './errors.js'; +import { + buildSocketConnection, + connectProprSocket, + type ProprAuthentication, + type ProprSocketOptions, + type Socket, +} from './socket.js'; +import { + completeDesktopPairing, + parseDesktopDiscovery, + parseDesktopPairingStart, + parseDesktopPairingActivationReceipt, + type ProprDesktopDiscovery, + type ProprDesktopPairingComplete, + type ProprDesktopPairingActivationReceipt, + type ProprDesktopPairingOptions, + type ProprDesktopPairingStart, +} from './desktopPairing.js'; +import { + requestPairingProtocol, + type PairingProtocolRequestOptions, +} from './pairingProtocol.js'; + +export interface ProprClientOptions extends NormalizeApiBaseUrlOptions { + baseUrl?: string | null; + authentication?: ProprAuthentication; + defaultTimeoutMs?: number; + fetch?: typeof globalThis.fetch; + /** @internal Deterministic response-lifecycle proof; production uses fixed protocol defaults. */ + pairingProtocol?: PairingProtocolRequestOptions; +} + +export interface ProprFetchOptions { + /** Zero or omitted uses the client default; a zero client default disables timeouts. */ + timeoutMs?: number; +} + +export interface ProprRequestOptions extends ProprFetchOptions { + responseType?: 'json' | 'text' | 'response'; +} + +export interface ProprCompatibilityOptions { + path?: string; + timeoutMs?: number; +} + +const responseErrorBody = async (response: Response): Promise => { + const contentType = response.headers.get('content-type') ?? ''; + try { + return contentType.includes('json') ? await response.clone().json() : await response.clone().text(); + } catch { + return undefined; + } +}; + +const errorCode = (body: unknown): string | undefined => { + if (!body || typeof body !== 'object' || !('code' in body)) return undefined; + return typeof body.code === 'string' ? body.code : undefined; +}; + +const isCompatibilityMetadata = (value: unknown): value is Partial => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const metadata = value as Record; + return ['version', 'apiCompatibility', 'uiCompatibility'].every(key => + metadata[key] === undefined || metadata[key] === null || typeof metadata[key] === 'string' + ); +}; + +const isExactLegacyDiscoveryAuthenticationBody = (contents: string): boolean => { + let offset = 0; + const whitespace = (): void => { + while (offset < contents.length && /[\x20\t\r\n]/.test(contents[offset])) offset += 1; + }; + const stringToken = (): string | null => { + if (contents[offset] !== '"') return null; + const start = offset; + offset += 1; + while (offset < contents.length) { + const character = contents[offset++]; + if (character === '"') { + try { return JSON.parse(contents.slice(start, offset)) as string; } catch { return null; } + } + if (character === '\\') { + const escape = contents[offset++]; + if (escape === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(contents.slice(offset, offset + 4))) return null; + offset += 4; + } else if (!escape || !'"\\/bfnrt'.includes(escape)) return null; + } else if (character.charCodeAt(0) < 0x20) return null; + } + return null; + }; + + whitespace(); + if (contents[offset++] !== '{') return false; + whitespace(); + if (stringToken() !== 'error') return false; + whitespace(); + if (contents[offset++] !== ':') return false; + whitespace(); + if (stringToken() !== 'Unauthorized') return false; + whitespace(); + if (contents[offset++] !== '}') return false; + whitespace(); + return offset === contents.length; +}; + +const assertTimeout = (timeoutMs: number): void => { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new ProprClientError('Request timeouts must be finite, non-negative numbers.', { + kind: 'configuration', + }); + } +}; + +const createDesktopDiscoveryDeadline = (timeoutMs: number, callerSignal?: AbortSignal) => { + assertTimeout(timeoutMs); + const controller = new AbortController(); + let rejectDeadline!: (reason: unknown) => void; + let timedOut = false; + let deadlineSettled = false; + let deadlineReason: unknown; + const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); + // A caller may already be aborted before any operation is raced. + void deadline.catch(() => undefined); + const timeoutReason = new Error('desktop discovery timed out'); + const abortReason = new Error('desktop discovery was cancelled'); + const settleDeadline = (reason: unknown): boolean => { + if (deadlineSettled) return false; + deadlineSettled = true; + deadlineReason = reason; + rejectDeadline(reason); + return true; + }; + const timeout = setTimeout(() => { + if (!settleDeadline(timeoutReason)) return; + timedOut = true; + controller.abort(timeoutReason); + }, Math.max(1, timeoutMs)); + const onAbort = (): void => { + if (!settleDeadline(abortReason)) return; + controller.abort(callerSignal?.reason); + }; + if (callerSignal?.aborted) onAbort(); + else callerSignal?.addEventListener('abort', onAbort, { once: true }); + return { + signal: controller.signal, + race: (operation: Promise, disposeLateValue?: (value: T) => void): Promise => { + const observed = Promise.resolve(operation); + if (deadlineSettled) { + observed.then( + value => { try { disposeLateValue?.(value); } catch { /* best-effort ownership cleanup */ } }, + () => undefined, + ); + return Promise.reject(deadlineReason); + } + return new Promise((resolve, reject) => { + let settled = false; + deadline.catch(error => { + if (settled) return; + settled = true; + reject(error); + }); + observed.then( + value => { + if (settled || deadlineSettled) { + try { disposeLateValue?.(value); } catch { /* best-effort ownership cleanup */ } + return; + } + settled = true; + resolve(value); + }, + error => { + if (settled) return; + settled = true; + reject(error); + }, + ); + }); + }, + timedOut: (): boolean => timedOut, + dispose: (): void => { + clearTimeout(timeout); + callerSignal?.removeEventListener('abort', onAbort); + }, + }; +}; + +export class ProprClient { + readonly baseUrl: ProprApiBaseUrl; + readonly authentication: ProprAuthentication; + readonly defaultTimeoutMs: number; + + private readonly fetchImplementation: typeof globalThis.fetch; + private readonly pairingProtocolOptions: PairingProtocolRequestOptions; + + constructor(options: ProprClientOptions = {}) { + this.baseUrl = normalizeApiBaseUrl(options.baseUrl, options); + this.authentication = options.authentication ?? { type: 'session' }; + this.defaultTimeoutMs = options.defaultTimeoutMs ?? 0; + assertTimeout(this.defaultTimeoutMs); + this.fetchImplementation = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + this.pairingProtocolOptions = options.pairingProtocol ?? {}; + } + + url(path: string): string { + return apiUrl(this.baseUrl, path); + } + + async fetch( + input: RequestInfo | URL, + init?: RequestInit, + options: ProprFetchOptions = {} + ): Promise { + const target = this.resolveRequestTarget(input); + const authentication = this.authenticate(init); + const authenticatedInit = authentication instanceof Promise + ? await authentication + : authentication; + const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs; + assertTimeout(timeoutMs); + + const controller = timeoutMs > 0 || authenticatedInit?.signal ? new AbortController() : undefined; + let timedOut = false; + let timeout: ReturnType | undefined; + const onAbort = (): void => controller?.abort(authenticatedInit?.signal?.reason); + + if (controller && authenticatedInit?.signal) { + if (authenticatedInit.signal.aborted) onAbort(); + else authenticatedInit.signal.addEventListener('abort', onAbort, { once: true }); + } + if (controller && timeoutMs > 0) { + timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + } + + try { + return await this.fetchImplementation(target, controller + ? { ...authenticatedInit, signal: controller.signal } + : authenticatedInit); + } catch (cause) { + if (timedOut) { + throw new ProprClientError('The ProPR API request timed out.', { kind: 'timeout', cause }); + } + if (authenticatedInit?.signal?.aborted || (cause instanceof Error && cause.name === 'AbortError')) { + throw new ProprClientError('The ProPR API request was cancelled.', { kind: 'aborted', cause }); + } + if (cause instanceof ProprClientError) throw cause; + throw new ProprClientError('The ProPR API could not be reached.', { kind: 'network', cause }); + } finally { + if (timeout) clearTimeout(timeout); + authenticatedInit?.signal?.removeEventListener('abort', onAbort); + } + } + + async request( + path: string, + init: RequestInit = {}, + options: ProprRequestOptions = {} + ): Promise { + const response = await this.fetch(this.url(path), init, options); + if (!response.ok) { + const body = await responseErrorBody(response); + throw new ProprClientError(`The ProPR API request failed with HTTP ${response.status}.`, { + kind: 'http', + status: response.status, + code: errorCode(body), + body, + }); + } + if (options.responseType === 'response') return response as T; + if (options.responseType === 'text') return await response.text() as T; + if (response.status === 204) return undefined as T; + try { + return await response.json() as T; + } catch (cause) { + throw new ProprClientError('The ProPR API returned an invalid JSON response.', { + kind: 'invalid_response', + status: response.status, + cause, + }); + } + } + + async negotiateCompatibility( + options: ProprCompatibilityOptions = {} + ): Promise { + const response = await this.fetch(this.url(options.path ?? '/api/compatibility'), { + credentials: this.authentication.type === 'session' + ? (this.authentication.credentials ?? 'include') + : undefined, + cache: 'no-store', + }, { timeoutMs: options.timeoutMs ?? 8000 }); + + if (response.status === 404) return evaluateProprApiCompatibility({}); + if (!response.ok) { + const body = await responseErrorBody(response); + throw new ProprClientError(`Compatibility negotiation failed with HTTP ${response.status}.`, { + kind: 'http', status: response.status, code: errorCode(body), body, + }); + } + + let metadata: unknown; + try { + metadata = await response.json(); + } catch (cause) { + throw new ProprClientError('The ProPR API returned invalid compatibility metadata.', { + kind: 'invalid_response', status: response.status, cause, + }); + } + if (!isCompatibilityMetadata(metadata)) { + throw new ProprClientError('The ProPR API returned invalid compatibility metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + return evaluateProprApiCompatibility(metadata); + } + + async requireCompatibility( + options: ProprCompatibilityOptions = {} + ): Promise { + const result = await this.negotiateCompatibility(options); + if (!result.compatible) { + throw new ProprClientError(result.message, { + kind: 'compatibility', + code: result.reason, + body: result, + }); + } + return result; + } + + async discoverDesktop(timeoutMs = 8000, signal?: AbortSignal): Promise { + const deadline = createDesktopDiscoveryDeadline(timeoutMs, signal); + if (signal?.aborted) { + deadline.dispose(); + throw new ProprClientError('Desktop discovery was cancelled.', { + kind: 'aborted', cause: signal.reason, + }); + } + let response: Response; + try { + response = await deadline.race( + this.fetchImplementation(this.resolveRequestTarget(this.url('/api/desktop/discovery')), { + cache: 'no-store', + credentials: 'omit', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: deadline.signal, + }), + lateResponse => { + try { void lateResponse.body?.cancel().catch(() => undefined); } catch { /* hostile late response */ } + }, + ); + } catch (cause) { + deadline.dispose(); + if (deadline.timedOut()) { + throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); + } + if (signal?.aborted) { + throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + } + if (cause instanceof ProprClientError) throw cause; + throw new ProprClientError('The ProPR API could not be reached.', { kind: 'network', cause }); + } + try { + const discoveryContentType = response.headers.get('content-type') + ?.split(';', 1)[0]?.trim().toLowerCase(); + const legacyAuthenticationCandidate = response.status === 401 + && !response.redirected + && discoveryContentType === 'application/json'; + if ((!response.ok && !legacyAuthenticationCandidate) + || response.redirected + || discoveryContentType !== 'application/json') { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ } + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', + status: response.status, + }); + } + const declaredLength = response.headers.get('content-length'); + if (declaredLength !== null && (!/^(?:0|[1-9]\d*)$/.test(declaredLength) + || Number(declaredLength) > PROPR_CONNECT_DISCOVERY_MAX_BYTES)) { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ } + throw new ProprClientError('The ProPR instance returned oversized desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + try { + if (reader) { + while (true) { + const part = await deadline.race(reader.read()); + if (part.done) break; + received += part.value.byteLength; + if (received > PROPR_CONNECT_DISCOVERY_MAX_BYTES) throw new Error('oversized'); + chunks.push(part.value); + } + } + } catch (cause) { + try { void reader?.cancel().catch(() => undefined); } catch { /* best-effort body cancellation */ } + if (deadline.timedOut()) { + throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); + } + if (signal?.aborted) { + throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + } + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + ...(legacyAuthenticationCandidate ? {} : { cause }), + }); + } finally { try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } } + const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase(); + if (declaredLength !== null && (!contentEncoding || contentEncoding === 'identity') + && Number(declaredLength) !== received) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + const bytes = new Uint8Array(received); + let cursor = 0; + for (const chunk of chunks) { bytes.set(chunk, cursor); cursor += chunk.byteLength; } + let contents: string; + try { contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } + catch (cause) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + ...(legacyAuthenticationCandidate ? {} : { cause }), + }); + } + if (legacyAuthenticationCandidate) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', + status: response.status, + ...(isExactLegacyDiscoveryAuthenticationBody(contents) + ? { code: DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED } + : {}), + }); + } + const metadata = parseProprDesktopDiscoveryJson(contents); + if (!metadata) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + const compatibility = evaluateProprApiCompatibility( + metadata, + ); + return parseDesktopDiscovery(metadata, compatibility); + } finally { + deadline.dispose(); + } + } + + async startDesktopPairing( + clientName: string, + options: Pick, + ): Promise { + const path = '/api/desktop/pairings'; + const expectedOrigin = this.resolveRequestOrigin(this.url(path)); + return parseDesktopPairingStart(await this.requestDesktopPairing(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientName, ...options.binding }), + redirect: 'manual', + signal: options.signal, + }), expectedOrigin, options.now); + } + + async pairDesktop( + clientName: string, + options: ProprDesktopPairingOptions, + ): Promise { + const start = await this.startDesktopPairing(clientName, options); + return completeDesktopPairing(this, start, options); + } + + async activateDesktopPairing( + pairing: ProprDesktopPairingComplete, + signal?: AbortSignal, + ): Promise { + return parseDesktopPairingActivationReceipt(await this.requestDesktopPairing( + `/api/desktop/pairings/${encodeURIComponent(pairing.pairingId)}/activate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deviceSecret: pairing.deviceSecret, + activationTicket: pairing.activationTicket, + instanceId: pairing.instanceId, + origin: pairing.origin, + scope: pairing.scope, + credentialGeneration: pairing.credentialGeneration, + }), + redirect: 'manual', + signal, + }, + )); + } + + async cancelDesktopPairing( + pairing: ProprDesktopPairingComplete, + signal?: AbortSignal, + ): Promise<{ status: 'cancelled'; cancelledAt: string }> { + const value = await this.requestDesktopPairing( + `/api/desktop/pairings/${encodeURIComponent(pairing.pairingId)}/cancel`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deviceSecret: pairing.deviceSecret, + activationTicket: pairing.activationTicket, + instanceId: pairing.instanceId, + origin: pairing.origin, + scope: pairing.scope, + credentialGeneration: pairing.credentialGeneration, + }), + redirect: 'manual', + signal, + }, + ); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ProprClientError('The ProPR instance returned an invalid pairing cancellation receipt.', { + kind: 'invalid_response', + }); + } + const receipt = value as Record; + if (receipt.status !== 'cancelled' || typeof receipt.cancelledAt !== 'string' + || !Number.isFinite(Date.parse(receipt.cancelledAt)) + || Object.keys(receipt).some(key => !['status', 'cancelledAt'].includes(key))) { + throw new ProprClientError('The ProPR instance returned an invalid pairing cancellation receipt.', { + kind: 'invalid_response', + }); + } + return receipt as unknown as { status: 'cancelled'; cancelledAt: string }; + } + + /** @internal Pairing keeps transport ownership through the complete body. */ + async requestDesktopPairing( + path: string, + init: RequestInit, + overallTimeoutMs?: number, + overallTimeoutError?: PairingProtocolRequestOptions['overallTimeoutError'], + ): Promise { + const target = this.resolveRequestTarget(this.url(path)); + const authentication = this.authenticate(init); + const authenticatedInit = authentication instanceof Promise + ? await authentication + : authentication; + return requestPairingProtocol( + this.fetchImplementation, + target, + authenticatedInit ?? {}, + { + ...this.pairingProtocolOptions, + overallTimeoutMs: overallTimeoutMs ?? this.pairingProtocolOptions.overallTimeoutMs, + overallTimeoutError: overallTimeoutError + ?? this.pairingProtocolOptions.overallTimeoutError, + }, + ); + } + + connectSocket(options: ProprSocketOptions = {}): Socket { + return connectProprSocket(buildSocketConnection(this.baseUrl, this.authentication, options)); + } + + private resolveRequestTarget(input: RequestInfo | URL): RequestInfo | URL { + const raw = input instanceof Request ? input.url : input.toString(); + if (raw.startsWith('/')) { + return apiUrl(this.baseUrl, raw); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new ProprClientError('The ProPR API request URL is invalid.', { kind: 'configuration' }); + } + if (parsed.username || parsed.password) { + throw new ProprClientError('ProPR API request URLs must not contain embedded credentials.', { + kind: 'configuration', + }); + } + const browserOrigin = typeof globalThis.location !== 'undefined' + ? globalThis.location.origin + : undefined; + const expectedOrigin = this.baseUrl || browserOrigin; + if (!expectedOrigin || parsed.origin !== expectedOrigin) { + throw new ProprClientError('The request URL does not belong to the configured ProPR instance.', { + kind: 'configuration', + }); + } + return input; + } + + private resolveRequestOrigin(input: RequestInfo | URL): string { + const raw = input instanceof Request ? input.url : input.toString(); + const browserOrigin = typeof globalThis.location !== 'undefined' + ? globalThis.location.origin + : undefined; + try { + const origin = new URL(raw, browserOrigin).origin; + if (origin === 'null') throw new Error(); + return origin; + } catch { + throw new ProprClientError('The ProPR instance origin could not be established.', { + kind: 'configuration', + }); + } + } + + private authenticate(init?: RequestInit): RequestInit | undefined | Promise { + if (this.authentication.type === 'none') return init; + if (this.authentication.type === 'session') { + if (init?.credentials !== undefined || this.authentication.applyByDefault === false) return init; + return { ...init, credentials: this.authentication.credentials ?? 'include' }; + } + return this.authenticateBearer(init, this.authentication.getAccessToken); + } + + private async authenticateBearer( + init: RequestInit | undefined, + getAccessToken: () => string | null | undefined | Promise + ): Promise { + let token: string | undefined; + try { + token = (await getAccessToken())?.trim(); + } catch (cause) { + if (cause instanceof ProprClientError) throw cause; + throw new ProprClientError('ProPR bearer authentication is unavailable.', { + kind: 'authentication', cause, + }); + } + const headers = new Headers(init?.headers); + headers.delete('Authorization'); + if (token) { + if (/\r|\n/.test(token)) { + throw new ProprClientError('The bearer token is invalid.', { kind: 'configuration' }); + } + headers.set('Authorization', `Bearer ${token}`); + } + // Bearer profiles must never accidentally inherit a browser/Electron cookie + // identity from another named profile on the same origin. + return { ...init, credentials: 'omit', headers }; + } +} diff --git a/packages/client/src/desktopPairing.ts b/packages/client/src/desktopPairing.ts new file mode 100644 index 000000000..c261af7e3 --- /dev/null +++ b/packages/client/src/desktopPairing.ts @@ -0,0 +1,367 @@ +import type { + ProprApiCompatibilityResult, + ProprDesktopDiscovery as SharedProprDesktopDiscovery, +} from '@propr/shared'; +import { canonicalProprHttpUrlOrigin, parseProprDesktopDiscovery } from '@propr/shared'; +import type { ProprClient } from './client.js'; +import { ProprClientError } from './errors.js'; + +export interface ProprDesktopDiscovery extends SharedProprDesktopDiscovery { + compatibility: ProprApiCompatibilityResult; +} + +export interface ProprDesktopPairingStart { + pairingId: string; + deviceSecret: string; + approvalUrl: string; + expiresAt: string; + interval: number; +} + +export interface ProprDesktopPairingComplete { + token: string; + tokenType: 'Bearer'; + pairingId: string; + deviceSecret: string; + activationTicket: string; + activationExpiresAt: string; + instanceId: string; + origin: string; + scope: 'desktop-instance'; + credentialGeneration: string; +} + +export interface ProprDesktopPairingBinding { + instanceId: string; + origin: string; + scope: 'desktop-instance'; + credentialGeneration: string; +} + +export interface ProprDesktopPairingActivationReceipt { + status: 'active'; + receipt: string; + activatedAt: string; + expiresAt: string | null; +} + +export interface ProprDesktopPairingOptions { + signal?: AbortSignal; + binding: ProprDesktopPairingBinding; + onApprovalRequired?(approvalUrl: string, expiresAt: string, pairingId: string): void | Promise; + /** Injectable only to make protocol tests deterministic. */ + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; + /** Injectable only to make expiry tests deterministic. */ + now?: () => number; + /** @internal Deterministic monotonic deadline source for protocol tests. */ + clock?: { + now(): number; + setTimeout(callback: () => void, milliseconds: number): ReturnType; + clearTimeout(timer: ReturnType): void; + }; +} + +const MIN_POLL_INTERVAL_SECONDS = 1; +const MAX_POLL_INTERVAL_SECONDS = 60; +const MAX_PAIRING_LIFETIME_MS = 30 * 60 * 1000; +const PAIRING_REQUEST_TIMEOUT_MS = 8_000; +const exactKeys = (body: Record, keys: readonly string[]): boolean => + Object.keys(body).length === keys.length && Object.keys(body).every(key => keys.includes(key)); + +const record = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ProprClientError('The ProPR desktop protocol returned an invalid response.', { + kind: 'invalid_response', + }); + } + return value as Record; +}; + +const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0; +const validPollInterval = (value: unknown): value is number => typeof value === 'number' + && Number.isInteger(value) + && value >= MIN_POLL_INTERVAL_SECONDS + && value <= MAX_POLL_INTERVAL_SECONDS; + +const validPairingDeadline = (value: unknown, now: number): value is string => { + if (!string(value)) return false; + const deadline = Date.parse(value); + return Number.isFinite(deadline) + && Number.isFinite(now) + && deadline > now + && deadline - now <= MAX_PAIRING_LIFETIME_MS; +}; + +const validBinding = (value: unknown): value is ProprDesktopPairingBinding => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const binding = value as Record; + return typeof binding.instanceId === 'string' + && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(binding.instanceId) + && typeof binding.origin === 'string' + && canonicalProprHttpUrlOrigin(binding.origin) === binding.origin + && binding.scope === 'desktop-instance' + && typeof binding.credentialGeneration === 'string' + && /^[A-Za-z0-9_-]{22}$/.test(binding.credentialGeneration); +}; + +export const parseDesktopDiscovery = ( + value: unknown, + compatibility: ProprApiCompatibilityResult, +): ProprDesktopDiscovery => { + const body = parseProprDesktopDiscovery(value); + if (!body) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', + }); + } + return { ...body, compatibility }; +}; + +export const parseDesktopPairingStart = ( + value: unknown, + expectedOrigin: string, + now: () => number = Date.now, +): ProprDesktopPairingStart => { + const body = record(value); + if (!exactKeys(body, ['pairingId', 'deviceSecret', 'approvalUrl', 'expiresAt', 'interval']) + || !string(body.pairingId) || !/^dpr_[A-Za-z0-9_-]{22}$/.test(body.pairingId) + || !string(body.deviceSecret) || !/^[A-Za-z0-9_-]{43}$/.test(body.deviceSecret) + || !string(body.approvalUrl) + || !validPollInterval(body.interval) + || !validPairingDeadline(body.expiresAt, now())) { + throw new ProprClientError('The ProPR instance returned an invalid pairing request.', { + kind: 'invalid_response', + }); + } + try { + const approvalUrl = new URL(body.approvalUrl); + if (canonicalProprHttpUrlOrigin(body.approvalUrl) !== approvalUrl.origin) throw new Error(); + if (approvalUrl.username || approvalUrl.password) throw new Error(); + // Device approval is intentionally same-origin. A future hosted approval + // service must define and validate a narrow trust contract here first. + if (!expectedOrigin || approvalUrl.origin !== expectedOrigin) throw new Error(); + } catch { + throw new ProprClientError('The ProPR instance returned an unsafe pairing approval URL.', { + kind: 'invalid_response', + }); + } + return { + pairingId: body.pairingId, + deviceSecret: body.deviceSecret, + approvalUrl: body.approvalUrl, + expiresAt: body.expiresAt, + interval: body.interval, + }; +}; + +const cancelled = (cause?: unknown): ProprClientError => + new ProprClientError('Desktop pairing was cancelled.', { kind: 'aborted', cause }); + +const expired = (cause?: unknown): ProprClientError => + new ProprClientError('Desktop pairing expired before it was approved.', { + kind: 'authentication', code: 'PAIRING_EXPIRED', cause, + }); + +const safeDelay = (milliseconds: number): number => Math.max(1, Math.ceil(milliseconds)); + +const defaultSleep = (milliseconds: number, signal?: AbortSignal): Promise => new Promise((resolve, reject) => { + const aborted = () => { + clearTimeout(timer); + reject(cancelled()); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', aborted); + resolve(); + }, milliseconds); + if (signal?.aborted) aborted(); + else signal?.addEventListener('abort', aborted, { once: true }); +}); + +export const completeDesktopPairing = async ( + client: ProprClient, + start: ProprDesktopPairingStart, + options: ProprDesktopPairingOptions, +): Promise => { + const sleep = options.sleep ?? defaultSleep; + const now = options.now ?? Date.now; + const clock = options.clock ?? { + now: () => performance.now(), + setTimeout: (callback: () => void, milliseconds: number) => setTimeout(callback, milliseconds), + clearTimeout: (timer: ReturnType) => clearTimeout(timer), + }; + if (options.signal?.aborted) throw cancelled(options.signal.reason); + const deadline = Date.parse(start.expiresAt); + const startedAt = now(); + const lifetimeMs = deadline - startedAt; + if (!validPollInterval(start.interval) + || !Number.isFinite(deadline) + || !Number.isFinite(startedAt) + || lifetimeMs > MAX_PAIRING_LIFETIME_MS) { + throw new ProprClientError('The ProPR instance returned an invalid pairing deadline.', { + kind: 'invalid_response', + }); + } + if (lifetimeMs <= 0) throw expired(); + + const lifetimeController = new AbortController(); + const monotonicStartedAt = clock.now(); + let terminal: 'caller' | 'deadline' | undefined; + const abortForCaller = () => { + if (terminal) return; + terminal = 'caller'; + lifetimeController.abort(options.signal?.reason); + }; + const abortForDeadline = () => { + if (terminal) return; + terminal = 'deadline'; + lifetimeController.abort(expired()); + }; + const deadlineTimer = clock.setTimeout(abortForDeadline, safeDelay(lifetimeMs)); + if (options.signal?.aborted) abortForCaller(); + else options.signal?.addEventListener('abort', abortForCaller, { once: true }); + + const terminalError = (cause?: unknown): ProprClientError => terminal === 'caller' + ? cancelled(cause ?? options.signal?.reason) + : expired(cause); + const remainingLifetime = (): number => Math.min( + deadline - now(), + lifetimeMs - (clock.now() - monotonicStartedAt), + ); + const requireRemainingLifetime = (): number => { + if (terminal) throw terminalError(); + const remaining = remainingLifetime(); + if (remaining <= 0) { + abortForDeadline(); + throw terminalError(); + } + return remaining; + }; + const raceLifetime = (operation: PromiseLike): Promise => { + let removeAbortListener: () => void = () => undefined; + const result = new Promise((resolve, reject) => { + const rejectForAbort = () => reject(terminalError()); + removeAbortListener = () => { + lifetimeController.signal.removeEventListener('abort', rejectForAbort); + }; + if (lifetimeController.signal.aborted) rejectForAbort(); + else lifetimeController.signal.addEventListener('abort', rejectForAbort, { once: true }); + // Always attach both handlers, even if the lifetime already ended, so a + // callback or transport that settles late cannot become unhandled. + Promise.resolve(operation).then(resolve, error => { + reject(terminal ? terminalError(error) : error); + }); + }); + return result.finally(() => removeAbortListener()); + }; + + try { + let intervalSeconds = start.interval; + if (options.onApprovalRequired) { + const approval = Promise.resolve().then(() => + options.onApprovalRequired?.(start.approvalUrl, start.expiresAt, start.pairingId)); + await raceLifetime(approval); + requireRemainingLifetime(); + } + + while (true) { + const remainingBeforeSleep = requireRemainingLifetime(); + const delay = safeDelay(Math.min(intervalSeconds * 1000, remainingBeforeSleep)); + await raceLifetime(sleep(delay, lifetimeController.signal)); + const remaining = requireRemainingLifetime(); + + let value: unknown; + const requestUsesPairingDeadline = remaining <= PAIRING_REQUEST_TIMEOUT_MS; + let pairingDeadlineTimedOut = false; + try { + // The pairing reader owns cancellation through body drain/cancel. Do + // not race it with a faster outer rejection: completion here is the + // operation's guarantee that no response task survives this poll. + value = await client.requestDesktopPairing( + `/api/desktop/pairings/${encodeURIComponent(start.pairingId)}/poll`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceSecret: start.deviceSecret }), + redirect: 'manual', + signal: lifetimeController.signal, + }, + Math.min(PAIRING_REQUEST_TIMEOUT_MS, safeDelay(remaining)), + requestUsesPairingDeadline ? cause => { + pairingDeadlineTimedOut = true; + return expired(cause); + } : undefined, + ); + } catch (error) { + // A request clamped to the remaining lifetime owns the same boundary + // as the pairing deadline. Its timer can run first when the pairing + // timer's task is delayed, but that must not change expiry into a + // transport timeout at the exact boundary. + if (terminal) throw terminalError(error); + if (pairingDeadlineTimedOut) { + abortForDeadline(); + throw error; + } + if (remainingLifetime() <= 0) { + abortForDeadline(); + throw terminalError(error); + } + throw error; + } + requireRemainingLifetime(); + const body = record(value); + if (body.status === 'pending' + && exactKeys(body, ['status', 'interval']) + && validPollInterval(body.interval)) { + intervalSeconds = body.interval; + continue; + } + if (body.status === 'provisional' + && exactKeys(body, [ + 'status', 'token', 'tokenType', 'activationTicket', 'activationExpiresAt', + 'instanceId', 'origin', 'scope', 'credentialGeneration', + ]) + && string(body.token) + && /^propr_it_[A-Za-z0-9_-]{43}$/.test(body.token) && body.tokenType === 'Bearer' + && string(body.activationTicket) && /^[A-Za-z0-9_-]{43}$/.test(body.activationTicket) + && validPairingDeadline(body.activationExpiresAt, now()) + && validBinding(body) + && body.instanceId === options.binding.instanceId + && body.origin === options.binding.origin + && body.scope === options.binding.scope + && body.credentialGeneration === options.binding.credentialGeneration) { + requireRemainingLifetime(); + return { + token: body.token, + tokenType: 'Bearer', + pairingId: start.pairingId, + deviceSecret: start.deviceSecret, + activationTicket: body.activationTicket, + activationExpiresAt: body.activationExpiresAt, + instanceId: body.instanceId, + origin: body.origin, + scope: body.scope, + credentialGeneration: body.credentialGeneration, + }; + } + throw new ProprClientError('The ProPR instance returned an invalid pairing status.', { + kind: 'invalid_response', + }); + } + } finally { + clock.clearTimeout(deadlineTimer); + options.signal?.removeEventListener('abort', abortForCaller); + } +}; + +export const parseDesktopPairingActivationReceipt = (value: unknown): ProprDesktopPairingActivationReceipt => { + const body = record(value); + if (body.status !== 'active' || !string(body.receipt) || !/^[A-Za-z0-9_-]{22}$/.test(body.receipt) + || !string(body.activatedAt) || !Number.isFinite(Date.parse(body.activatedAt)) + || !(body.expiresAt === null || (string(body.expiresAt) && Number.isFinite(Date.parse(body.expiresAt)))) + || Object.keys(body).some(key => !['status', 'receipt', 'activatedAt', 'expiresAt'].includes(key))) { + throw new ProprClientError('The ProPR instance returned an invalid pairing activation receipt.', { + kind: 'invalid_response', + }); + } + return body as unknown as ProprDesktopPairingActivationReceipt; +}; diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts new file mode 100644 index 000000000..75ed8fa8b --- /dev/null +++ b/packages/client/src/errors.ts @@ -0,0 +1,57 @@ +export type ProprClientErrorKind = + | 'configuration' + | 'authentication' + | 'network' + | 'timeout' + | 'aborted' + | 'http' + | 'invalid_response' + | 'compatibility'; + +/** The exact credential-free public discovery request was authentication-gated. */ +export const DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED = + 'DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED' as const; + +export interface ProprClientErrorOptions { + kind: ProprClientErrorKind; + status?: number; + code?: string; + body?: unknown; + cause?: unknown; +} + +/** A transport-safe error shape shared by browser, desktop, and CLI clients. */ +export class ProprClientError extends Error { + readonly kind: ProprClientErrorKind; + readonly status?: number; + readonly code?: string; + readonly body?: unknown; + readonly cause?: unknown; + + constructor(message: string, options: ProprClientErrorOptions) { + super(message); + this.name = 'ProprClientError'; + this.kind = options.kind; + this.status = options.status; + this.code = options.code; + this.body = options.body; + this.cause = options.cause; + } + + toJSON(): Record { + return { + name: this.name, + message: this.message, + kind: this.kind, + status: this.status, + code: this.code, + }; + } +} + +export const isProprClientError = (error: unknown): error is ProprClientError => + error instanceof ProprClientError || ( + error instanceof Error + && error.name === 'ProprClientError' + && 'kind' in error + ); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 000000000..84f5a37ab --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,51 @@ +export { + apiUrl, + classifyApiBaseUrl, + normalizeApiBaseUrl, + type NormalizeApiBaseUrlOptions, + type ProprApiEndpointClassification, + type ProprApiEndpointKind, + type ProprApiBaseUrl, +} from './baseUrl.js'; +export { + ProprClient, + type ProprClientOptions, + type ProprCompatibilityOptions, + type ProprFetchOptions, + type ProprRequestOptions, +} from './client.js'; +export { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + isProprClientError, + ProprClientError, + type ProprClientErrorKind, + type ProprClientErrorOptions, +} from './errors.js'; +export { + completeDesktopPairing, + parseDesktopDiscovery, + parseDesktopPairingStart, + parseDesktopPairingActivationReceipt, + type ProprDesktopPairingActivationReceipt, + type ProprDesktopPairingBinding, + type ProprDesktopDiscovery, + type ProprDesktopPairingComplete, + type ProprDesktopPairingOptions, + type ProprDesktopPairingStart, +} from './desktopPairing.js'; +export type { PairingProtocolRequestOptions } from './pairingProtocol.js'; +export { + normalizeInstanceProfile, + type NormalizedProprInstanceProfile, + type ProprInstanceAuthentication, + type ProprInstanceProfile, +} from './profile.js'; +export { + buildSocketConnection, + connectProprSocket, + type AccessTokenProvider, + type ProprAuthentication, + type ProprSocketConnection, + type ProprSocketOptions, + type Socket, +} from './socket.js'; diff --git a/packages/client/src/pairingProtocol.ts b/packages/client/src/pairingProtocol.ts new file mode 100644 index 000000000..bbb8ba49b --- /dev/null +++ b/packages/client/src/pairingProtocol.ts @@ -0,0 +1,349 @@ +import { ProprClientError } from './errors.js'; + +const CONNECT_HEADER_TIMEOUT_MS = 8_000; +const BODY_TIMEOUT_MS = 8_000; +const OVERALL_TIMEOUT_MS = CONNECT_HEADER_TIMEOUT_MS + BODY_TIMEOUT_MS; +const CANCELLATION_TIMEOUT_MS = 100; +const MAX_RESPONSE_BYTES = 4_096; +const MAX_ENCODED_RESPONSE_BYTES = 4_096; +const CANCELLATION_TIMEOUT_DIAGNOSTIC = 'ProPR pairing response cancellation exceeded its fixed deadline.'; + +type TimeoutPhase = 'connect-header' | 'body' | 'overall'; +type ContentEncoding = 'identity' | 'gzip' | 'br'; + +export interface PairingProtocolRequestOptions { + overallTimeoutMs?: number; + /** @internal Reclassifies only the overall boundary owned by a caller. */ + overallTimeoutError?: (cause?: unknown) => ProprClientError; + /** @internal Deterministic protocol-test deadlines may only shorten production limits. */ + deadlines?: Partial<{ + headerMs: number; + bodyMs: number; + cancellationMs: number; + }>; + /** @internal Receives only a fixed, redacted cancellation diagnostic. */ + reportDiagnostic?: (message: string) => void; + /** @internal Deterministic monotonic timer source for protocol tests. */ + clock?: { + now(): number; + setTimeout(callback: () => void, milliseconds: number): ReturnType; + clearTimeout(timer: ReturnType): void; + }; +} + +const timeoutError = (cause?: unknown): ProprClientError => + new ProprClientError('The ProPR desktop pairing request timed out.', { kind: 'timeout', cause }); + +const cancelledError = (cause?: unknown): ProprClientError => + new ProprClientError('Desktop pairing was cancelled.', { kind: 'aborted', cause }); + +const invalidResponse = (status?: number, cause?: unknown): ProprClientError => + new ProprClientError('The ProPR desktop pairing service returned an invalid response.', { + kind: 'invalid_response', + status, + cause, + }); + +const networkError = (cause?: unknown): ProprClientError => + new ProprClientError('The ProPR desktop pairing service could not be reached.', { + kind: 'network', + cause, + }); + +const errorCode = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const body = value as Record; + if (Object.keys(body).some(key => !['code', 'error'].includes(key)) + || typeof body.code !== 'string' + || !/^[A-Z][A-Z0-9_]{0,63}$/.test(body.code) + || typeof body.error !== 'string' + || body.error.length < 1 + || body.error.length > 256) return undefined; + return body.code; +}; + +const positiveTimeout = (value: number | undefined): number => { + const timeout = value ?? OVERALL_TIMEOUT_MS; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > OVERALL_TIMEOUT_MS) { + throw new ProprClientError('Desktop pairing request deadlines are invalid.', { + kind: 'configuration', + }); + } + return timeout; +}; + +const boundedDeadline = (value: number | undefined, maximum: number): number => { + const deadline = value ?? maximum; + if (!Number.isSafeInteger(deadline) || deadline < 1 || deadline > maximum) { + throw new ProprClientError('Desktop pairing request deadlines are invalid.', { + kind: 'configuration', + }); + } + return deadline; +}; + +const contentLength = (response: Response): number | undefined => { + const raw = response.headers.get('content-length'); + if (raw === null) return undefined; + if (!/^(?:0|[1-9][0-9]*)$/.test(raw)) throw invalidResponse(response.status); + const value = Number(raw); + if (!Number.isSafeInteger(value)) throw invalidResponse(response.status); + return value; +}; + +const contentEncoding = (response: Response): ContentEncoding => { + const raw = response.headers.get('content-encoding'); + if (raw === null) return 'identity'; + const encoding = raw.trim().toLowerCase(); + if (encoding !== 'identity' && encoding !== 'gzip' && encoding !== 'br') { + // A comma also makes duplicate and stacked encodings fail closed. Fetch + // exposes transparently decoded bytes, so only one known wire encoding can + // be related safely to the remaining response metadata. + throw invalidResponse(response.status); + } + return encoding; +}; + +/** + * Reads one pairing response under a single cancellation owner. The caller's + * signal and all timers remain installed until the response stream is complete + * or has been cancelled, so receiving headers never releases the operation. + */ +export const requestPairingProtocol = async ( + fetchImplementation: typeof globalThis.fetch, + target: RequestInfo | URL, + init: RequestInit, + options: PairingProtocolRequestOptions = {}, +): Promise => { + const callerSignal = init.signal; + const overallTimeoutMs = positiveTimeout(options.overallTimeoutMs); + const headerTimeoutMs = boundedDeadline(options.deadlines?.headerMs, CONNECT_HEADER_TIMEOUT_MS); + const bodyTimeoutMs = boundedDeadline(options.deadlines?.bodyMs, BODY_TIMEOUT_MS); + const cancellationTimeoutMs = boundedDeadline( + options.deadlines?.cancellationMs, + CANCELLATION_TIMEOUT_MS, + ); + const reportDiagnostic = options.reportDiagnostic ?? ((message: string) => console.warn(message)); + const clock = options.clock ?? { + now: () => performance.now(), + setTimeout: (callback: () => void, milliseconds: number) => setTimeout(callback, milliseconds), + clearTimeout: (timer: ReturnType) => clearTimeout(timer), + }; + const reportCancellationTimeout = (): void => { + try { + reportDiagnostic(CANCELLATION_TIMEOUT_DIAGNOSTIC); + } catch { + // A diagnostic hook must never change transport or shutdown settlement. + } + }; + const controller = new AbortController(); + const startedAt = clock.now(); + let timeoutPhase: TimeoutPhase | undefined; + let headerTimer: ReturnType | undefined; + let bodyTimer: ReturnType | undefined; + let overallTimer: ReturnType | undefined; + let response: Response | undefined; + let reader: ReadableStreamDefaultReader | undefined; + + const abortForCaller = (): void => controller.abort(callerSignal?.reason); + const abortForTimeout = (phase: TimeoutPhase): void => { + if (controller.signal.aborted) return; + timeoutPhase = phase; + controller.abort(new DOMException('Desktop pairing deadline exceeded', 'TimeoutError')); + }; + const raceCancellation = (operation: PromiseLike): Promise => new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + controller.signal.removeEventListener('abort', aborted); + callback(); + }; + const aborted = () => finish(() => reject( + controller.signal.reason ?? new DOMException('Aborted', 'AbortError'), + )); + if (controller.signal.aborted) aborted(); + else controller.signal.addEventListener('abort', aborted, { once: true }); + // Both handlers remain attached to the foreign promise after our abort + // wins. A later resolve/reject is deliberately consumed and cannot alter + // endpoint state or become an unhandled rejection. + Promise.resolve(operation).then( + value => finish(() => resolve(value)), + error => finish(() => reject(error)), + ); + }); + const remainingOverall = (): number => Math.max( + 0, + overallTimeoutMs - (clock.now() - startedAt), + ); + const cancelResponse = async (): Promise => { + const cancelTarget = reader ?? response?.body; + if (!cancelTarget) return; + let cancellation: Promise; + try { + cancellation = Promise.resolve(cancelTarget.cancel()); + } catch { + return; + } + // Attach a rejection handler before doing anything else. The underlying + // stream controls this promise and may reject long after local shutdown. + let cancellationSettled = false; + const settled = cancellation.then( + () => { cancellationSettled = true; return true; }, + () => { cancellationSettled = true; return true; }, + ); + const budget = Math.min(cancellationTimeoutMs, remainingOverall()); + if (budget <= 0) { + // Give an already-settled cancellation its queued promise reaction, but + // never install or await a foreign task beyond the overall boundary. + await Promise.resolve(); + if (!cancellationSettled) reportCancellationTimeout(); + return; + } + let cancellationTimer: ReturnType | undefined; + const cancelledInBudget = await Promise.race([ + settled, + new Promise(resolve => { + cancellationTimer = clock.setTimeout(() => resolve(false), budget); + }), + ]); + if (cancellationTimer) clock.clearTimeout(cancellationTimer); + if (!cancelledInBudget) reportCancellationTimeout(); + }; + + if (callerSignal?.aborted) abortForCaller(); + else callerSignal?.addEventListener('abort', abortForCaller, { once: true }); + if (!controller.signal.aborted) { + overallTimer = clock.setTimeout(() => abortForTimeout('overall'), overallTimeoutMs); + headerTimer = clock.setTimeout( + () => abortForTimeout('connect-header'), + Math.min(headerTimeoutMs, overallTimeoutMs), + ); + } + + try { + // Promise argument evaluation would otherwise call an untrusted fetch even + // when disposal/caller cancellation was already complete. + if (controller.signal.aborted) { + throw controller.signal.reason ?? new DOMException('Aborted', 'AbortError'); + } + response = await raceCancellation(fetchImplementation(target, { + ...init, + redirect: 'manual', + signal: controller.signal, + })); + if (headerTimer) clock.clearTimeout(headerTimer); + headerTimer = undefined; + + // Browsers may expose a manual cross-origin redirect as opaqueredirect + // rather than preserving its 3xx status. Both forms are terminal and their + // bodies are never parsed. + if ((response.status >= 300 && response.status < 400) + || response.type === 'opaqueredirect' + || response.status === 0) { + throw invalidResponse(response.status || undefined); + } + + const encoding = contentEncoding(response); + const declaredLength = contentLength(response); + // Content-Length describes the encoded wire representation. Bound it for + // every supported encoding before reading Fetch's decoded response stream. + const maximumDeclaredLength = encoding === 'identity' + ? MAX_RESPONSE_BYTES + : MAX_ENCODED_RESPONSE_BYTES; + if (declaredLength !== undefined && declaredLength > maximumDeclaredLength) { + throw invalidResponse(response.status); + } + if (!response.body) { + if (!response.ok) { + throw new ProprClientError(`Desktop pairing request failed with HTTP ${response.status}.`, { + kind: 'http', + status: response.status, + }); + } + throw invalidResponse(response.status); + } + + reader = response.body.getReader(); + bodyTimer = clock.setTimeout( + () => abortForTimeout('body'), + Math.min(bodyTimeoutMs, overallTimeoutMs), + ); + const chunks: Uint8Array[] = []; + let byteLength = 0; + while (true) { + const part = await raceCancellation(reader.read()); + if (part.done) break; + if (!(part.value instanceof Uint8Array) || part.value.byteLength === 0) { + throw invalidResponse(response.status); + } + byteLength += part.value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) throw invalidResponse(response.status); + chunks.push(part.value); + } + // For gzip and Brotli, Fetch retains the wire Content-Length while exposing + // transparently decoded stream chunks. It is not meaningful to compare the + // compressed length with byteLength; the decoded cap above remains the + // authoritative bound. Identity responses still require an exact match. + if (encoding === 'identity' + && declaredLength !== undefined + && declaredLength !== byteLength) { + throw invalidResponse(response.status); + } + + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + throw invalidResponse(response.status, cause); + } + + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch (cause) { + if (!response.ok) value = undefined; + else throw invalidResponse(response.status, cause); + } + if (!response.ok) { + throw new ProprClientError(`Desktop pairing request failed with HTTP ${response.status}.`, { + kind: 'http', + status: response.status, + code: errorCode(value), + }); + } + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (contentType !== 'application/json') throw invalidResponse(response.status); + return value; + } catch (cause) { + if (cause instanceof ProprClientError) throw cause; + if (callerSignal?.aborted) throw cancelledError(cause); + if (timeoutPhase) { + if (timeoutPhase === 'overall' && options.overallTimeoutError) { + throw options.overallTimeoutError(cause); + } + throw timeoutError(cause); + } + if (cause instanceof Error && cause.name === 'AbortError') throw cancelledError(cause); + throw networkError(cause); + } finally { + // Network ownership ends before touching the untrusted stream primitive. + // All local timers/listeners are detached first; cancellation then gets a + // separate short budget which is also clamped to the endpoint deadline. + if (!controller.signal.aborted) controller.abort(); + if (headerTimer) clock.clearTimeout(headerTimer); + if (bodyTimer) clock.clearTimeout(bodyTimer); + if (overallTimer) clock.clearTimeout(overallTimer); + callerSignal?.removeEventListener('abort', abortForCaller); + await cancelResponse(); + try { reader?.releaseLock(); } catch { /* The stream may already be errored. */ } + reader = undefined; + response = undefined; + } +}; diff --git a/packages/client/src/profile.ts b/packages/client/src/profile.ts new file mode 100644 index 000000000..2dba2a2d9 --- /dev/null +++ b/packages/client/src/profile.ts @@ -0,0 +1,50 @@ +import { + normalizeApiBaseUrl, + type NormalizeApiBaseUrlOptions, + type ProprApiBaseUrl, +} from './baseUrl.js'; +import { ProprClientError } from './errors.js'; + +export type ProprInstanceAuthentication = 'session' | 'bearer' | 'none'; + +/** Serializable instance metadata. Credentials and persistence intentionally live elsewhere. */ +export interface ProprInstanceProfile { + id: string; + name: string; + /** Empty or omitted selects the browser's current origin. */ + apiBaseUrl?: string; + authentication: ProprInstanceAuthentication; + allowInsecureHttp?: boolean; +} + +export interface NormalizedProprInstanceProfile extends Omit { + apiBaseUrl: ProprApiBaseUrl; +} + +const validateLabel = (value: string, field: 'id' | 'name'): string => { + const normalized = value.trim(); + const maximum = field === 'id' ? 128 : 200; + if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new ProprClientError(`The instance ${field} is invalid.`, { kind: 'configuration' }); + } + return normalized; +}; + +export const normalizeInstanceProfile = ( + profile: ProprInstanceProfile, + options: NormalizeApiBaseUrlOptions = {} +): NormalizedProprInstanceProfile => { + if (!['session', 'bearer', 'none'].includes(profile.authentication)) { + throw new ProprClientError('The instance authentication mode is invalid.', { + kind: 'configuration', + }); + } + return { + ...profile, + id: validateLabel(profile.id, 'id'), + name: validateLabel(profile.name, 'name'), + apiBaseUrl: normalizeApiBaseUrl(profile.apiBaseUrl, { + allowInsecureHttp: profile.allowInsecureHttp ?? options.allowInsecureHttp, + }), + }; +}; diff --git a/packages/client/src/socket.ts b/packages/client/src/socket.ts new file mode 100644 index 000000000..b6bdefc50 --- /dev/null +++ b/packages/client/src/socket.ts @@ -0,0 +1,93 @@ +import { io, type ManagerOptions, type Socket, type SocketOptions } from 'socket.io-client'; +import type { ProprApiBaseUrl } from './baseUrl.js'; + +export type AccessTokenProvider = () => string | null | undefined | Promise; + +export type ProprAuthentication = + | { + type: 'session'; + credentials?: RequestCredentials; + /** Leave RequestInit credentials untouched unless a request opts in. */ + applyByDefault?: boolean; + } + | { type: 'bearer'; getAccessToken: AccessTokenProvider } + | { type: 'none' }; + +export type ProprSocketOptions = Partial; + +export interface ProprSocketConnection { + url: string | undefined; + options: ProprSocketOptions; +} + +type SocketAuthPayload = Record; +type SocketAuthCallback = (data: SocketAuthPayload) => void; + +const metadataWithoutToken = (value: unknown): SocketAuthPayload => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const { token: _untrustedToken, ...metadata } = value as SocketAuthPayload; + return metadata; +}; + +const bearerSocketAuth = ( + getAccessToken: AccessTokenProvider, + configuredAuth: SocketOptions['auth'], +): SocketOptions['auth'] => (callback: SocketAuthCallback): void => { + const resolveBearer = (metadataValue: unknown): void => { + const metadata = metadataWithoutToken(metadataValue); + Promise.resolve(getAccessToken()).then( + token => { + const normalized = token?.trim(); + callback(normalized && !/\r|\n/.test(normalized) + ? { ...metadata, token: normalized } + : metadata); + }, + () => callback(metadata), + ); + }; + + if (typeof configuredAuth === 'function') { + try { + configuredAuth(resolveBearer); + } catch { + resolveBearer({}); + } + return; + } + resolveBearer(configuredAuth); +}; + +/** Build the complete, explicit reconnect policy used by every ProPR surface. */ +export const buildSocketConnection = ( + baseUrl: ProprApiBaseUrl, + authentication: ProprAuthentication, + overrides: ProprSocketOptions = {} +): ProprSocketConnection => { + const auth = authentication.type === 'bearer' + ? bearerSocketAuth(authentication.getAccessToken, overrides.auth) + : undefined; + + return { + url: baseUrl || undefined, + options: { + transports: ['websocket'], + withCredentials: authentication.type === 'session', + autoConnect: true, + path: '/socket.io/', + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + randomizationFactor: 0.5, + timeout: 20_000, + ...overrides, + ...(auth ? { auth } : {}), + }, + }; +}; + +export const connectProprSocket = ( + connection: ProprSocketConnection +): Socket => io(connection.url, connection.options); + +export type { Socket } from 'socket.io-client'; diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts new file mode 100644 index 000000000..7f331678f --- /dev/null +++ b/packages/client/test/client.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { PROPR_API_COMPATIBILITY, PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import { + classifyApiBaseUrl, + ProprClient, + ProprClientError, + normalizeApiBaseUrl, + normalizeInstanceProfile, +} from '../src/index.js'; + +describe('Propr API base URLs and instance profiles', () => { + it('matches the shared canonical origin parity table', () => { + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + if (expected === null) assert.throws(() => normalizeApiBaseUrl(input), ProprClientError, name); + else assert.equal(normalizeApiBaseUrl(input), expected, name); + } + }); + it('supports browser same-origin, loopback, and secure remote instances', () => { + assert.equal(normalizeApiBaseUrl(), ''); + assert.equal(normalizeApiBaseUrl(' http://localhost:4000/ '), 'http://localhost:4000'); + assert.equal(normalizeApiBaseUrl('http://api.dev.localhost:3000'), 'http://api.dev.localhost:3000'); + assert.equal(normalizeApiBaseUrl('http://127.42.7.9:3000'), 'http://127.42.7.9:3000'); + assert.equal(normalizeApiBaseUrl('http://127.0.0.1:3000'), 'http://127.0.0.1:3000'); + assert.equal(normalizeApiBaseUrl('http://[::1]:3000'), 'http://[::1]:3000'); + assert.equal(normalizeApiBaseUrl('https://propr.example.com/'), 'https://propr.example.com'); + + const profile = normalizeInstanceProfile({ + id: 'remote-primary', + name: 'Remote primary', + apiBaseUrl: 'https://propr.example.com/', + authentication: 'bearer', + }); + assert.equal(profile.apiBaseUrl, 'https://propr.example.com'); + assert.equal(profile.name, 'Remote primary'); + }); + + it('rejects malformed and unsafe endpoints', () => { + for (const value of [ + '/api', + 'ftp://propr.example.com', + 'https://user:secret@propr.example.com', + 'https://propr.example.com/api', + 'https://propr.example.com?token=secret', + 'http://propr.example.com', + 'https://t-instance123.propr.dev:443', + 'https://t-instance123.propr.dev:8443', + ' https://t-instance123.propr.dev', + 'https://t-instance123.propr.dev ', + 'https://t-instance123.propr.dev/', + 'https://t-instance123.propr.dev//', + 'HTTPS://t-instance123.propr.dev', + 'https://T-instance123.propr.dev', + 'https://t-%69nstance123.propr.dev', + 'https://t-instance123.propr%2edev', + 'https://x.t-instance123.propr.dev', + 'https://nested.t-instance123.propr.dev', + 'http://localhost.:3000', + 'http://127.1:3000', + 'http://0177.0.0.1:3000', + 'http://0x7f000001:3000', + 'http://[::ffff:127.0.0.1]:3000', + ]) { + assert.throws(() => normalizeApiBaseUrl(value), ProprClientError); + } + }); + + it('classifies only the canonical hosted ProPR Connect origin as verified', () => { + assert.deepEqual(classifyApiBaseUrl('https://t-instance-123.propr.dev'), { + baseUrl: 'https://t-instance-123.propr.dev', + kind: 'propr-connect', + connectInstanceId: 'instance-123', + }); + assert.equal(classifyApiBaseUrl('http://127.0.0.1:4000').kind, 'loopback'); + assert.equal(classifyApiBaseUrl('https://propr.example.com').kind, 'remote'); + + for (const rejectedReserved of [ + 'https://t-instance-123.foo.propr.dev', + 'https://x.t-instance-123.propr.dev', + 'https://t-\u0430bc.propr.dev', + ]) { + assert.throws(() => classifyApiBaseUrl(rejectedReserved), (error: unknown) => + error instanceof ProprClientError + && error.code === 'INVALID_API_BASE_URL' + && !error.message.includes(rejectedReserved)); + } + + for (const lookalike of [ + 'https://t-instance-123.propr.dev.example.com', + 'https://t-abc.pr\u03bfpr.dev', + ]) { + assert.notEqual(classifyApiBaseUrl(lookalike).kind, 'propr-connect', lookalike); + } + }); + + it('bounds malformed configuration and reports only a fixed safe code and message', () => { + const unsafeValues = [ + 'https://user:password-sentinel@t-instance123.propr.dev', + 'https://t-instance123.propr.dev?token=query-token-sentinel', + `https://example.com/${'private-path-sentinel'.repeat(200)}`, + ]; + for (const value of unsafeValues) { + assert.throws(() => normalizeApiBaseUrl(value), (error: unknown) => { + assert.ok(error instanceof ProprClientError); + assert.equal(error.code, 'INVALID_API_BASE_URL'); + assert.equal(error.message, 'The configured ProPR API URL is invalid.'); + assert.doesNotMatch(JSON.stringify(error), /password-sentinel|query-token-sentinel|private-path-sentinel/); + return true; + }); + } + }); +}); + +describe('ProprClient REST transport', () => { + it('routes Connect status and REST calls directly to the verified origin', async () => { + const calls: string[] = []; + const client = new ProprClient({ + baseUrl: 'https://t-instance123.propr.dev', + authentication: { type: 'none' }, + fetch: async input => { + calls.push(input.toString()); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await client.request('/api/status'); + await client.request('/api/tasks'); + assert.deepEqual(calls, [ + 'https://t-instance123.propr.dev/api/status', + 'https://t-instance123.propr.dev/api/tasks', + ]); + }); + + it('adds a fresh bearer token without exposing it in the endpoint', async () => { + const calls: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + const client = new ProprClient({ + baseUrl: 'https://propr.example.com', + authentication: { type: 'bearer', getAccessToken: () => 'secret-token' }, + fetch: async (input, init) => { + calls.push([input, init]); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await client.request('/api/status', { credentials: 'include' }); + + assert.equal(calls[0][0], 'https://propr.example.com/api/status'); + assert.equal(new Headers(calls[0][1]?.headers).get('Authorization'), 'Bearer secret-token'); + assert.equal(calls[0][1]?.credentials, 'omit'); + assert.doesNotMatch(String(calls[0][0]), /secret-token/); + }); + + it('uses cookies for session authentication', async () => { + let captured: RequestInit | undefined; + const client = new ProprClient({ + authentication: { type: 'session' }, + fetch: async (_input, init) => { + captured = init; + return new Response(null, { status: 204 }); + }, + }); + + await client.request('/api/status'); + assert.equal(captured?.credentials, 'include'); + }); + + it('returns structured HTTP errors without changing the backend body', async () => { + const body = { code: 'NOT_ALLOWED', message: 'No access' }; + const client = new ProprClient({ + fetch: async () => new Response(JSON.stringify(body), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }); + + await assert.rejects(client.request('/api/admin'), (error: unknown) => { + assert.ok(error instanceof ProprClientError); + assert.equal(error.kind, 'http'); + assert.equal(error.status, 403); + assert.equal(error.code, 'NOT_ALLOWED'); + assert.deepEqual(error.body, body); + return true; + }); + }); + + it('distinguishes cancellation from a client timeout', async () => { + const abortingFetch: typeof fetch = async (_input, init) => new Promise((_resolve, reject) => { + const rejectAborted = () => reject(new DOMException('Aborted', 'AbortError')); + if (init?.signal?.aborted) rejectAborted(); + else init?.signal?.addEventListener('abort', rejectAborted); + }); + const client = new ProprClient({ fetch: abortingFetch }); + + await assert.rejects( + client.fetch('/api/slow', {}, { timeoutMs: 1 }), + (error: unknown) => error instanceof ProprClientError && error.kind === 'timeout' + ); + + const controller = new AbortController(); + const cancelled = client.fetch('/api/slow', { signal: controller.signal }); + controller.abort(); + await assert.rejects( + cancelled, + (error: unknown) => error instanceof ProprClientError && error.kind === 'aborted' + ); + }); +}); + +describe('Propr compatibility negotiation', () => { + it('reports an API compatibility mismatch', async () => { + const client = new ProprClient({ + fetch: async () => new Response(JSON.stringify({ + version: '99.0.0', + apiCompatibility: '9999-12-31', + uiCompatibility: PROPR_API_COMPATIBILITY, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + + const result = await client.negotiateCompatibility(); + assert.equal(result.compatible, false); + if (!result.compatible) assert.equal(result.reason, 'too_new'); + await assert.rejects( + client.requireCompatibility(), + (error: unknown) => error instanceof ProprClientError + && error.kind === 'compatibility' + && error.code === 'too_new' + ); + }); + + it('rejects malformed compatibility metadata as a structured response error', async () => { + const client = new ProprClient({ + fetch: async () => new Response(JSON.stringify({ apiCompatibility: 42 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + }); + + await assert.rejects( + client.negotiateCompatibility(), + (error: unknown) => error instanceof ProprClientError && error.kind === 'invalid_response' + ); + }); +}); diff --git a/packages/client/test/connectPairing.test.ts b/packages/client/test/connectPairing.test.ts new file mode 100644 index 000000000..53a41401a --- /dev/null +++ b/packages/client/test/connectPairing.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { normalizeDesktopPairingApprovalUrl } from '@propr/shared'; + +const pairingId = 'dpr_ABCDEFGHIJKLMNOPQRSTUV'; +const apiBaseUrl = 'https://t-instance123.propr.dev'; + +describe('ProPR Connect desktop pairing approval URLs', () => { + it('accepts the API-returned hosted approval and exact tunnel browser fallback', () => { + assert.equal(normalizeDesktopPairingApprovalUrl({ + apiBaseUrl, + pairingId, + approvalUrl: `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.propr.dev`, + }), `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.propr.dev`); + + assert.equal(normalizeDesktopPairingApprovalUrl({ + apiBaseUrl, + pairingId, + approvalUrl: `${apiBaseUrl}/api/desktop/pairings/${pairingId}/browser`, + }), `${apiBaseUrl}/api/desktop/pairings/${pairingId}/browser`); + }); + + it('rejects synthesized, cross-origin, private, and secret-bearing approval URLs', () => { + for (const approvalUrl of [ + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}`, + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-other.propr.dev`, + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.propr.dev&token=secret`, + `https://app.propr.dev/desktop/pairing?pairing_id=dpr_1234567890123456789012&tunnel=t-instance123.propr.dev`, + `https://evil.example/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.propr.dev`, + `${apiBaseUrl}/api/desktop/pairings/${pairingId}/approval`, + `${apiBaseUrl}/api/desktop/pairings/${pairingId}/browser?device_secret=secret`, + `https://user:secret@t-instance123.propr.dev/api/desktop/pairings/${pairingId}/browser`, + `https://t-%69nstance123.propr.dev/api/desktop/pairings/${pairingId}/browser`, + `https://t-instance123.propr.dev:443/api/desktop/pairings/${pairingId}/browser`, + ]) { + assert.equal(normalizeDesktopPairingApprovalUrl({ apiBaseUrl, pairingId, approvalUrl }), null, approvalUrl); + } + }); + + it('matches the hosted UI raw query contract for approval parameters', () => { + for (const approvalUrl of [ + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t%2Dinstance123.propr.dev`, + `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&%74unnel=t-instance123.propr.dev`, + `https://app.propr.dev/desktop/pairing?pairing%5Fid=${pairingId}&tunnel=t-instance123.propr.dev`, + `https://app.propr.dev/desktop/pairing?pairing_id=dpr%5FABCDEFGHIJKLMNOPQRSTUV&tunnel=t-instance123.propr.dev`, + ]) { + assert.equal(normalizeDesktopPairingApprovalUrl({ apiBaseUrl, pairingId, approvalUrl }), null, approvalUrl); + } + }); + + it('requires a normalized, validated API origin', () => { + const approvalUrl = `${apiBaseUrl}/api/desktop/pairings/${pairingId}/browser`; + for (const untrustedBase of [ + `${apiBaseUrl}/`, + 'https://t-instance123.propr.dev:443', + 'https://t-%69nstance123.propr.dev', + 'http://remote.example.com', + ]) { + assert.equal(normalizeDesktopPairingApprovalUrl({ + apiBaseUrl: untrustedBase, + pairingId, + approvalUrl, + }), null); + } + }); + + it('does not grant the hosted approval contract to Connect lookalikes', () => { + assert.equal(normalizeDesktopPairingApprovalUrl({ + apiBaseUrl: 'https://t-instance123.foo.propr.dev', + pairingId, + approvalUrl: `https://app.propr.dev/desktop/pairing?pairing_id=${pairingId}&tunnel=t-instance123.foo.propr.dev`, + }), null); + }); + + it('rejects every noncanonical reserved-host base before generic HTTPS fallback', () => { + for (const untrustedBase of [ + 'https://t-instance123.propr.dev:443', + 'https://t-instance123.propr.dev:8443', + 'https://user:secret@t-instance123.propr.dev', + 'https://t-%69nstance123.propr.dev', + 'https://t-instance123.propr.dev.', + 'https://t-instance123.foo.propr.dev', + 'http://localhost.:4000', + 'http://api.dev.localhost.:4000', + ]) { + assert.equal(normalizeDesktopPairingApprovalUrl({ + apiBaseUrl: untrustedBase, + pairingId, + approvalUrl: `${untrustedBase}/api/desktop/pairings/${pairingId}/browser`, + }), null, untrustedBase); + } + }); + + it('preserves unrelated HTTPS remotes, outside lookalikes, and loopback HTTP', () => { + for (const baseUrl of [ + 'https://remote.example.com', + 'https://t-instance123.propr.dev.example.com', + 'http://127.0.0.1:4000', + 'http://localhost:4000', + 'http://api.dev.localhost:4000', + ]) { + const approvalUrl = `${baseUrl}/api/desktop/pairings/${pairingId}/browser`; + assert.equal(normalizeDesktopPairingApprovalUrl({ apiBaseUrl: baseUrl, pairingId, approvalUrl }), approvalUrl); + } + }); +}); diff --git a/packages/client/test/desktopPairing.test.ts b/packages/client/test/desktopPairing.test.ts new file mode 100644 index 000000000..cdd59a678 --- /dev/null +++ b/packages/client/test/desktopPairing.test.ts @@ -0,0 +1,739 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClient, + ProprClientError, +} from '../src/index.js'; + +const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const discovery = { + schemaVersion: 1 as const, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2 as const, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; +const protocolNow = Date.parse('2026-01-01T00:00:00.000Z'); +const protocolDeadline = new Date(protocolNow + 10 * 60 * 1000).toISOString(); +const binding = { + instanceId: 'profile-a', + origin: 'https://propr.example.test', + scope: 'desktop-instance' as const, + credentialGeneration: 'G'.repeat(22), +}; +const bounded = (promise: Promise, milliseconds = 1_000): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Pairing did not settle within the test timeout')), milliseconds); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +}; + +class PairingClock { + #now = 0; + #nextId = 1; + readonly #timers = new Map void }>(); + + readonly source = { + now: (): number => this.#now, + setTimeout: (callback: () => void, milliseconds: number): ReturnType => { + const id = this.#nextId++; + this.#timers.set(id, { at: this.#now + milliseconds, callback }); + return id as unknown as ReturnType; + }, + clearTimeout: (timer: ReturnType): void => { + this.#timers.delete(timer as unknown as number); + }, + }; + + async advanceAfterSchedulerDelay(milliseconds: number): Promise { + this.#now += milliseconds; + while (true) { + const due = [...this.#timers.entries()] + .filter(([, timer]) => timer.at <= this.#now) + .sort(([leftId, left], [rightId, right]) => left.at - right.at || leftId - rightId)[0]; + if (!due) break; + this.#timers.delete(due[0]); + due[1].callback(); + await Promise.resolve(); + await Promise.resolve(); + } + } +} + +describe('desktop instance protocol', () => { + it('strictly classifies only the credential-free public discovery 401', async () => { + const legacy = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (_input, init) => { + assert.equal(init?.credentials, 'omit'); + assert.equal(init?.redirect, 'manual'); + return new Response('{ "error": "Unauthorized" }', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + await assert.rejects(legacy.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.status === 401 + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED); + + const oversized = `{"error":"Unauthorized","padding":"${'x'.repeat(8 * 1024)}"}`; + const invalidResponses = [ + new Response(null, { status: 401, headers: { 'Content-Type': 'application/json' } }), + new Response('

Policy login required

', { + status: 401, headers: { 'Content-Type': 'text/html' }, + }), + new Response('{"error":', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized","error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized","code":"PROXY_POLICY"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"code":"AUTHENTICATION_REQUIRED"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"private proxy policy detail"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json', 'Content-Length': '8193' }, + }), + new Response(oversized, { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d]), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json', 'Content-Length': '1' }, + }), + new Response('{"error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/problem+json' }, + }), + ]; + const redirected = new Response('{"error":"Unauthorized"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + Object.defineProperty(redirected, 'redirected', { value: true }); + invalidResponses.push(redirected); + + for (const response of invalidResponses) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => response, + }); + await assert.rejects(client.discoverDesktop(), (error: unknown) => { + assert.ok(error instanceof ProprClientError); + assert.equal(error.kind, 'invalid_response'); + assert.equal(error.status, 401); + assert.equal(error.code, undefined); + assert.equal(error.body, undefined); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /private proxy policy detail|Unauthorized/u); + return true; + }); + } + + const operational = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => json({ code: 'AUTHENTICATION_REQUIRED' }, 401), + }); + await assert.rejects(operational.request('/api/tasks'), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'http' + && error.status === 401 + && error.code === 'AUTHENTICATION_REQUIRED'); + + }); + + it('uses the shared strict wire parser for missing, extra, malformed, duplicate, and oversized discovery', async () => { + const valid = JSON.stringify(discovery); + const invalidBodies = [ + JSON.stringify((({ publicInstanceIdentity: _omitted, ...rest }) => rest)(discovery)), + JSON.stringify({ ...discovery, account: 'must-not-be-present' }), + '{', + valid.replace('"product":"ProPR"', '"product":"ProPR","product":"ProPR"'), + `${valid}${' '.repeat(8 * 1024)}`, + JSON.stringify({ ...discovery, publicInstanceIdentity: discovery.publicInstanceIdentity.toUpperCase() }), + JSON.stringify({ ...discovery, desktopAuthentication: { + ...discovery.desktopAuthentication, protocolVersion: 1, + } }), + ]; + for (const body of invalidBodies) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Response(body, { headers: { 'Content-Type': 'application/json' } }), + }); + await assert.rejects(client.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + } + }); + + it('bounds discovery headers and body with one deadline and preserves caller cancellation', async () => { + let headerSignal: AbortSignal | null = null; + let resolveLateTimeout!: (response: Response) => void; + const stalledHeaders = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (_input, init) => { + headerSignal = init?.signal ?? null; + return new Promise(resolve => { resolveLateTimeout = resolve; }); + }, + }); + await assert.rejects(bounded(stalledHeaders.discoverDesktop(20), 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'timeout'); + assert.equal(headerSignal?.aborted, true); + let timedOutBodyCancelled = 0; + resolveLateTimeout(new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([1])); }, + cancel() { timedOutBodyCancelled += 1; }, + }))); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(timedOutBodyCancelled, 1); + + let bodyCancelled = 0; + const stalledBody = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"error":"Unauthor')); + }, + cancel() { bodyCancelled += 1; }, + }), { status: 401, headers: { 'Content-Type': 'application/json' } }), + }); + await assert.rejects(bounded(stalledBody.discoverDesktop(20), 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'timeout'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(bodyCancelled, 1); + + const controller = new AbortController(); + let resolveLateCancellation!: (response: Response) => void; + const cancelled = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Promise(resolve => { resolveLateCancellation = resolve; }), + }).discoverDesktop(1_000, controller.signal); + controller.abort('caller cancelled'); + await assert.rejects(bounded(cancelled, 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + let abortedBodyCancelled = 0; + resolveLateCancellation(new Response(new ReadableStream({ + start(streamController) { streamController.enqueue(new Uint8Array([1])); }, + cancel() { abortedBodyCancelled += 1; }, + }))); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(abortedBodyCancelled, 1); + + const preAborted = new AbortController(); + preAborted.abort('already cancelled'); + let preAbortedRequests = 0; + await assert.rejects(new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => { + preAbortedRequests += 1; + return json(discovery); + }, + }).discoverDesktop(1_000, preAborted.signal), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + assert.equal(preAbortedRequests, 0); + + const synchronouslyCancelled = new AbortController(); + let synchronousBodyCancelled = 0; + const synchronousCancellation = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => { + synchronouslyCancelled.abort('cancelled during fetch'); + return new Response(new ReadableStream({ + start(streamController) { streamController.enqueue(new Uint8Array([1])); }, + cancel() { synchronousBodyCancelled += 1; }, + })); + }, + }).discoverDesktop(1_000, synchronouslyCancelled.signal); + await assert.rejects(synchronousCancellation, (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(synchronousBodyCancelled, 1); + }); + + it('discovers capabilities, opens approval, and polls to a single opaque token', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + let polls = 0; + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (input, init) => { + const url = input.toString(); + requests.push({ url, init }); + if (url.endsWith('/api/desktop/discovery')) return json(discovery); + if (url.endsWith('/api/desktop/pairings')) return json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: `https://propr.example.test/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`, + expiresAt: protocolDeadline, + interval: 2, + }, 201); + polls += 1; + return polls === 1 + ? json({ status: 'pending', interval: 3 }, 202) + : json({ + status: 'provisional', + token: `propr_it_${'C'.repeat(43)}`, + tokenType: 'Bearer', + activationTicket: 'T'.repeat(43), + activationExpiresAt: protocolDeadline, + ...binding, + }); + }, + }); + + const metadata = await client.discoverDesktop(); + assert.equal(metadata.compatibility.compatible, true); + assert.equal(metadata.desktopAuthentication.browserPairing, true); + + const opened: string[] = []; + const sleeps: number[] = []; + const complete = await client.pairDesktop('Test desktop', { + binding, + now: () => protocolNow, + sleep: async milliseconds => { sleeps.push(milliseconds); }, + onApprovalRequired: url => { opened.push(url); }, + }); + + assert.deepEqual(complete, { + token: `propr_it_${'C'.repeat(43)}`, + tokenType: 'Bearer', + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + activationTicket: 'T'.repeat(43), + activationExpiresAt: protocolDeadline, + ...binding, + }); + assert.deepEqual(opened, [`https://propr.example.test/api/desktop/pairings/dpr_${'A'.repeat(22)}/browser`]); + assert.deepEqual(sleeps, [2000, 3000]); + assert.equal(requests.every(request => !request.url.includes('B'.repeat(43))), true); + assert.equal(requests.filter(request => request.url.endsWith('/poll')).every(request => + String(request.init?.body).includes('B'.repeat(43))), true); + }); + + it('cancels and expires without another poll request', async () => { + const client = new ProprClient({ fetch: async () => { throw new Error('must not request'); } }); + const start = { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: '2026-01-01T00:00:00.000Z', + interval: 1, + }; + await assert.rejects( + // Importing through the client keeps the public helper covered separately + // from the start endpoint. + import('../src/index.js').then(({ completeDesktopPairing }) => completeDesktopPairing(client, start, { + now: () => Date.parse('2026-01-01T00:00:00.000Z'), + })), + (error: unknown) => error instanceof ProprClientError && error.code === 'PAIRING_EXPIRED', + ); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + import('../src/index.js').then(({ completeDesktopPairing }) => completeDesktopPairing(client, { + ...start, + expiresAt: protocolDeadline, + }, { signal: controller.signal })), + (error: unknown) => error instanceof ProprClientError && error.kind === 'aborted', + ); + }); + + it('expires while the approval callback is still pending and ignores its late completion', async () => { + const { completeDesktopPairing } = await import('../src/index.js'); + let finishApproval!: () => void; + let polls = 0; + const approvalStarted = new Promise(resolve => { finishApproval = resolve; }); + let completeApproval!: () => void; + const client = new ProprClient({ fetch: async () => { + polls += 1; + throw new Error('must not poll after approval expiry'); + } }); + const pairing = completeDesktopPairing(client, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: new Date(Date.now() + 50).toISOString(), + interval: 1, + }, { + onApprovalRequired: () => new Promise(resolve => { + completeApproval = resolve; + finishApproval(); + }), + }); + + await approvalStarted; + await assert.rejects(bounded(pairing), (error: unknown) => + error instanceof ProprClientError && error.code === 'PAIRING_EXPIRED'); + completeApproval(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(polls, 0); + }); + + it('aborts while the approval callback is pending and handles a late callback rejection', async () => { + const { completeDesktopPairing } = await import('../src/index.js'); + const controller = new AbortController(); + let approvalStarted!: () => void; + const started = new Promise(resolve => { approvalStarted = resolve; }); + let rejectApproval!: (error: Error) => void; + const client = new ProprClient({ fetch: async () => { throw new Error('must not poll'); } }); + const pairing = completeDesktopPairing(client, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + interval: 1, + }, { + signal: controller.signal, + onApprovalRequired: () => new Promise((_resolve, reject) => { + rejectApproval = reject; + approvalStarted(); + }), + }); + + await started; + controller.abort('test cancellation'); + await assert.rejects(bounded(pairing), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + rejectApproval(new Error('late approval failure')); + await new Promise(resolve => setImmediate(resolve)); + }); + + it('rejects an unsafe approval URL', async () => { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async () => json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'http://remote.example.test/approve', + expiresAt: protocolDeadline, + interval: 2, + }, 201), + }); + await assert.rejects(client.startDesktopPairing('Desktop', { now: () => protocolNow }), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + }); + + it('enforces the browser request origin for same-origin pairing clients', async () => { + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location'); + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://propr.example.test' }, + }); + try { + for (const [approvalUrl, accepted] of [ + ['https://propr.example.test/approve', true], + ['https://attacker.example.test/approve', false], + ] as const) { + const client = new ProprClient({ + fetch: async () => json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl, + expiresAt: protocolDeadline, + interval: 2, + }, 201), + }); + if (accepted) { + await assert.doesNotReject(client.startDesktopPairing('Desktop', { now: () => protocolNow })); + } else { + await assert.rejects( + client.startDesktopPairing('Desktop', { now: () => protocolNow }), + (error: unknown) => error instanceof ProprClientError && error.kind === 'invalid_response', + ); + } + } + } finally { + if (locationDescriptor) Object.defineProperty(globalThis, 'location', locationDescriptor); + else Reflect.deleteProperty(globalThis, 'location'); + } + }); + + it('fails closed before pairing when a same-origin request has no browser origin', async () => { + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location'); + Reflect.deleteProperty(globalThis, 'location'); + let requests = 0; + try { + const client = new ProprClient({ fetch: async () => { + requests += 1; + throw new Error('must not request without a trusted origin'); + } }); + await assert.rejects( + client.startDesktopPairing('Desktop', { now: () => protocolNow }), + (error: unknown) => error instanceof ProprClientError && error.kind === 'configuration', + ); + assert.equal(requests, 0); + } finally { + if (locationDescriptor) Object.defineProperty(globalThis, 'location', locationDescriptor); + } + }); + + it('rejects cross-origin, credentialed, malformed, and invalid-deadline approval responses', async () => { + for (const override of [ + { approvalUrl: 'https://attacker.example.test/approve' }, + { approvalUrl: 'https://user:secret@propr.example.test/approve' }, + { approvalUrl: 'not a URL' }, + { expiresAt: 'not a deadline' }, + ]) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async () => json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: protocolDeadline, + interval: 2, + ...override, + }, 201), + }); + await assert.rejects(client.startDesktopPairing('Desktop', { now: () => protocolNow }), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + } + }); + + it('cancels while the pairing start request is in flight', async () => { + const controller = new AbortController(); + let started!: () => void; + const requestStarted = new Promise(resolve => { started = resolve; }); + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async (_input, init) => new Promise((_resolve, reject) => { + started(); + init?.signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true }); + }), + }); + + const pairing = client.pairDesktop('Desktop', { signal: controller.signal }); + await requestStarted; + controller.abort(); + await assert.rejects(pairing, (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + }); + + it('aborts a hung poll at the advertised deadline and reports expiry', async () => { + const expiresAt = new Date(protocolNow + 40).toISOString(); + const sleeps: number[] = []; + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/pairings')) return json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt, + interval: 1, + }, 201); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('expired', 'AbortError')), { once: true }); + }); + }, + }); + + await assert.rejects(client.pairDesktop('Desktop', { + now: () => protocolNow, + sleep: async milliseconds => { sleeps.push(milliseconds); }, + }), (error: unknown) => + error instanceof ProprClientError && error.code === 'PAIRING_EXPIRED'); + assert.deepEqual(sleeps, [40]); + }); + + for (const lateSettlement of ['microtask', 'next-task'] as const) { + it(`expires when a scheduler-delayed token response settles in the ${lateSettlement}`, async () => { + const { completeDesktopPairing } = await import('../src/index.js'); + const pairingClock = new PairingClock(); + const transportClock = new PairingClock(); + const expiresAt = new Date(protocolNow + 40).toISOString(); + let lateResponseResolved = false; + let pollStarted!: () => void; + const polling = new Promise(resolve => { pollStarted = resolve; }); + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + pairingProtocol: { clock: transportClock.source }, + fetch: async (_input, init) => new Promise(resolve => { + pollStarted(); + init?.signal?.addEventListener('abort', () => { + const settle = () => { + lateResponseResolved = true; + resolve(json({ + status: 'provisional', + token: `propr_it_${'C'.repeat(43)}`, + tokenType: 'Bearer', + activationTicket: 'T'.repeat(43), + activationExpiresAt: protocolDeadline, + ...binding, + })); + }; + if (lateSettlement === 'microtask') queueMicrotask(settle); + else setImmediate(settle); + }, { once: true }); + }), + }); + + const pairing = completeDesktopPairing(client, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt, + interval: 1, + }, { + binding, + clock: pairingClock.source, + now: () => protocolNow + pairingClock.source.now(), + sleep: async () => undefined, + }); + + await polling; + // The transport scheduler reaches the shared boundary while the pairing + // scheduler remains stalled. This deterministically reproduces hosted + // load without relying on a real 40 ms timer race. + await transportClock.advanceAfterSchedulerDelay(75); + await assert.rejects(bounded(pairing), (error: unknown) => + error instanceof ProprClientError && error.code === 'PAIRING_EXPIRED'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(lateResponseResolved, true); + }); + } + + it('aborts an in-flight poll when the caller cancels', async () => { + const controller = new AbortController(); + let pollStarted!: () => void; + const polling = new Promise(resolve => { pollStarted = resolve; }); + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/pairings')) return json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: protocolDeadline, + interval: 1, + }, 201); + pollStarted(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('cancelled', 'AbortError')), + { once: true }, + ); + }); + }, + }); + + const pairing = client.pairDesktop('Desktop', { + signal: controller.signal, + now: () => protocolNow, + sleep: async () => undefined, + }); + await polling; + controller.abort(); + await assert.rejects(pairing, (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + }); + + it('rejects invalid start intervals and deadlines instead of scheduling them', async () => { + const invalidOverrides: Array> = [ + { interval: 0 }, + { interval: 0.5 }, + { interval: 61 }, + { interval: Number.MAX_VALUE }, + { interval: Number.NaN }, + { interval: Number.POSITIVE_INFINITY }, + { expiresAt: 'not a deadline' }, + { expiresAt: new Date(protocolNow).toISOString() }, + { expiresAt: new Date(protocolNow + 30 * 60 * 1000 + 1).toISOString() }, + ]; + for (const override of invalidOverrides) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + fetch: async () => json({ + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: protocolDeadline, + interval: 2, + ...override, + }, 201), + }); + await assert.rejects( + client.startDesktopPairing('Desktop', { now: () => protocolNow }), + (error: unknown) => error instanceof ProprClientError && error.kind === 'invalid_response', + ); + } + }); + + it('rejects invalid intervals returned by every pending response', async () => { + const { completeDesktopPairing } = await import('../src/index.js'); + const start = { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: protocolDeadline, + interval: 1, + }; + for (const interval of [0, 0.5, 61, Number.MAX_VALUE, Number.NaN, Number.POSITIVE_INFINITY]) { + const client = new ProprClient({ + fetch: async () => json({ status: 'pending', interval }, 202), + }); + await assert.rejects(completeDesktopPairing(client, start, { + now: () => protocolNow, + sleep: async () => undefined, + }), (error: unknown) => error instanceof ProprClientError && error.kind === 'invalid_response'); + } + }); + + it('clamps a valid polling interval to the remaining advertised deadline', async () => { + const { completeDesktopPairing } = await import('../src/index.js'); + let now = protocolNow; + const sleeps: number[] = []; + const client = new ProprClient({ fetch: async () => { throw new Error('must not poll after deadline'); } }); + await assert.rejects(completeDesktopPairing(client, { + pairingId: `dpr_${'A'.repeat(22)}`, + deviceSecret: 'B'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + expiresAt: new Date(protocolNow + 500).toISOString(), + interval: 60, + }, { + now: () => now, + sleep: async milliseconds => { + sleeps.push(milliseconds); + now += milliseconds; + }, + }), (error: unknown) => error instanceof ProprClientError && error.code === 'PAIRING_EXPIRED'); + assert.deepEqual(sleeps, [500]); + }); +}); diff --git a/packages/client/test/pairingContentEncoding.test.ts b/packages/client/test/pairingContentEncoding.test.ts new file mode 100644 index 000000000..0f7bac517 --- /dev/null +++ b/packages/client/test/pairingContentEncoding.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { brotliCompressSync, gzipSync } from 'node:zlib'; +import { afterEach, describe, it } from 'node:test'; +import { ProprClientError } from '../src/index.js'; +import { requestPairingProtocol } from '../src/pairingProtocol.js'; + +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }))); +}); + +const jsonBytes = (byteLength: number): Buffer => { + const prefix = '{"value":"'; + const suffix = '"}'; + const padding = byteLength - Buffer.byteLength(prefix) - Buffer.byteLength(suffix); + assert.ok(padding >= 0); + const result = Buffer.from(`${prefix}${'A'.repeat(padding)}${suffix}`); + assert.equal(result.byteLength, byteLength); + return result; +}; + +type Encoding = 'identity' | 'gzip' | 'br'; + +const encode = (body: Buffer, encoding: Encoding): Buffer => { + if (encoding === 'gzip') return gzipSync(body); + if (encoding === 'br') return brotliCompressSync(body); + return body; +}; + +const listen = async ( + fixtures: Record, +): Promise => { + const server = createServer((request, response) => { + const fixture = fixtures[request.url ?? '']; + if (!fixture) { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Encoding': fixture.encoding, + 'Content-Length': String(fixture.body.byteLength), + }); + response.end(fixture.body); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +}; + +const request = ( + target: string, + fetchImplementation: typeof globalThis.fetch = globalThis.fetch, +): Promise => requestPairingProtocol(fetchImplementation, target, { method: 'POST' }); + +const invalidResponse = (error: unknown): boolean => + error instanceof ProprClientError && error.kind === 'invalid_response'; + +describe('pairing response Content-Encoding', () => { + it('accepts deterministic identity, gzip, and Brotli proxy responses at the decoded limit', async () => { + const decoded = jsonBytes(4_096); + const fixtures = Object.fromEntries( + (['identity', 'gzip', 'br'] as const).map(encoding => { + const body = encode(decoded, encoding); + if (encoding !== 'identity') assert.notEqual(body.byteLength, decoded.byteLength); + return [`/${encoding}`, { body, encoding }]; + }), + ); + const origin = await listen(fixtures); + const expected = JSON.parse(decoded.toString('utf8')) as unknown; + + for (const encoding of ['identity', 'gzip', 'br'] as const) { + assert.deepEqual(await request(`${origin}/${encoding}`), expected); + } + }); + + it('enforces the decoded 4 KiB cap for identity, gzip, and Brotli proxy responses', async () => { + const decoded = jsonBytes(4_097); + const fixtures = Object.fromEntries( + (['identity', 'gzip', 'br'] as const).map(encoding => [ + `/${encoding}`, + { body: encode(decoded, encoding), encoding }, + ]), + ); + const origin = await listen(fixtures); + + for (const encoding of ['identity', 'gzip', 'br'] as const) { + await assert.rejects(request(`${origin}/${encoding}`), invalidResponse); + } + }); + + it('fails closed on truncated gzip and Brotli proxy responses without exposing decoder details', async () => { + const decoded = jsonBytes(128); + const gzip = encode(decoded, 'gzip'); + const br = encode(decoded, 'br'); + const origin = await listen({ + '/gzip': { body: gzip.subarray(0, Math.floor(gzip.byteLength / 2)), encoding: 'gzip' }, + '/br': { body: br.subarray(0, Math.floor(br.byteLength / 2)), encoding: 'br' }, + }); + + for (const encoding of ['gzip', 'br'] as const) { + await assert.rejects(request(`${origin}/${encoding}`), (error: unknown) => + error instanceof ProprClientError + && ['invalid_response', 'network'].includes(error.kind) + && !error.message.toLowerCase().includes('decompress')); + } + }); + + it('rejects duplicate, stacked, empty, and unsupported Content-Encoding metadata', async () => { + const body = jsonBytes(32); + const values = ['', 'gzip, gzip', 'gzip, br', 'deflate']; + + for (const value of values) { + await assert.rejects(request('https://propr.example.test/pair', async () => new Response(body, { + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': value, + 'Content-Length': String(body.byteLength), + }, + })), invalidResponse); + } + }); + + it('validates encoded Content-Length syntax without comparing it to decoded bytes', async () => { + const body = jsonBytes(32); + const response = (length: string): Response => new Response(body, { + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': length, + }, + }); + + assert.deepEqual( + await request('https://propr.example.test/pair', async () => response('17')), + JSON.parse(body.toString('utf8')), + ); + for (const length of ['', '01', '-1', '17, 17', '9007199254740992']) { + await assert.rejects( + request('https://propr.example.test/pair', async () => response(length)), + invalidResponse, + ); + } + }); + + it('rejects an encoded Content-Length above the wire limit', async () => { + const body = jsonBytes(32); + + await assert.rejects(request('https://propr.example.test/pair', async () => new Response(body, { + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': '4097', + }, + })), invalidResponse); + }); +}); diff --git a/packages/client/test/pairingTransport.test.ts b/packages/client/test/pairingTransport.test.ts new file mode 100644 index 000000000..7e7257797 --- /dev/null +++ b/packages/client/test/pairingTransport.test.ts @@ -0,0 +1,552 @@ +import assert from 'node:assert/strict'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, it } from 'node:test'; +import { completeDesktopPairing, ProprClient, ProprClientError } from '../src/index.js'; +import { requestPairingProtocol, type PairingProtocolRequestOptions } from '../src/pairingProtocol.js'; + +const protocolNow = Date.parse('2026-01-01T00:00:00.000Z'); +const deadline = new Date(protocolNow + 60_000).toISOString(); +const pairingId = `dpr_${'P'.repeat(22)}`; +const deviceSecret = 'D'.repeat(43); +const activationTicket = 'A'.repeat(43); +const token = `propr_it_${'T'.repeat(43)}`; +const binding = { + instanceId: 'profile-transport', + origin: 'https://propr.example.test', + scope: 'desktop-instance' as const, + credentialGeneration: 'G'.repeat(22), +}; +const completedPairing = { + token, + tokenType: 'Bearer' as const, + pairingId, + deviceSecret, + activationTicket, + activationExpiresAt: deadline, + ...binding, +}; + +type EndpointName = 'start' | 'poll' | 'activate' | 'cancel'; + +const successBody = (endpoint: EndpointName, origin = binding.origin): Record => { + if (endpoint === 'start') return { + pairingId, + deviceSecret, + approvalUrl: `${origin}/api/desktop/pairings/${pairingId}/browser`, + expiresAt: deadline, + interval: 1, + }; + if (endpoint === 'poll') return { + status: 'provisional', + token, + tokenType: 'Bearer', + activationTicket, + activationExpiresAt: deadline, + ...binding, + origin, + }; + if (endpoint === 'activate') return { + status: 'active', + receipt: 'R'.repeat(22), + activatedAt: '2026-01-01T00:00:01.000Z', + expiresAt: null, + }; + return { status: 'cancelled', cancelledAt: '2026-01-01T00:00:01.000Z' }; +}; + +const jsonResponse = ( + value: unknown, + status = 200, + headers: Record = {}, +): Response => new Response(JSON.stringify(value), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, +}); + +const streamResponse = ( + chunks: Uint8Array[], + options: { status?: number; headers?: Record; error?: Error } = {}, +): Response => new Response(new ReadableStream({ + start(controller) { + chunks.forEach(chunk => controller.enqueue(chunk)); + if (options.error) controller.error(options.error); + else controller.close(); + }, +}), { + status: options.status ?? 200, + headers: { 'Content-Type': 'application/json', ...options.headers }, +}); + +const runEndpoint = async ( + endpoint: EndpointName, + fetchImplementation: typeof globalThis.fetch, + signal?: AbortSignal, + baseUrl = binding.origin, +): Promise => { + const client = new ProprClient({ + baseUrl, + authentication: { type: 'none' }, + fetch: fetchImplementation, + }); + if (endpoint === 'start') { + return client.startDesktopPairing('Transport test', { + signal, + now: () => protocolNow, + binding: { ...binding, origin: baseUrl }, + }); + } + const pairing = { ...completedPairing, origin: baseUrl }; + if (endpoint === 'activate') return client.activateDesktopPairing(pairing, signal); + if (endpoint === 'cancel') return client.cancelDesktopPairing(pairing, signal); + return completeDesktopPairing(client, { + pairingId, + deviceSecret, + approvalUrl: `${baseUrl}/approve`, + expiresAt: deadline, + interval: 1, + }, { + signal, + now: () => protocolNow, + sleep: async () => undefined, + binding: { ...binding, origin: baseUrl }, + }); +}; + +const bounded = async (promise: Promise, milliseconds = 1_000): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('transport operation did not settle')), milliseconds); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +}; + +class ProtocolClock { + #now = 0; + #nextId = 1; + readonly #timers = new Map void }>(); + + readonly source: NonNullable = { + now: () => this.#now, + setTimeout: (callback, milliseconds) => { + const id = this.#nextId++; + this.#timers.set(id, { at: this.#now + milliseconds, callback }); + return id as unknown as ReturnType; + }, + clearTimeout: timer => { this.#timers.delete(timer as unknown as number); }, + }; + + get now(): number { return this.#now; } + get pending(): number { return this.#timers.size; } + + async advance(milliseconds: number): Promise { + const target = this.#now + milliseconds; + while (true) { + const due = [...this.#timers.entries()] + .filter(([, timer]) => timer.at <= target) + .sort(([leftId, left], [rightId, right]) => left.at - right.at || leftId - rightId)[0]; + if (!due) break; + this.#now = due[1].at; + this.#timers.delete(due[0]); + due[1].callback(); + await Promise.resolve(); + await Promise.resolve(); + } + this.#now = target; + await Promise.resolve(); + await Promise.resolve(); + } +} + +const protocolRequest = ( + path: EndpointName, + fetchImplementation: typeof globalThis.fetch, + clock: ProtocolClock, + options: Omit = {}, +): Promise => requestPairingProtocol( + fetchImplementation, + `https://propr.example.test/${path}`, + { method: 'POST' }, + { ...options, clock: clock.source }, +); + +const timeoutKind = (error: unknown): boolean => + error instanceof ProprClientError && error.kind === 'timeout'; + +describe('bounded pairing protocol response transport', () => { + for (const endpoint of ['start', 'poll', 'activate', 'cancel'] as const) { + it(`${endpoint} accepts exact-limit and absent-length bodies but rejects deceptive Content-Length`, async () => { + const json = JSON.stringify(successBody(endpoint)); + const exact = new TextEncoder().encode(json + ' '.repeat(4_096 - Buffer.byteLength(json))); + assert.equal(exact.byteLength, 4_096); + await runEndpoint(endpoint, async () => streamResponse([ + exact.slice(0, 1), + exact.slice(1, 2_049), + exact.slice(2_049), + ])); + await assert.rejects(runEndpoint(endpoint, async () => streamResponse([ + new TextEncoder().encode(json), + ], { headers: { 'Content-Length': '1' } })), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + }); + + it(`${endpoint} cancels over-limit, stalled, malformed, errored, and late-extra bodies`, async () => { + const valid = JSON.stringify(successBody(endpoint)); + const over = new TextEncoder().encode(valid + ' '.repeat(4_097 - Buffer.byteLength(valid))); + let cancelled = 0; + const failures: Array<() => Promise> = [ + () => runEndpoint(endpoint, async () => streamResponse([over.slice(0, 4_096), over.slice(4_096)])), + () => runEndpoint(endpoint, async () => streamResponse([new Uint8Array([0xff])])), + () => runEndpoint(endpoint, async () => jsonResponse({ broken: true })), + () => runEndpoint(endpoint, async () => streamResponse([ + new TextEncoder().encode(valid), + new TextEncoder().encode('{"late":true}'), + ])), + () => runEndpoint(endpoint, async () => streamResponse([ + new TextEncoder().encode(valid.slice(0, 2)), + ], { error: new Error('private premature stream detail') })), + ]; + for (const failure of failures) { + await assert.rejects(bounded(failure()), (error: unknown) => + error instanceof ProprClientError + && ['invalid_response', 'network'].includes(error.kind) + && !error.message.includes('private')); + } + + const controller = new AbortController(); + let bodyStarted!: () => void; + const started = new Promise(resolve => { bodyStarted = resolve; }); + let streamCancelled = false; + const stalled = runEndpoint(endpoint, async () => new Response(new ReadableStream({ + start(streamController) { + setImmediate(() => { + if (!streamCancelled) streamController.enqueue(new TextEncoder().encode('{')); + bodyStarted(); + }); + }, + cancel() { streamCancelled = true; cancelled += 1; }, + }), { headers: { 'Content-Type': 'application/json' } }), controller.signal); + await started; + controller.abort('caller stopped operation'); + await assert.rejects(bounded(stalled), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + assert.equal(cancelled, 1); + }); + + it(`${endpoint} aborts a headers stall and redacts empty or HTML HTTP errors`, async () => { + const controller = new AbortController(); + let headerStarted!: () => void; + const started = new Promise(resolve => { headerStarted = resolve; }); + const stalled = runEndpoint(endpoint, async (_input, init) => new Promise((_resolve, reject) => { + headerStarted(); + init?.signal?.addEventListener('abort', () => reject(new DOMException('secret', 'AbortError')), { once: true }); + }), controller.signal); + await started; + controller.abort(); + await assert.rejects(bounded(stalled), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + + for (const response of [ + new Response(null, { status: 502 }), + new Response('

private upstream detail

', { + status: 502, + headers: { 'Content-Type': 'text/html' }, + }), + new Response('{', { status: 502, headers: { 'Content-Type': 'application/json' } }), + ]) { + await assert.rejects(runEndpoint(endpoint, async () => response), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'http' + && error.status === 502 + && error.code === undefined + && !error.message.includes('private')); + } + }); + } + + for (const endpoint of ['start', 'poll', 'activate', 'cancel'] as const) { + it(`${endpoint} enforces automatic header, body, slowloris, and overall deadlines`, async () => { + { + const clock = new ProtocolClock(); + let networkSignal: AbortSignal | undefined; + const operation = protocolRequest(endpoint, async (_input, init) => { + networkSignal = init?.signal ?? undefined; + return new Promise(() => undefined); + }, clock, { + overallTimeoutMs: 40, + deadlines: { headerMs: 10, bodyMs: 10, cancellationMs: 5 }, + }); + await clock.advance(9); + assert.equal(networkSignal?.aborted, false); + await clock.advance(1); + await assert.rejects(operation, timeoutKind); + assert.equal(networkSignal?.aborted, true); + assert.equal(clock.pending, 0); + } + + for (const firstChunk of [undefined, new Uint8Array([0x7b])]) { + const clock = new ProtocolClock(); + let networkSignal: AbortSignal | undefined; + let cancelled = 0; + const operation = protocolRequest(endpoint, async (_input, init) => { + networkSignal = init?.signal ?? undefined; + return new Response(new ReadableStream({ + start(controller) { if (firstChunk) controller.enqueue(firstChunk); }, + cancel() { cancelled += 1; }, + }), { headers: { 'Content-Type': 'application/json' } }); + }, clock, { + overallTimeoutMs: 40, + deadlines: { headerMs: 20, bodyMs: 10, cancellationMs: 5 }, + }); + await clock.advance(0); + await clock.advance(10); + await assert.rejects(operation, timeoutKind); + assert.equal(networkSignal?.aborted, true); + assert.equal(cancelled, 1); + assert.equal(clock.pending, 0); + } + + { + const clock = new ProtocolClock(); + let networkSignal: AbortSignal | undefined; + const operation = protocolRequest(endpoint, async (_input, init) => { + networkSignal = init?.signal ?? undefined; + return new Promise(() => undefined); + }, clock, { + overallTimeoutMs: 10, + deadlines: { headerMs: 20, bodyMs: 20, cancellationMs: 5 }, + }); + await clock.advance(10); + await assert.rejects(operation, timeoutKind); + assert.equal(networkSignal?.aborted, true); + assert.equal(clock.pending, 0); + } + }); + + it(`${endpoint} bounds never-settling reader cancellation and ignores every late callback`, async () => { + const clock = new ProtocolClock(); + let networkSignal: AbortSignal | undefined; + let cancelReject!: (error: Error) => void; + const cancellation = new Promise((_resolve, reject) => { cancelReject = reject; }); + let cancelCalls = 0; + const diagnostics: string[] = []; + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown): void => { unhandled.push(error); }; + process.on('unhandledRejection', onUnhandled); + try { + const operation = protocolRequest(endpoint, async (_input, init) => { + networkSignal = init?.signal ?? undefined; + return new Response(new ReadableStream({ + cancel() { + cancelCalls += 1; + return cancellation; + }, + }), { headers: { 'Content-Type': 'application/json' } }); + }, clock, { + overallTimeoutMs: 40, + deadlines: { headerMs: 20, bodyMs: 10, cancellationMs: 5 }, + reportDiagnostic: message => { diagnostics.push(message); }, + }); + await clock.advance(0); + await clock.advance(10); + assert.equal(clock.pending, 1); + await clock.advance(5); + await assert.rejects(operation, timeoutKind); + assert.equal(networkSignal?.aborted, true); + assert.equal(cancelCalls, 1); + assert.deepEqual(diagnostics, [ + 'ProPR pairing response cancellation exceeded its fixed deadline.', + ]); + assert.equal(clock.pending, 0); + + cancelReject(new Error('private late cancellation failure')); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(cancelCalls, 1); + assert.equal(clock.pending, 0); + assert.equal(diagnostics.length, 1); + assert.deepEqual(unhandled, []); + } finally { + process.removeListener('unhandledRejection', onUnhandled); + } + }); + } + + it('bounds a never-settling response.body.cancel before a reader exists', async () => { + const clock = new ProtocolClock(); + let networkSignal: AbortSignal | undefined; + let rejectCancellation!: (error: Error) => void; + const diagnostics: string[] = []; + const response = new Response(new ReadableStream({ + cancel() { + return new Promise((_resolve, reject) => { rejectCancellation = reject; }); + }, + }), { + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '4097', + }, + }); + const operation = protocolRequest('activate', async (_input, init) => { + networkSignal = init?.signal ?? undefined; + return response; + }, clock, { + overallTimeoutMs: 40, + deadlines: { headerMs: 20, bodyMs: 20, cancellationMs: 5 }, + reportDiagnostic: message => { diagnostics.push(message); }, + }); + await clock.advance(0); + assert.equal(clock.pending, 1); + await clock.advance(5); + await assert.rejects(operation, (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + assert.equal(networkSignal?.aborted, true); + assert.equal(clock.pending, 0); + assert.equal(diagnostics.length, 1); + rejectCancellation(new Error('private late body cancellation failure')); + await new Promise(resolve => setImmediate(resolve)); + }); + + it('makes exact header, body, overall, and cancellation boundaries terminal', async () => { + { + const clock = new ProtocolClock(); + let signal: AbortSignal | undefined; + const operation = protocolRequest('start', async (_input, init) => { + signal = init?.signal ?? undefined; + return new Promise(resolve => { + clock.source.setTimeout(() => resolve(jsonResponse(successBody('start'))), 10); + }); + }, clock, { + overallTimeoutMs: 40, + deadlines: { headerMs: 10, bodyMs: 20, cancellationMs: 5 }, + }); + await clock.advance(10); + await assert.rejects(operation, timeoutKind); + assert.equal(signal?.aborted, true); + assert.equal(clock.pending, 0); + } + + for (const overallWins of [false, true]) { + const clock = new ProtocolClock(); + let signal: AbortSignal | undefined; + let bodyController!: ReadableStreamDefaultController; + const operation = protocolRequest('activate', async (_input, init) => { + signal = init?.signal ?? undefined; + return new Response(new ReadableStream({ + start(controller) { bodyController = controller; }, + }), { headers: { 'Content-Type': 'application/json' } }); + }, clock, { + overallTimeoutMs: overallWins ? 10 : 40, + deadlines: { headerMs: 20, bodyMs: overallWins ? 20 : 10, cancellationMs: 5 }, + }); + await clock.advance(0); + clock.source.setTimeout(() => { + if (signal?.aborted) return; + bodyController.enqueue(new TextEncoder().encode(JSON.stringify(successBody('activate')))); + bodyController.close(); + }, 10); + await clock.advance(10); + await assert.rejects(operation, timeoutKind); + assert.equal(signal?.aborted, true); + assert.equal(clock.pending, 0); + } + + { + const clock = new ProtocolClock(); + const diagnostics: string[] = []; + const operation = protocolRequest('cancel', async () => new Response( + new ReadableStream({ cancel: () => new Promise(() => undefined) }), + { headers: { 'Content-Type': 'application/json' } }, + ), clock, { + overallTimeoutMs: 10, + deadlines: { headerMs: 20, bodyMs: 8, cancellationMs: 5 }, + reportDiagnostic: message => { diagnostics.push(message); }, + }); + await clock.advance(0); + await clock.advance(8); + assert.equal(clock.pending, 1); + await clock.advance(2); + await assert.rejects(operation, timeoutKind); + assert.equal(clock.now, 10); + assert.equal(clock.pending, 0); + assert.equal(diagnostics.length, 1); + } + }); +}); + +const servers: Server[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }))); +}); + +const listen = async (handler: Parameters[0]): Promise<{ server: Server; origin: string }> => { + const server = createServer(handler); + servers.push(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as AddressInfo; + return { server, origin: `http://127.0.0.1:${address.port}` }; +}; + +describe('pairing redirect fencing', () => { + it('never replays any pairing endpoint across origins on 307 or 308', async () => { + const received: string[] = []; + const receiver = await listen((request, response) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', chunk => { body += String(chunk); }); + request.on('end', () => { + received.push(`${request.url}\n${JSON.stringify(request.headers)}\n${body}`); + response.end(); + }); + }); + let redirectStatus = 307; + const source = await listen((_request, response) => { + response.writeHead(redirectStatus, { Location: `${receiver.origin}/captured` }); + response.end(); + }); + + for (redirectStatus of [307, 308]) { + for (const endpoint of ['start', 'poll', 'activate', 'cancel'] as const) { + await assert.rejects(runEndpoint(endpoint, globalThis.fetch, undefined, source.origin), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + } + } + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(received, []); + const receiverDump = received.join('\n'); + for (const material of [deviceSecret, pairingId, activationTicket, token, 'Bearer', binding.instanceId]) { + assert.equal(receiverDump.includes(material), false); + } + }); + + it('rejects absolute, relative, missing, and looping same-origin redirects without replay', async () => { + let requests = 0; + let location: string | undefined; + let origin = ''; + const source = await listen((_request, response) => { + requests += 1; + const headers = location === undefined ? {} : { Location: location }; + response.writeHead(307, headers); + response.end(); + }); + origin = source.origin; + + for (const nextLocation of [`${origin}/absolute`, '/relative', undefined, '/loop']) { + location = nextLocation; + const before = requests; + await assert.rejects(runEndpoint('activate', globalThis.fetch, undefined, origin), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + assert.equal(requests, before + 1); + } + }); +}); diff --git a/packages/client/test/socket.test.ts b/packages/client/test/socket.test.ts new file mode 100644 index 000000000..c970bc096 --- /dev/null +++ b/packages/client/test/socket.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildSocketConnection, normalizeApiBaseUrl } from '../src/index.js'; + +describe('Socket.IO connection configuration', () => { + it('uses same-origin session cookies and explicit reconnect defaults', () => { + const connection = buildSocketConnection( + normalizeApiBaseUrl(''), + { type: 'session' } + ); + + assert.equal(connection.url, undefined); + assert.equal(connection.options.withCredentials, true); + assert.equal(connection.options.path, '/socket.io/'); + assert.deepEqual(connection.options.transports, ['websocket']); + assert.equal(connection.options.reconnection, true); + assert.equal(connection.options.reconnectionAttempts, Infinity); + assert.equal(connection.options.reconnectionDelay, 1000); + assert.equal(connection.options.reconnectionDelayMax, 5000); + }); + + it('targets remote instances and resolves bearer auth for every connection attempt', async () => { + let token = 'first-token'; + const connection = buildSocketConnection( + normalizeApiBaseUrl('https://propr.example.com'), + { type: 'bearer', getAccessToken: () => token } + ); + + assert.equal(connection.url, 'https://propr.example.com'); + assert.equal(connection.options.withCredentials, false); + assert.equal(typeof connection.options.auth, 'function'); + + const resolveAuth = (): Promise => new Promise(resolve => { + (connection.options.auth as (callback: (data: unknown) => void) => void)(resolve); + }); + assert.deepEqual(await resolveAuth(), { token: 'first-token' }); + token = 'refreshed-token'; + assert.deepEqual(await resolveAuth(), { token: 'refreshed-token' }); + }); + + it('preserves handshake metadata while only the fresh provider can supply the bearer token', async () => { + let token = 'first-token'; + const connection = buildSocketConnection( + normalizeApiBaseUrl('https://propr.example.com'), + { type: 'bearer', getAccessToken: () => token }, + { auth: { proprDesktopTransportScope: 'scope-a', token: 'metadata-token' } } + ); + + const resolveAuth = (): Promise => new Promise(resolve => { + (connection.options.auth as (callback: (data: unknown) => void) => void)(resolve); + }); + assert.deepEqual(await resolveAuth(), { + proprDesktopTransportScope: 'scope-a', + token: 'first-token', + }); + token = 'refreshed-token'; + assert.deepEqual(await resolveAuth(), { + proprDesktopTransportScope: 'scope-a', + token: 'refreshed-token', + }); + }); + + it('routes Connect Socket.IO to the same origin and fixed proxy path', () => { + const connection = buildSocketConnection( + normalizeApiBaseUrl('https://t-instance123.propr.dev'), + { type: 'none' } + ); + + assert.equal(connection.url, 'https://t-instance123.propr.dev'); + assert.equal(connection.options.path, '/socket.io/'); + assert.equal(connection.options.reconnection, true); + assert.equal(connection.options.reconnectionAttempts, Infinity); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 000000000..a6189a037 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/core/src/db/migrations/20260829000000_create_desktop_auth.js b/packages/core/src/db/migrations/20260829000000_create_desktop_auth.js new file mode 100644 index 000000000..5b40337db --- /dev/null +++ b/packages/core/src/db/migrations/20260829000000_create_desktop_auth.js @@ -0,0 +1,67 @@ +/** + * Device pairing requests and opaque, instance-scoped API credentials. + * + * Pairing secrets and API tokens are deliberately represented only by their + * SHA-256 digests. The plaintext values exist only in the response that hands + * them to the desktop client. + */ +export async function up(knex) { + await knex.schema.createTable('desktop_pairing_requests', (table) => { + table.text('id').primary(); + table.text('device_secret_hash').notNullable(); + table.text('client_name').notNullable(); + table.text('status').notNullable().defaultTo('pending').checkIn(['pending', 'approved', 'consumed']); + table.text('approved_by_user_id').nullable(); + table.text('approved_by_username').nullable(); + table.text('approved_by_display_name').nullable(); + table.text('approved_by_email').nullable(); + table.text('approved_by_avatar_url').nullable(); + table.timestamp('created_at').notNullable(); + table.timestamp('expires_at').notNullable(); + table.timestamp('approved_at').nullable(); + table.timestamp('consumed_at').nullable(); + + table.index(['status', 'expires_at']); + }); + + await knex.schema.createTable('instance_api_tokens', (table) => { + table.text('id').primary(); + table.text('token_hash').notNullable().unique(); + table.text('token_hint').notNullable(); + table.text('name').notNullable(); + table.text('owner_github_user_id').notNullable(); + table.text('owner_github_username').notNullable(); + table.text('owner_display_name').notNullable(); + table.text('owner_email').nullable(); + table.text('owner_avatar_url').nullable(); + table.timestamp('created_at').notNullable(); + table.timestamp('last_used_at').nullable(); + table.timestamp('expires_at').nullable(); + table.timestamp('revoked_at').nullable(); + table.text('revoked_by_user_id').nullable(); + + table.index('owner_github_user_id'); + table.index(['revoked_at', 'expires_at']); + }); + + await knex.schema.createTable('desktop_auth_audit', (table) => { + table.increments('id').primary(); + table.text('action').notNullable(); + table.text('actor_github_user_id').nullable(); + table.text('actor_github_username').nullable(); + table.text('pairing_id').nullable(); + table.text('token_id').nullable(); + table.text('client_name').nullable(); + table.timestamp('created_at').notNullable(); + + table.index('created_at'); + table.index('actor_github_user_id'); + table.index('token_id'); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('desktop_auth_audit'); + await knex.schema.dropTableIfExists('instance_api_tokens'); + await knex.schema.dropTableIfExists('desktop_pairing_requests'); +} diff --git a/packages/core/src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js b/packages/core/src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js new file mode 100644 index 000000000..ec00d8358 --- /dev/null +++ b/packages/core/src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js @@ -0,0 +1,56 @@ +/** + * Make desktop credentials unusable until the desktop confirms that encrypted + * rollback material is durable. Existing active credentials remain active; + * only credentials issued by the new pairing protocol begin provisional. + */ +export async function up(knex) { + await knex.schema.alterTable('desktop_pairing_requests', (table) => { + table.text('requested_instance_id').nullable(); + table.text('requested_origin').nullable(); + table.text('requested_scope').nullable(); + table.text('credential_generation').nullable(); + table.text('provisional_token_id').nullable(); + table.text('activation_ticket_hash').nullable(); + table.text('activation_receipt').nullable(); + table.timestamp('activation_expires_at').nullable(); + table.timestamp('activated_at').nullable(); + table.timestamp('cancelled_at').nullable(); + }); + await knex.schema.alterTable('instance_api_tokens', (table) => { + table.text('activation_state').notNullable().defaultTo('active'); + table.text('pairing_id').nullable(); + table.text('bound_instance_id').nullable(); + table.text('bound_origin').nullable(); + table.text('bound_scope').nullable(); + table.text('credential_generation').nullable(); + table.index(['activation_state', 'expires_at']); + }); +} + +export async function down(knex) { + await knex.schema.alterTable('instance_api_tokens', (table) => { + table.dropIndex(['activation_state', 'expires_at']); + table.dropColumns( + 'activation_state', + 'pairing_id', + 'bound_instance_id', + 'bound_origin', + 'bound_scope', + 'credential_generation', + ); + }); + await knex.schema.alterTable('desktop_pairing_requests', (table) => { + table.dropColumns( + 'requested_instance_id', + 'requested_origin', + 'requested_scope', + 'credential_generation', + 'provisional_token_id', + 'activation_ticket_hash', + 'activation_receipt', + 'activation_expires_at', + 'activated_at', + 'cancelled_at', + ); + }); +} diff --git a/packages/core/test/desktopTwoPhaseAuthMigration.test.ts b/packages/core/test/desktopTwoPhaseAuthMigration.test.ts new file mode 100644 index 000000000..384673168 --- /dev/null +++ b/packages/core/test/desktopTwoPhaseAuthMigration.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import knex from 'knex'; +import { up as createDesktopAuth } from '../src/db/migrations/20260829000000_create_desktop_auth.js'; +import { + down as rollbackTwoPhaseDesktopAuth, + up as addTwoPhaseDesktopAuth, +} from '../src/db/migrations/20260830000000_add_two_phase_desktop_pairing.js'; + +test('adds two-phase state without changing existing active credentials and rolls it back', async () => { + const database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + try { + await createDesktopAuth(database); + await database('instance_api_tokens').insert({ + id: 'token-id', + token_hash: 'hash', + token_hint: 'hint', + name: 'Existing desktop', + owner_github_user_id: '1', + owner_github_username: 'owner', + owner_display_name: 'Owner', + created_at: '2026-08-30T00:00:00.000Z', + }); + + await addTwoPhaseDesktopAuth(database); + const migrated = await database('instance_api_tokens').where({ id: 'token-id' }).first(); + assert.equal(migrated.activation_state, 'active'); + assert.equal(migrated.pairing_id, null); + assert.equal(await database.schema.hasColumn('desktop_pairing_requests', 'activation_ticket_hash'), true); + + await rollbackTwoPhaseDesktopAuth(database); + assert.equal(await database.schema.hasColumn('instance_api_tokens', 'activation_state'), false); + assert.equal(await database.schema.hasColumn('desktop_pairing_requests', 'activation_ticket_hash'), false); + assert.notEqual(await database('instance_api_tokens').where({ id: 'token-id' }).first(), undefined); + } finally { + await database.destroy(); + } +}); diff --git a/packages/local-setup/package.json b/packages/local-setup/package.json new file mode 100644 index 000000000..0c480ac16 --- /dev/null +++ b/packages/local-setup/package.json @@ -0,0 +1,22 @@ +{ + "name": "@propr/local-setup", + "version": "0.8.15", + "description": "UI-agnostic local ProPR setup state machine", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "engines": { "node": ">=22" }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test": "npx tsx --test src/*.test.ts" + }, + "dependencies": { + "@propr/shared": "^0.8.15" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/local-setup/src/agents.ts b/packages/local-setup/src/agents.ts new file mode 100644 index 000000000..2f58936e2 --- /dev/null +++ b/packages/local-setup/src/agents.ts @@ -0,0 +1,231 @@ +/** + * Agent enablement + image-based authentication for local setup. + * + * This runs as a setup step *after the stack is up* (the backend must be + * reachable to read and write agent configuration). It does three things, each + * non-destructively: + * + * 1. Reads the agents already configured in the running backend. + * 2. Adds any *selected* agent whose type is not yet configured, seeding it + * from the shared {@link AGENT_DEFAULTS} metadata (alias + supported + * models). Existing agents are never disabled, deleted, or re-aliased — a + * re-run only fills in what is missing. + * 3. For selected agents that support an interactive image login (see + * {@link planAgentLogin}), offers to authenticate through the agent's + * Docker image and runs the login only for the ones the user confirms. + * + * Like the engine, this module is UI-agnostic: the side effects live behind the + * injectable {@link AgentSetupActions} seam (tests pass mocks so the flow runs + * without Docker, the network, or a TTY) and the single user decision is + * collected through the optional {@link AgentSetupParams.confirmLogin} callback + * (a missing callback means "authenticate nothing", the safe default). + */ + +import { AGENT_DEFAULTS, type AgentType } from "@propr/shared"; + +/** Minimal backend agent shape needed by the setup engine. */ +export interface AgentConfig { + type: AgentType; +} + +/** Portable add-agent request emitted by the engine. */ +export interface AddAgentOptions { + alias: string; + type: AgentType; + models: string[]; + enabled: boolean; +} + +/** Outcome of attempting to authenticate a single agent through its image. */ +export interface AgentLoginResult { + /** False when the agent has no usable image-login plan (nothing was run). */ + available: boolean; + /** True when an interactive login ran and exited successfully. */ + success: boolean; + /** Human-readable detail (error reason or status line). */ + detail?: string; +} + +export interface AgentConnectivityResult { + type: string; + status: "ok" | "failed" | "skipped"; + detail: string; +} + +/** + * The side effects the agent-setup step performs against the running stack. + * Hosts bind these operations to their backend and launcher. Tests can provide + * in-memory implementations without Docker or network access. + */ +export interface AgentSetupActions { + /** List the agents currently configured in the running backend. */ + listAgents(rootDir: string): Promise; + /** Add a new agent to the backend configuration. */ + addAgent(rootDir: string, options: AddAgentOptions): Promise; + /** Agent types that support an interactive image login (have a login plan). */ + loginableAgents(): Promise; + /** Authenticate one agent through its image; interactive (inherits stdio). */ + loginAgent(rootDir: string, type: string): Promise; + /** Run a live, image-only request that mirrors the worker credential mount. */ + validateAgents(rootDir: string, types: string[]): Promise; +} + +/** Inputs for {@link runAgentSetup}. */ +export interface AgentSetupParams { + rootDir: string; + /** Agent types the user selected earlier in the flow (pull/configure steps). */ + selectedAgents: string[]; + actions: AgentSetupActions; + /** + * Confirm which of the loginable candidates to authenticate now. Returns the + * subset to log in. Omitted (or returning an empty array) authenticates none. + */ + confirmLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; + onLog?(line: string): void; +} + +/** What the agent-setup step did, for the caller to render as a step status. */ +export interface AgentSetupOutcome { + /** Agent types newly added to the backend configuration. */ + added: string[]; + /** Selected agent types that were already configured (left untouched). */ + alreadyConfigured: string[]; + /** Agents that authenticated successfully through their image. */ + authenticated: string[]; + /** Agents the user chose to authenticate but whose login did not succeed. */ + authFailed: string[]; + /** Agents whose worker-image connectivity check returned a valid response. */ + validated: string[]; + /** Agents whose live image check failed or could not run. */ + validationFailed: string[]; + /** Exact recovery commands for agents that still need attention. */ + nextCommands: string[]; + /** Non-fatal problems encountered (surfaced as a warning by the caller). */ + errors: string[]; +} + +/** + * Enable the selected agents in the running backend and, on confirmation, + * authenticate the ones that support an image login. Never throws for expected + * conditions — every failure is captured in {@link AgentSetupOutcome.errors} so + * the caller can settle the step as a warning rather than aborting setup. + */ +export async function runAgentSetup(params: AgentSetupParams): Promise { + const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params; + const outcome: AgentSetupOutcome = { + added: [], + alreadyConfigured: [], + authenticated: [], + authFailed: [], + validated: [], + validationFailed: [], + nextCommands: [], + errors: [], + }; + + if (selectedAgents.length === 0) return outcome; + + // 1. Read the current backend configuration. Without it we cannot safely tell + // which agents are new, so a read failure stops here (nothing was changed). + let existing: AgentConfig[]; + try { + existing = await actions.listAgents(rootDir); + } catch (error) { + outcome.errors.push(`could not read backend agents: ${(error as Error).message}`); + return outcome; + } + + // 2. Add the selected agents that are not yet configured. Match by type so we + // never add a second agent for a type the user already runs — existing + // agents (enabled or not) are left exactly as they are. + const configuredTypes = new Set(existing.map((agent) => agent.type)); + for (const type of selectedAgents) { + if (configuredTypes.has(type as AgentType)) { + outcome.alreadyConfigured.push(type); + continue; + } + const defaults = AGENT_DEFAULTS[type as AgentType]; + if (!defaults) continue; // unknown type — guarded, but never trust the input + try { + onLog?.(`enabling agent ${type}…`); + // Seed from shared metadata: alias + the full supported-model set. The + // backend resolves the default docker image and host config path, so we + // don't pass them (a literal "~" path would otherwise reach the backend). + await actions.addAgent(rootDir, { + alias: defaults.defaultAlias, + type: type as AgentType, + models: defaults.defaultModels, + enabled: true, + }); + outcome.added.push(type); + configuredTypes.add(type as AgentType); + } catch (error) { + outcome.errors.push(`could not enable ${type}: ${(error as Error).message}`); + } + } + + // 3. Image-based authentication — only for selected agents that actually have + // a login plan, and only for the ones the user confirms. + let loginable: Set; + try { + loginable = new Set(await actions.loginableAgents()); + } catch (error) { + outcome.errors.push(`could not determine which agents support image login: ${(error as Error).message}`); + loginable = new Set(); + } + const candidates = selectedAgents.filter((type) => loginable.has(type)); + if (candidates.length > 0 && confirmLogin) { + let chosen: string[] = []; + try { + chosen = await confirmLogin({ candidates, rootDir }); + } catch (error) { + // A failed/cancelled prompt must not abort the whole run — validation and + // exact recovery commands are still useful. + outcome.errors.push(`agent login prompt failed: ${(error as Error).message}`); + } + const chosenSet = new Set(chosen.filter((type) => loginable.has(type))); + // Iterate the candidate order (not the user's), so logins run in a stable order. + for (const type of candidates) { + if (!chosenSet.has(type)) continue; + try { + onLog?.(`authenticating ${type} through its image…`); + const result = await actions.loginAgent(rootDir, type); + if (result.detail) onLog?.(result.detail); + if (result.available && result.success) outcome.authenticated.push(type); + else outcome.authFailed.push(type); + } catch (error) { + outcome.authFailed.push(type); + outcome.errors.push(`login for ${type} failed: ${(error as Error).message}`); + } + } + } + + // 4. Always validate the selected agents from the same image/mount shape the + // worker uses. This is one live call per agent (host calls are deliberately + // skipped), so setup catches a successful host login that was not mounted into + // Docker without doubling subscription usage. + try { + onLog?.(`checking agent connectivity through worker image${selectedAgents.length === 1 ? "" : "s"}…`); + const checks = await actions.validateAgents(rootDir, selectedAgents); + for (const check of checks) { + onLog?.(`${check.type}: ${check.detail}`); + if (check.status === "ok") { + outcome.validated.push(check.type); + continue; + } + outcome.validationFailed.push(check.type); + if (loginable.has(check.type)) outcome.nextCommands.push(`propr agent login ${check.type}`); + outcome.nextCommands.push(`propr check agents --agents ${check.type}`); + } + } catch (error) { + outcome.errors.push(`could not validate agent connectivity: ${(error as Error).message}`); + for (const type of selectedAgents) { + if (loginable.has(type)) outcome.nextCommands.push(`propr agent login ${type}`); + outcome.nextCommands.push(`propr check agents --agents ${type}`); + } + } + + outcome.nextCommands = Array.from(new Set(outcome.nextCommands)); + + return outcome; +} diff --git a/packages/local-setup/src/engine.test.ts b/packages/local-setup/src/engine.test.ts new file mode 100644 index 000000000..461e34b68 --- /dev/null +++ b/packages/local-setup/src/engine.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + getLocalSetupCapability, + retrySetup, + runSetup, + type SetupActions, + type SetupProgressEvent, +} from "./index.js"; + +const unusedActions = {} as SetupActions; + +test("platform capabilities support Linux and make macOS/Windows explicitly remote-only", () => { + assert.deepEqual(getLocalSetupCapability("linux"), { + supported: true, + kind: "local", + platform: "linux", + }); + for (const platform of ["darwin", "win32"] as const) { + const capability = getLocalSetupCapability(platform); + assert.equal(capability.supported, false); + assert.equal(capability.kind, "remote-only"); + assert.match(capability.reason, /remote ProPR deployment/); + } +}); + +for (const platform of ["darwin", "win32"] as const) { + test(`the setup engine remains platform-neutral on ${platform}`, async () => { + let checksRun = false; + const actions = { + runChecks: async () => { + checksRun = true; + return { + rootDir: "/stack", + anyFail: true, + results: [{ name: "Docker daemon", group: "Docker", status: "fail", detail: "not running" }], + }; + }, + } as unknown as SetupActions; + const result = await runSetup({ root: "/stack", platform, actions }); + + assert.equal(checksRun, true); + assert.equal(result.completed, false); + assert.equal(result.capability.kind, "remote-only"); + assert.notEqual(result.errors[0]?.code, "local-unsupported"); + }); +} + +test("an already-aborted run is cancelled before invoking host operations", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await runSetup({ root: "/stack", platform: "linux", actions: unusedActions, signal: controller.signal }); + + assert.equal(result.cancelled, true); + assert.equal(result.errors[0]?.code, "cancelled"); + assert.equal(result.completed, false); +}); + +test("cancellation between steps returns resumable state without starting the next host action", async () => { + const controller = new AbortController(); + let inspected = false; + const actions = { + runChecks: async () => ({ + rootDir: "/stack", + anyFail: false, + results: [{ name: "Docker daemon", group: "Docker", status: "ok", detail: "ready" }], + }), + inspectStackInit: () => { + inspected = true; + throw new Error("must not inspect after cancellation"); + }, + } as unknown as SetupActions; + const result = await runSetup({ + root: "/stack", + platform: "linux", + actions, + signal: controller.signal, + reporter: { + onStepSettled: (step) => { + if (step.id === "check") controller.abort(); + }, + }, + }); + + assert.equal(inspected, false); + assert.equal(result.cancelled, true); + assert.equal(result.state.steps.find((step) => step.id === "check")?.status, "done"); + assert.equal(result.state.steps.find((step) => step.id === "init-stack")?.status, "skipped"); +}); + +test("progress and structured errors redact values identified as secrets", async () => { + const events: SetupProgressEvent[] = []; + const actions = { + runChecks: async () => { throw new Error("token=very-secret-value"); }, + } as unknown as SetupActions; + const result = await runSetup({ + root: "/stack", + platform: "linux", + actions, + reporter: { onProgress: (event) => events.push(event) }, + }); + + const serialized = JSON.stringify({ events, errors: result.errors, state: result.state }); + assert.doesNotMatch(serialized, /very-secret-value/); + assert.match(serialized, /REDACTED/); + assert.equal(result.errors[0]?.code, "step-failed"); +}); + +test("retry preserves the previous root and re-evaluates platform capability", async () => { + const previous = await runSetup({ root: "/chosen/root", platform: "win32", actions: unusedActions }); + const retried = await retrySetup(previous, { platform: "darwin", actions: unusedActions }); + assert.equal(retried.rootDir, "/chosen/root"); + assert.equal(retried.capability.platform, "darwin"); +}); diff --git a/packages/local-setup/src/engine.ts b/packages/local-setup/src/engine.ts new file mode 100644 index 000000000..ac19ddf67 --- /dev/null +++ b/packages/local-setup/src/engine.ts @@ -0,0 +1,1519 @@ +/** + * Local setup engine. + * + * `propr setup` walks a new user from a bare host to a running local + * control-plane stack. It combines what `propr check` and `propr init stack` + * already do, then sequences the remaining one-time tasks — pulling images, + * recording agent credentials, choosing GitHub auth, starting the stack and + * validating its health, configuring the whitelist, optionally connecting a + * first repository, and surfacing the UI URL. + * + * The engine is intentionally UI-agnostic. It owns the *order* of the flow and + * the *decision logic* (what to run, what to skip, what is safe), but performs + * no rendering and prompts no user directly. Two seams keep it decoupled: + * + * - {@link SetupPrompts} — callback hooks a renderer supplies to collect user + * decisions (which agents, which auth mode, whether to add a repo, …). Every + * hook is optional; a missing hook falls back to a safe, non-interactive + * default (keep what exists, skip optional work). Ink and the readline + * fallback will provide these in later issues. + * - {@link SetupActions} — the side-effecting operations (run checks, scaffold, + * pull, start, health-probe, add repo). A host must inject them explicitly; + * tests use in-memory implementations without Docker, network, or a TTY. + * + * Safety contract (enforced here, not just by convention): + * - The stack is initialized only when `.env` is missing or the user picks a + * new root — an existing functional install is left intact on re-run. + * - `.env` is never overwritten wholesale; edits go through the non-destructive + * {@link applyEnvSelection} (per-key, never blanks an existing value). + * - No step deletes user data; a running stack is reused, not recreated. + * - Core images pull by default; the agent image pulls when an agent is selected. + */ + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, normalize, resolve } from "node:path"; +import { + resolveGithubEventIntakeMode, + validateIntakeModePrerequisites, + DEFAULT_PROPR_GH_RELAY_URL, + type GithubAuthMode, + type GithubAuthModeResult, +} from "@propr/shared"; +import { + buildIntakeEnvVars, + defaultIntakeChoice, + intakeModeLabel, + saveWhitelist, + type GithubIntakeDecision, + type GithubIntakeMode, +} from "./github.js"; +import { + runAgentSetup, + type AgentSetupActions, +} from "./agents.js"; +import { + createSetupState, + getStep, + isSetupComplete, + updateStep, + type EnvSelectionResult, + type DatastoreAdminInspection, + type StackInitState, +} from "./state.js"; +import type { SetupState, SetupStep, SetupStepId, SetupStepPatch } from "./types.js"; + +const DEFAULT_PROPR_GITHUB_APP_INSTALL_URL = "https://github.com/apps/propr-dev/installations/new"; + +/** Match the API's distinction between real OAuth credentials and example placeholders. */ +function isConfiguredOAuthValue(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return Boolean(normalized && !normalized.startsWith("your_") && normalized !== "changeme"); +} + +function isTruthyEnvFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; +} + +function normalizeServiceUrl(value: string | undefined): string | undefined { + try { + if (!value?.trim()) return undefined; + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + +function isSupportedLoopbackCallback(value: string | undefined): boolean { + try { + if (!value?.trim()) return false; + const url = new URL(value.trim()); + const hostname = url.hostname.toLowerCase(); + return ( + url.protocol === "http:" && + (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") && + url.username === "" && + url.password === "" && + url.pathname === "/api/auth/github/callback" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +/** + * Catalog of supported agents: the image each one needs and the host + * credential directories recorded into `.env` when it is selected. Mirrors + * `agentDescriptors()` in ../checkCommands.ts and `detectCredentials()` in + * ../initStack.ts — kept local so the engine has no rendering/command imports. + */ +interface AgentDescriptor { + type: string; + /** Unified agent manifest image key. */ + imageKey: string; + /** Host credential dirs mounted into the agent container. */ + credentials: { envKey: string; defaultDir: string }[]; +} + +/** Reject unsafe Docker bind sources before asking a host to create them. */ +function assertSafeAgentCredentialDir(path: string, name = "Agent credential path"): void { + if ( + !isAbsolute(path) || + normalize(path) === "/" || + path.includes(":") || + /[\u0000-\u001f\u007f-\u009f]/.test(path) + ) { + throw new Error(`${name} must be an absolute, non-root Linux path without ':' or control characters`); + } +} + +function agentCatalog(): AgentDescriptor[] { + const home = homedir(); + return [ + { type: "claude", imageKey: "agent", credentials: [{ envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude") }] }, + { type: "codex", imageKey: "agent", credentials: [{ envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex") }] }, + { type: "antigravity", imageKey: "agent", credentials: [{ envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini") }] }, + { + type: "opencode", + imageKey: "agent", + credentials: [ + { envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode") }, + { envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode") }, + ], + }, + { type: "vibe", imageKey: "agent", credentials: [{ envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe") }] }, + ]; +} + +/** Reject unsafe Docker bind sources before any recursive filesystem write. */ +/** Agent types whose default credential directory exists on this host. */ +function detectInstalledAgents(catalog: AgentDescriptor[]): string[] { + return catalog.filter((a) => a.credentials.some((c) => existsSync(c.defaultDir))).map((a) => a.type); +} + +// --------------------------------------------------------------------------- +// Decisions the renderer collects from the user. +// --------------------------------------------------------------------------- + +/** Where to put the stack, and whether to scaffold it. */ +export interface RootDecision { + /** Stack root to use (absolute). May differ from the resolved default. */ + rootDir: string; + /** + * Ensure this root is scaffolded, creating any *missing* `.env`/data/logs/repos + * pieces. Non-destructive: scaffolding runs without `force`, so an existing + * `.env` is always preserved — this fills in what is absent, it never resets a + * working install. (A root with a missing `.env` or sub-directory is scaffolded + * regardless of this flag; the flag only forces a scaffold pass on a root that + * already looks complete.) + */ + reinitialize: boolean; +} + +/** Outcome of the GitHub-auth prompt. */ +export interface GithubAuthDecision { + /** Keep the existing configuration untouched. */ + keep?: boolean; + /** Informational: the auth mode the user picked. */ + mode?: GithubAuthMode; + /** Env values to write (non-destructively, overwriting only these keys). */ + vars?: Record; + /** + * Relay path: the user chose token relay and wants the engine to enroll on + * their behalf (discover the installation, mint the token, write the relay + * env vars) using the stored `propr login` token. `relayUrl` is the relay base + * URL to enroll against — the hosted default unless overridden. Mutually + * exclusive with `vars`. + */ + enrollRelay?: { relayUrl: string }; +} + +/** A repository to start monitoring. */ +export interface RepoSelection { + fullName: string; + alias?: string; + baseBranch?: string; +} + +/** + * Hooks a renderer implements to drive user decisions. All optional: a missing + * hook means "use the safe default" (keep existing config, skip optional work), + * which is exactly what lets the engine run unattended in tests. + */ +export interface SetupPrompts { + /** Choose/confirm the stack root. Default: keep resolved root, scaffold only if `.env` is absent. */ + resolveStackRoot?(ctx: { currentRoot: string; init: StackInitState }): Promise; + /** Pick which agents to enable. Default: the agents detected on this host. */ + selectAgents?(ctx: { available: string[]; detected: string[] }): Promise; + /** Configure GitHub auth. Default: keep whatever `.env` already has. */ + configureGithubAuth?(ctx: { current: GithubAuthModeResult }): Promise; + /** + * Choose which installation to enroll when the relay reports more than one the + * user can access. Only consulted for the ambiguous (>1) case; a single + * installation is auto-selected and zero is an error. Default (no hook): the + * first installation. + */ + selectInstallation?(ctx: { installations: AuthorizedInstallation[] }): Promise; + /** + * Ask whether to run the interactive `propr login` (gh CLI) now when Connect + * enrollment or protected local API steps need a user token and none is + * stored. `reason` explains which part of setup needs it. + */ + confirmGithubLogin?(ctx: { reason: string }): Promise; + /** Offer to open the official hosted ProPR GitHub App installation page. */ + confirmGithubAppInstall?(ctx: { url: string }): Promise; + /** Continue enrollment after the user finishes the browser installation. */ + confirmGithubAppInstalled?(ctx: { url: string }): Promise; + /** + * Choose how the backend ingests GitHub events (routing WebSocket, polling, or + * direct webhooks). `defaultMode` is the choice to pre-select: the auth-derived + * recommendation on a fresh install, but `"keep"` when `.env` already carries + * an intake decision so a blank Enter never rewrites a working config. + * `currentMode` is the intake mode `.env` resolves to today. Default: keep. + */ + configureIntake?(ctx: { + authMode: GithubAuthMode; + defaultMode: GithubIntakeMode | "keep"; + currentMode: GithubIntakeMode; + }): Promise; + /** Confirm starting the stack. Default: start it. */ + confirmStartStack?(ctx: { rootDir: string; alreadyRunning: boolean }): Promise; + /** + * Choose which of the selected agents to authenticate through their image + * (only agents with an image-login plan are offered). Returns the subset to + * log in. Default: authenticate none. + */ + confirmAgentLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; + /** Provide the user whitelist. Return null to keep the current value. Default: keep. */ + configureWhitelist?(ctx: { current: string[]; demoMode: boolean }): Promise; + /** Optionally add a first repository. Return null to skip. Default: skip. */ + addRepository?(ctx: { rootDir: string }): Promise; + /** + * Ask whether to open the UI in a browser. Returning `true` makes the engine + * launch it (via {@link SetupActions.openUrl}); the renderer only collects the + * yes/no. Default: don't open, just report the URL. + */ + launchUi?(ctx: { url: string }): Promise; +} + +// --------------------------------------------------------------------------- +// Progress reporting. +// --------------------------------------------------------------------------- + +/** Progress hooks a renderer implements to reflect engine state. All optional. */ +export interface SetupReporter { + /** Fired after every state transition with the latest immutable snapshot. */ + onState?(state: SetupState): void; + /** Fired when a step becomes active. */ + onStepStart?(step: SetupStep): void; + /** Fired when a step reaches a terminal status. */ + onStepSettled?(step: SetupStep): void; + /** Free-form progress lines (e.g. docker pull output). */ + onLog?(line: string): void; + /** Structured event stream for non-renderer hosts such as Electron main. */ + onProgress?(event: SetupProgressEvent): void; +} + +export type SetupProgressEvent = + | { type: "state"; state: SetupState } + | { type: "step-start"; step: SetupStep } + | { type: "step-settled"; step: SetupStep } + | { type: "log"; line: string }; + +// --------------------------------------------------------------------------- +// Injectable side effects. +// --------------------------------------------------------------------------- + +/** Relay installation shape used by setup prompts and enrollment. */ +export interface AuthorizedInstallation { + installation_id: number; + account_login: string; + account_type: string; +} + +/** Minimal environment-check contract consumed by the setup state machine. */ +export interface SetupCheckResult { + name: string; + status: "ok" | "warn" | "fail"; + detail: string; + group?: string; +} + +export interface RunChecksOptions { + root?: string; + skipRemoteImageCheck?: boolean; + signal?: AbortSignal; +} + +export interface ChecksOutcome { + results: SetupCheckResult[]; + rootDir: string; + anyFail: boolean; + /** Host-specific configuration returned by a checker; opaque to the engine. */ + cfg?: unknown; +} + +export interface InitStackOptions { + root?: string; + force?: boolean; + signal?: AbortSignal; +} + +export interface InitStackResult { + rootDir: string; + envCreated: boolean; + envSkipped: boolean; + envBackedUp: boolean; + dirsCreated: string[]; + dirsSkipped: string[]; + detected?: Array<{ envKey: string; path: string }>; + credentialsAppended?: boolean; + pendingCredentials?: Array<{ envKey: string; path: string }>; + runtimeModeWarning?: string; +} + +export interface PullImagesParams { + rootDir: string; + /** Agent types whose images should be pulled (in addition to core images). */ + agentTypes: string[]; + onLog?: (line: string) => void; + signal?: AbortSignal; +} + +export interface PullImagesResult { + pulledCore: string[]; + pulledAgents: string[]; + /** Core images that failed to pull — fatal, the stack cannot start. */ + failedCore: string[]; + /** Agent images that failed to pull — non-fatal, only those agents are affected. */ + failedAgents: string[]; +} + +export interface StartStackParams { + rootDir: string; + ui?: boolean; + docs?: boolean; + onLog?: (line: string) => void; + signal?: AbortSignal; +} + +export interface BackendHealthParams { + rootDir: string; + timeoutMs?: number; + signal?: AbortSignal; +} + +export interface BackendHealth { + healthy: boolean; + detail: string; + /** + * Set when the backend answered the probe (it is reachable and running) but + * rejected the request for authentication or authorization reasons rather + * than being genuinely unhealthy. The value lets the caller recommend login + * for a 401 without giving the same incorrect advice for a 403. + */ + accessFailure?: "unauthorized" | "forbidden"; +} + +/** Classify an HTTP access failure from the protected backend status route. */ +export function classifyBackendAccessError(error: unknown): BackendHealth | undefined { + const httpStatus = (error as { status?: unknown } | null)?.status; + if (httpStatus !== 401 && httpStatus !== 403) return undefined; + + const accessFailure = httpStatus === 401 ? "unauthorized" : "forbidden"; + const message = error instanceof Error ? error.message : String(error); + return { + healthy: false, + accessFailure, + detail: `backend is running but rejected the status request as ${accessFailure} (${message})`, + }; +} + +/** + * The operations the engine performs against the outside world. CLI, desktop, + * and tests each provide their own implementation. + */ +export interface SetupActions extends AgentSetupActions { + runChecks(options: RunChecksOptions): Promise; + inspectStackInit(rootDir: string): StackInitState; + /** Inspect the configured datastore's durable administrator state without modifying it. */ + inspectDatastoreAdministrators(rootDir: string): Promise; + scaffoldStack(options: InitStackOptions): Promise; + /** + * Persist the resolved stack root to the CLI config so later `propr start` / + * `propr status` invoked without `--root` target this stack. `scaffoldStack` + * already records it whenever it runs; this exists for the reuse path (an + * already-initialized root that setup leaves untouched), which would otherwise + * leave config pointing at a stale root or the cwd. A no-op without a config. + */ + persistStackRoot(rootDir: string): Promise; + readEnvVars(rootDir: string): Record; + applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }): EnvSelectionResult; + /** Remove keys from `.env` entirely (used to clear a value, not blank it). */ + clearEnvKeys(rootDir: string, keys: string[]): void; + detectGithubAuthMode(rootDir: string): GithubAuthModeResult; + /** Ensure a selected agent's host credential path is a directory, creating it securely when absent. */ + prepareAgentCredentialDir(path: string): void; + pullImages(params: PullImagesParams): Promise; + isStackRunning(rootDir: string): Promise; + startStack(params: StartStackParams): Promise; + checkBackendHealth(params: BackendHealthParams): Promise; + addRepository(selection: RepoSelection, rootDir: string): Promise; + resolveUiUrl(rootDir: string): Promise; + /** Open `url` in the host's default browser (best-effort; may reject). */ + openUrl(url: string): Promise; + /** + * Save the user whitelist through the running backend's settings API. A + * partial update — only the whitelist key is sent, so unrelated settings are + * left intact. + */ + saveWhitelistSetting(rootDir: string, users: string[]): Promise; + /** True when a GitHub user token is stored (relay enrollment and protected local API calls need it). */ + hasGithubToken(): boolean; + /** + * List the relay installations the stored GitHub identity can access (drives + * auto-select / the picker during relay enrollment). Throws if not logged in. + */ + fetchRelayInstallations(params: { + relayUrl?: string; + }): Promise<{ username: string; installations: AuthorizedInstallation[] }>; + /** + * Mint a relay token for `installationId`, returning the token and the relay + * URL it was minted against (the hosted default unless `relayUrl` overrides). + */ + enrollRelay(params: { + relayUrl?: string; + installationId: string; + label?: string; + }): Promise<{ relayUrl: string; token: string }>; + /** Authenticate with GitHub via the interactive `gh` CLI and store the token. */ + loginWithGithub(params?: { onLog?: (line: string) => void }): Promise; + /** Host preference used to select managed browser authentication. */ + getTunnelEnabled?(rootDir: string): boolean | undefined; +} + +/** Options for {@link runSetup}. */ +export interface RunSetupOptions { + /** Explicit stack root flag (highest precedence). */ + root?: string; + prompts?: SetupPrompts; + reporter?: SetupReporter; + /** All host I/O is supplied explicitly; the engine has no Docker or login dependency. */ + actions: SetupActions; + skipRemoteImageCheck?: boolean; + /** Defaults to the current Node platform and is reported for capability presentation. */ + platform?: NodeJS.Platform; + /** Cooperative cancellation, observed before every setup step. */ + signal?: AbortSignal; +} + +export type LocalSetupCapability = + | { supported: true; kind: "local"; platform: "linux" } + | { supported: false; kind: "remote-only"; platform: NodeJS.Platform; reason: string }; + +/** Desktop-facing capability metadata; the platform-neutral engine does not use it as an execution gate. */ +export function getLocalSetupCapability(platform: NodeJS.Platform = process.platform): LocalSetupCapability { + if (platform === "linux") return { supported: true, kind: "local", platform }; + return { + supported: false, + kind: "remote-only", + platform, + reason: `Local setup is not supported on ${platform}; use a remote ProPR deployment.`, + }; +} + +export interface SetupStructuredError { + code: "local-unsupported" | "step-failed" | "cancelled"; + message: string; + stepId?: SetupStepId; + retryable: boolean; + nextAction?: string; +} + +/** Raised when an AbortSignal is observed between setup steps. */ +export class SetupCancellation extends Error { + readonly state: SetupState; + + constructor(state: SetupState) { + super("Setup was cancelled."); + this.name = "SetupCancellation"; + this.state = state; + } +} + +/** Final outcome of a setup run. */ +export interface SetupRunResult { + rootDir: string; + state: SetupState; + /** Capability metadata for adapters that present local-versus-remote setup choices. */ + capability: LocalSetupCapability; + /** Environment-check outcome, when the check step ran. */ + checks?: ChecksOutcome; + /** True when every required step finished without a blocking failure. */ + completed: boolean; + cancelled: boolean; + errors: SetupStructuredError[]; +} + +async function runSetupAttempt(options: RunSetupOptions): Promise { + const { prompts = {}, reporter = {}, skipRemoteImageCheck, actions } = options; + const catalog = agentCatalog(); + + let rootDir = resolve(options.root ?? process.cwd()); + let state = createSetupState(rootDir); + let checks: ChecksOutcome | undefined; + const capability = getLocalSetupCapability(options.platform); + /** Agents chosen at the pull step, reused when recording credentials. */ + let selectedAgents: string[] = []; + /** True only when the configured datastore conclusively has no durable administrator. */ + let bootstrapIdentityEligible = false; + /** Set after this run successfully writes an authenticated identity to the administrator environment. */ + let bootstrapAdministratorSeeded = false; + let datastoreAdminInspection: DatastoreAdminInspection | undefined; + /** True only after the local API answers the setup health probe. */ + let backendReady = false; + + const redact = (value: string): string => value + .replace(/\b(Bearer\s+)\S+/gi, "$1[REDACTED]") + .replace(/\b(gh[pousr]_[A-Za-z0-9_]{8,})\b/g, "[REDACTED]") + .replace(/\b((?:token|secret|password|private[_-]?key)\s*[=:]\s*)\S+/gi, "$1[REDACTED]"); + const safeStep = (step: SetupStep): SetupStep => ({ + ...step, + detail: step.detail ? redact(step.detail) : undefined, + nextAction: step.nextAction ? redact(step.nextAction) : undefined, + }); + const safeState = (): SetupState => ({ ...state, steps: state.steps.map(safeStep) }); + const emit = (): void => { + const snapshot = safeState(); + reporter.onState?.(snapshot); + reporter.onProgress?.({ type: "state", state: snapshot }); + }; + const stepOf = (id: SetupStepId): SetupStep => getStep(state, id)!; + const begin = (id: SetupStepId): void => { + if (options.signal?.aborted) { + state = { + ...state, + steps: state.steps.map((step) => step.status === "pending" + ? { ...step, status: "skipped", detail: "setup cancelled" } + : step), + }; + throw new SetupCancellation(state); + } + state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined }); + emit(); + const step = safeStep(stepOf(id)); + reporter.onStepStart?.(step); + reporter.onProgress?.({ type: "step-start", step }); + }; + const settle = (id: SetupStepId, patch: SetupStepPatch): void => { + state = updateStep(state, id, patch); + emit(); + const step = safeStep(stepOf(id)); + reporter.onStepSettled?.(step); + reporter.onProgress?.({ type: "step-settled", step }); + }; + const log = (line: string): void => { + const safeLine = redact(line); + reporter.onLog?.(safeLine); + reporter.onProgress?.({ type: "log", line: safeLine }); + }; + const finish = (): SetupRunResult => ({ + rootDir, + state: safeState(), + capability, + checks, + // A terminal-looking step list is not a working installation unless the + // API actually became healthy during this run. + completed: isSetupComplete(state) && backendReady, + cancelled: false, + errors: state.steps + .filter((step) => step.status === "failed") + .map((step) => ({ + code: "step-failed" as const, + message: redact(step.detail ?? `${step.title} failed`), + stepId: step.id, + retryable: true, + nextAction: step.nextAction ? redact(step.nextAction) : undefined, + })), + }); + + if (options.signal?.aborted) { + emit(); + return { ...finish(), cancelled: true, errors: [{ code: "cancelled", message: "Setup was cancelled.", retryable: true }] }; + } + + /** + * Relay enrollment for the auth step. Ensures a GitHub token (offering the + * interactive login when a `confirmGithubLogin` hook is present), discovers the + * installation (auto-select one, pick among many, error on none), mints the + * relay token, and writes the relay env vars. Returns a success `detail` or a + * actionable `note`. It never throws for expected problems; the caller marks + * the auth step failed and stops before launching a backend that cannot boot. + */ + const enrollRelayForSetup = async ( + relayUrl: string + ): Promise<{ detail?: string; note?: { detail: string; nextAction?: string } }> => { + // 1. A stored GitHub token is required. Offer interactive login when the + // renderer supports it. The Ink entry point performs this handoff before + // enabling raw mode; the sequential renderer prompts through this hook. + if (!actions.hasGithubToken()) { + const reason = "Relay enrollment needs a GitHub token."; + if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { + await actions.loginWithGithub({ onLog: log }); + } + if (!actions.hasGithubToken()) { + return { + note: { + detail: "relay not enrolled — not logged in to GitHub", + nextAction: "Run `propr login`, then re-run `propr setup` and accept ProPR Connect.", + }, + }; + } + } + + try { + // 2. Discover installations: auto-select the only one, pick among many, + // error when there are none. + let { username, installations } = await actions.fetchRelayInstallations({ relayUrl }); + const usingHostedRelay = + relayUrl.replace(/\/+$/, "") === DEFAULT_PROPR_GH_RELAY_URL.replace(/\/+$/, ""); + if (installations.length === 0 && usingHostedRelay && prompts.confirmGithubAppInstall) { + const installUrl = DEFAULT_PROPR_GITHUB_APP_INSTALL_URL; + if (await prompts.confirmGithubAppInstall({ url: installUrl })) { + await actions.openUrl(installUrl); + const installed = prompts.confirmGithubAppInstalled + ? await prompts.confirmGithubAppInstalled({ url: installUrl }) + : false; + if (installed) { + ({ username, installations } = await actions.fetchRelayInstallations({ relayUrl })); + } + } + } + if (installations.length === 0) { + return { + note: { + detail: "relay not enrolled — no GitHub App installation available", + nextAction: usingHostedRelay + ? `Install the default ProPR GitHub App at ${DEFAULT_PROPR_GITHUB_APP_INSTALL_URL}, then re-run setup.` + : `Ask the administrator of ${relayUrl} for that relay's GitHub App installation URL, install it, then re-run setup.`, + }, + }; + } + let installationId: string; + if (installations.length === 1) { + installationId = String(installations[0].installation_id); + log(`relay: using installation ${installationId} (${installations[0].account_login})`); + } else if (prompts.selectInstallation) { + installationId = await prompts.selectInstallation({ installations }); + } else { + installationId = String(installations[0].installation_id); + } + + // 3. Mint the relay token and write the relay env vars (overwriting only + // these keys). PROPR_DEMO_MODE=false ensures the new relay config isn't + // shadowed by a leftover demo flag (see detectGithubAuthMode). + const { relayUrl: resolvedRelayUrl, token } = await actions.enrollRelay({ relayUrl, installationId }); + const existingEnv = actions.readEnvVars(rootDir); + const existingAdminUsers = [...new Set( + (existingEnv.PROPR_ADMIN_USERS ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + )]; + const hasExistingAdminUsers = existingAdminUsers.length > 0; + const seedBootstrapAdmin = bootstrapIdentityEligible && !hasExistingAdminUsers; + const existingWhitelist = (existingEnv.GITHUB_USER_WHITELIST ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const whitelistHasIdentity = existingWhitelist.some( + (value) => value.toLowerCase() === username.trim().toLowerCase() + ); + const bootstrapWhitelist = seedBootstrapAdmin && !whitelistHasIdentity + ? [...existingWhitelist, username].join(",") + : undefined; + const tunnelOverride = actions.getTunnelEnabled?.(rootDir); + const managedTunnelEnabled = tunnelOverride ?? Boolean( + existingEnv.PROPR_UI_TUNNEL_TOKEN?.trim() || isTruthyEnvFlag(existingEnv.PROPR_UI_TUNNEL_ENABLED) + ); + const explicitBrowserAuthMode = existingEnv.PROPR_WEB_AUTH_MODE?.trim().toLowerCase(); + const hasExplicitBrowserAuthMode = + explicitBrowserAuthMode === "connect" || + explicitBrowserAuthMode === "github" || + explicitBrowserAuthMode === "disabled"; + const customBrowserOAuthApplies = + !managedTunnelEnabled && + !hasExplicitBrowserAuthMode && + isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_ID) && + isConfiguredOAuthValue(existingEnv.GH_OAUTH_CLIENT_SECRET); + const usesHostedConnect = + normalizeServiceUrl(resolvedRelayUrl) === normalizeServiceUrl(DEFAULT_PROPR_GH_RELAY_URL) && + normalizeServiceUrl(existingEnv.PROPR_CONNECT_URL || "https://connect.propr.dev") === + "https://connect.propr.dev"; + const callbackUrl = existingEnv.GH_OAUTH_CALLBACK_URL || + "http://localhost:4000/api/auth/github/callback"; + const automaticConnectApplies = + managedTunnelEnabled || + (usesHostedConnect && isSupportedLoopbackCallback(callbackUrl)); + actions.applyEnvSelection( + rootDir, + { + PROPR_DEMO_MODE: "false", + GH_AUTH_MODE: "relay", + PROPR_GH_RELAY_URL: resolvedRelayUrl, + PROPR_GH_RELAY_TOKEN: token, + GH_INSTALLATION_ID: installationId, + // Select hosted Connect only for its managed tunnel and exact + // loopback callback deployments. Explicit modes, custom OAuth, and + // custom/self-hosted relay paths remain operator-owned. + ...(automaticConnectApplies && !hasExplicitBrowserAuthMode && !customBrowserOAuthApplies + ? { PROPR_WEB_AUTH_MODE: "connect" } + : {}), + // The relay identity was just authenticated by GitHub and owns this + // installation, so it is the safe bootstrap administrator only when + // the configured datastore is absent or conclusively contains no + // durable administrator. Existing environment administrators and + // durable database administrators are always preserved. + ...(seedBootstrapAdmin ? { PROPR_ADMIN_USERS: username } : {}), + // Preserve every user-managed whitelist entry, adding the enrolled + // identity only when bootstrap enrollment needs it. + ...(bootstrapWhitelist ? { GITHUB_USER_WHITELIST: bootstrapWhitelist } : {}), + }, + { overwrite: true } + ); + bootstrapAdministratorSeeded = seedBootstrapAdmin; + const adminDetail = hasExistingAdminUsers + ? "kept existing administrators" + : seedBootstrapAdmin + ? `bootstrap administrator: ${username}` + : datastoreAdminInspection?.status === "uninspectable" + ? "left administrators unchanged because the datastore could not be inspected" + : "left administrators unchanged on existing stack"; + return { + detail: `auth mode: relay (installation ${installationId}); ${adminDetail}`, + }; + } catch (error) { + return { + note: { + detail: `relay enrollment failed — ${(error as Error).message}`, + nextAction: "Confirm the shared GitHub App is installed and you own the installation, then re-run setup.", + }, + }; + } + }; + + emit(); + + // 1. Environment checks — run first; their results steer the rest. + begin("check"); + try { + checks = await actions.runChecks({ root: rootDir, skipRemoteImageCheck, signal: options.signal }); + } catch (error) { + settle("check", { + status: "failed", + detail: `could not run environment checks: ${(error as Error).message}`, + nextAction: "Resolve the error above, then re-run setup.", + }); + return finish(); + } + const dockerProblem = blockingDockerFailure(checks); + if (dockerProblem) { + settle("check", { + status: "failed", + detail: dockerProblem, + nextAction: "Install/start Docker and ensure this user can run `docker info`, then re-run setup.", + }); + return finish(); + } + const fails = checks.results.filter((r) => r.status === "fail").length; + const warns = checks.results.filter((r) => r.status === "warn").length; + settle("check", { + status: warns > 0 || fails > 0 ? "warning" : "done", + detail: `${checks.results.length} checks (${fails} failing, ${warns} warnings) — addressing them below`, + }); + + // 2. Initialize stack — only when `.env` is missing or the user picks a new + // root. An existing functional install is never re-scaffolded or clobbered. + begin("init-stack"); + try { + let initSettlement: SetupStepPatch; + let init = actions.inspectStackInit(rootDir); + let userChoseReinit = false; + if (prompts.resolveStackRoot) { + const decision = await prompts.resolveStackRoot({ currentRoot: rootDir, init }); + if (decision.rootDir && decision.rootDir !== rootDir) { + rootDir = decision.rootDir; + state = { ...state, rootDir }; + init = actions.inspectStackInit(rootDir); + } + userChoseReinit = decision.reinitialize; + } + + // Scaffold whenever the stack is incomplete — `.env` missing *or* a required + // sub-directory (data/logs/repos) absent — or when the user explicitly chose + // to (re)initialize a root. Keying off `initialized` (not just `envExists`) + // means a half-scaffolded root with a stray `.env` but no `data/` still gets + // its directories created, instead of being silently treated as ready and + // failing later at startup. scaffoldStack runs without `force`, so an existing + // `.env` is always preserved — re-running setup never clobbers it. + const reinitialize = !init.initialized || userChoseReinit; + if (reinitialize) { + // No `force`: scaffoldStack creates a fresh `.env` only when absent and + // otherwise leaves the existing one in place. + const result = await actions.scaffoldStack({ root: rootDir, signal: options.signal }); + // Adopt the absolute root scaffoldStack actually resolved. A root typed at + // the prompt may be relative or have a trailing slash; without this every + // later step (env writes, health probe, UI URL) would key off the raw + // string while the scaffold landed at the resolved path. + if (result.rootDir && result.rootDir !== rootDir) { + rootDir = result.rootDir; + state = { ...state, rootDir }; + } + // Persist through the active host as well as its scaffold initializer. + // Otherwise later setup saves can write stale host config and silently + // discard the root that scaffolding recorded. + await actions.persistStackRoot(rootDir); + const created = [...result.dirsCreated]; + initSettlement = { + status: "done", + detail: result.envCreated + ? `scaffolded stack at ${rootDir}${created.length ? ` (created ${created.join(", ")})` : ""}` + : `stack root ready at ${rootDir} (existing .env kept)`, + }; + } else { + // Reuse path: scaffolding is skipped, so nothing has recorded this root in + // config. Persist it now so a later `propr start` / `propr status` without + // --root targets this stack rather than an old saved root or the cwd. + await actions.persistStackRoot(rootDir); + initSettlement = { status: "skipped", detail: `using existing stack at ${rootDir} (.env preserved)` }; + } + + // Eligibility comes from the configured datastore itself, not scaffold + // artifacts. This recovers migrated databases with no durable administrator + // and follows the runtime's DB_FILENAME/DATA_DIR resolution. Configured + // paths outside the launcher's data bind mount cannot be safely inspected + // from the host and remain ineligible (fail closed). + datastoreAdminInspection = await actions.inspectDatastoreAdministrators(rootDir); + bootstrapIdentityEligible = + datastoreAdminInspection.status === "absent" || datastoreAdminInspection.status === "no-admin"; + if (datastoreAdminInspection.status === "uninspectable") { + const inspectionDetail = datastoreAdminInspection.detail ?? "configured datastore is unavailable"; + log(`administrator inspection: ${inspectionDetail}`); + } + // Inspect before reporting initialization success so this step has exactly + // one terminal settlement even when inspection itself throws. An + // uninspectable datastore is evaluated after auth resolves because demo + // mode does not require an instance administrator. + settle("init-stack", initSettlement); + } catch (error) { + settle("init-stack", { + status: "failed", + detail: `could not initialize stack: ${(error as Error).message}`, + nextAction: "Check directory permissions and that .env.example is available, then re-run setup.", + }); + return finish(); + } + + // 3. Pull images — core images by default, plus the shared agent image when + // the user selects an agent (defaulting to those detected on this host). + begin("pull-images"); + const detected = detectInstalledAgents(catalog); + try { + const requested = prompts.selectAgents + ? await prompts.selectAgents({ available: catalog.map((a) => a.type), detected }) + : detected; + // Guard the engine boundary: a renderer may hand back unknown or duplicate + // agent names. Keep only types we know about, de-duped (first occurrence + // wins), so unknown names never reach pullImages() and a duplicate can't + // double-apply credentials in the configure-agents step below. + const known = new Set(catalog.map((a) => a.type)); + selectedAgents = [...new Set(requested)].filter((type) => known.has(type)); + + const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log, signal: options.signal }); + if (pull.failedCore.length > 0) { + settle("pull-images", { + status: "failed", + detail: `failed to pull core image(s): ${pull.failedCore.join(", ")}`, + nextAction: "Check registry access / network and re-run setup; the stack cannot start without core images.", + }); + return finish(); + } + const pulledCount = pull.pulledCore.length + pull.pulledAgents.length; + if (pull.failedAgents.length > 0) { + settle("pull-images", { + status: "warning", + detail: `pulled ${pulledCount} image(s); ${pull.failedAgents.length} agent image(s) unavailable`, + nextAction: "Jobs using those agents fail until their images pull. Re-run `propr images pull` later.", + }); + } else { + settle("pull-images", { status: "done", detail: `pulled ${pulledCount} image(s)` }); + } + } catch (error) { + settle("pull-images", { + status: "failed", + detail: `could not pull images: ${(error as Error).message}`, + nextAction: "Check Docker and registry access, then re-run setup.", + }); + return finish(); + } + + // 4. Configure agents — record detected host credential dirs for the selected + // agents, non-destructively (never blanks an existing value). + begin("configure-agents"); + try { + if (selectedAgents.length === 0) { + settle("configure-agents", { + status: "skipped", + detail: "no agents selected", + nextAction: "Log in with an agent CLI on this host, then re-run setup to record its credentials.", + }); + } else { + const vars: Record = {}; + const existingEnv = actions.readEnvVars(rootDir); + for (const type of selectedAgents) { + const desc = catalog.find((a) => a.type === type); + if (!desc) continue; + for (const cred of desc.credentials) { + // A selected agent may not have logged in yet. Prepare its host mount + // before the stack starts so Docker never creates a root-owned path, + // and record it now so the post-login image validation sees exactly + // the mount the worker will use. + const configuredDir = existingEnv[cred.envKey]; + const effectiveDir = configuredDir?.trim() ? configuredDir : cred.defaultDir; + assertSafeAgentCredentialDir(effectiveDir, cred.envKey); + actions.prepareAgentCredentialDir(effectiveDir); + vars[cred.envKey] = effectiveDir; + } + } + const applied = actions.applyEnvSelection(rootDir, vars, { overwrite: false }); + const detailParts: string[] = []; + detailParts.push(applied.written.length > 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record"); + if (applied.skipped.length > 0) detailParts.push(`${applied.skipped.length} already set`); + settle("configure-agents", { status: "done", detail: detailParts.join("; ") }); + } + } catch (error) { + settle("configure-agents", { + status: "failed", + detail: `could not record agent credentials: ${(error as Error).message}`, + nextAction: "Correct invalid HOST_* credential paths and check write permissions on .env, then re-run setup.", + }); + return finish(); + } + + // 5. GitHub authentication — keep what works; only write the keys the user + // explicitly chose. Missing Connect/App credentials are a hard stop because + // every non-demo backend process exits before the health probe can pass. + begin("github-auth"); + let resolvedAuth: GithubAuthModeResult; + // Set by the relay path: `relayNote` drives a failed settle (and skips + // partial writes); `relayDoneDetail` carries the success line. Both stay unset + // for the keep / custom-App / no-prompt paths, which fall back to the + // mode-derived settle below. + let relayNote: { detail: string; nextAction?: string } | undefined; + let relayDoneDetail: string | undefined; + try { + const currentAuth = actions.detectGithubAuthMode(rootDir); + let authDecision: GithubAuthDecision | undefined; + if (prompts.configureGithubAuth) authDecision = await prompts.configureGithubAuth({ current: currentAuth }); + if (authDecision?.enrollRelay) { + const outcome = await enrollRelayForSetup(authDecision.enrollRelay.relayUrl); + relayNote = outcome.note; + relayDoneDetail = outcome.detail; + } else if (authDecision?.vars && Object.keys(authDecision.vars).length > 0) { + actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }); + } + resolvedAuth = relayDoneDetail + ? { mode: "relay", warnings: [] } + : actions.detectGithubAuthMode(rootDir); + } catch (error) { + settle("github-auth", { + status: "failed", + detail: `could not configure GitHub auth: ${(error as Error).message}`, + nextAction: "Check .env access and your GitHub auth settings, then re-run setup.", + }); + return finish(); + } + if (relayNote) { + settle("github-auth", { status: "failed", detail: relayNote.detail, nextAction: relayNote.nextAction }); + return finish(); + } + if (resolvedAuth.mode === "none") { + settle("github-auth", { + status: "failed", + detail: "no GitHub auth configured", + nextAction: "Choose ProPR Connect (default), configure your own GitHub App, or enable demo mode, then re-run setup.", + }); + return finish(); + } + + // Every non-demo start needs either an environment administrator or a + // durable one. Relay enrollment above already seeds its authenticated + // identity when the datastore is conclusively empty. On a keep rerun, the + // same identity can be recovered safely only when the stored GitHub session + // can access the installation already configured for this stack. + const demoModeEnabled = isTruthyEnvFlag(actions.readEnvVars(rootDir).PROPR_DEMO_MODE); + let keptRelayBootstrapIdentity: string | undefined; + const configuredAdministrators = (): string[] => + (actions.readEnvVars(rootDir).PROPR_ADMIN_USERS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const durableAdministratorExists = datastoreAdminInspection?.status === "has-admin"; + if ( + !demoModeEnabled && + !durableAdministratorExists && + !bootstrapAdministratorSeeded && + configuredAdministrators().length === 0 && + bootstrapIdentityEligible && + resolvedAuth.mode === "relay" && + actions.hasGithubToken() + ) { + const env = actions.readEnvVars(rootDir); + const installationId = env.GH_INSTALLATION_ID?.trim(); + if (installationId) { + try { + const identity = await actions.fetchRelayInstallations({ + relayUrl: env.PROPR_GH_RELAY_URL?.trim() || undefined, + }); + const username = identity.username.trim(); + const ownsConfiguredInstallation = identity.installations.some( + (installation) => String(installation.installation_id) === installationId + ); + if (username && ownsConfiguredInstallation) { + const existingWhitelist = (env.GITHUB_USER_WHITELIST ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const whitelistHasIdentity = existingWhitelist.some( + (value) => value.toLowerCase() === username.toLowerCase() + ); + actions.applyEnvSelection( + rootDir, + { + PROPR_ADMIN_USERS: username, + ...(!whitelistHasIdentity + ? { GITHUB_USER_WHITELIST: [...existingWhitelist, username].join(",") } + : {}), + }, + { overwrite: true } + ); + bootstrapAdministratorSeeded = true; + keptRelayBootstrapIdentity = username; + } + } catch (error) { + log(`administrator bootstrap: could not verify the configured relay identity: ${(error as Error).message}`); + } + } + } + + if ( + !demoModeEnabled && + !durableAdministratorExists && + !bootstrapAdministratorSeeded && + configuredAdministrators().length === 0 + ) { + const inspectionDetail = datastoreAdminInspection?.status === "uninspectable" + ? ` (${datastoreAdminInspection.detail ?? "the configured datastore could not be inspected"})` + : ""; + settle("github-auth", { + status: "failed", + detail: `no instance administrator is configured${inspectionDetail}`, + nextAction: + "Set PROPR_ADMIN_USERS to at least one GitHub username, repair the configured datastore, or re-run setup and enroll ProPR Connect with an authenticated GitHub account.", + }); + return finish(); + } + + // The GitHub App authenticates the backend to GitHub, but it does not + // authenticate this CLI user to the backend. Everything setup does after the + // stack starts (/api/status, agent configuration, settings, and repositories) + // is protected by bearer auth, so obtain the same user token as `propr login` + // before making any of those calls. Connect enrollment already guarantees a + // token; this covers custom-App and GitHub-only demo configurations alike. + if (!demoModeEnabled && !actions.hasGithubToken()) { + const reason = "Finishing setup requires a GitHub user token for protected backend API steps."; + if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) { + await actions.loginWithGithub({ onLog: log }); + } + if (!actions.hasGithubToken()) { + settle("github-auth", { + status: "failed", + detail: `auth mode: ${resolvedAuth.mode}; GitHub user login is required to finish setup`, + nextAction: "Run `propr login`, then re-run `propr setup`; the existing stack configuration will be reused.", + }); + return finish(); + } + } + + if (relayDoneDetail) { + settle("github-auth", { status: "done", detail: relayDoneDetail }); + } else if (resolvedAuth.warnings.length > 0) { + // The mode resolves, but the shared detector flagged a partial/ambiguous + // configuration — surface it so the user can fix it before it bites later. + settle("github-auth", { + status: "warning", + detail: `auth mode: ${resolvedAuth.mode} — ${resolvedAuth.warnings.join("; ")}`, + }); + } else { + settle("github-auth", { + status: "done", + detail: keptRelayBootstrapIdentity + ? `auth mode: ${resolvedAuth.mode}; bootstrap administrator: ${keptRelayBootstrapIdentity}` + : `auth mode: ${resolvedAuth.mode}`, + }); + } + + // 5b. GitHub event intake — how the backend learns about GitHub events + // (routing WebSocket, polling, or direct webhooks). Written before startup + // because the API/daemon resolve GITHUB_EVENT_INTAKE_MODE at boot. Demo + // mode has no GitHub access, so there is nothing to ingest. + begin("intake"); + try { + if (resolvedAuth.mode === "demo") { + settle("intake", { status: "skipped", detail: "demo mode — no GitHub events to ingest" }); + } else { + const envNow = actions.readEnvVars(rootDir); + // Resolve the mode the backend would pick from today's `.env` (unset + // defaults to routing_websocket, the hosted relay path) so the prompt and + // any "kept current" message reflect what actually runs. + const { mode: currentMode } = resolveGithubEventIntakeMode({ + eventIntakeMode: envNow.GITHUB_EVENT_INTAKE_MODE, + enableGithubWebhooks: envNow.ENABLE_GITHUB_WEBHOOKS, + }); + // When `.env` already records an intake decision, default the prompt to + // "keep" so a blank Enter on a re-run can't silently flip a working config + // (e.g. disable existing direct webhooks). This also covers older `.env` + // files that only carry the legacy `ENABLE_GITHUB_WEBHOOKS` boolean: it + // still resolves to a real `currentMode`, so a blank Enter must keep that + // rather than rewrite it to the auth-derived recommendation. Only a truly + // fresh install (neither key set) falls back to the recommendation. + const intakeConfigured = + envNow.GITHUB_EVENT_INTAKE_MODE !== undefined || envNow.ENABLE_GITHUB_WEBHOOKS !== undefined; + const defaultMode = defaultIntakeChoice(resolvedAuth.mode, { intakeConfigured }); + let decision: GithubIntakeDecision | undefined; + if (prompts.configureIntake) { + decision = await prompts.configureIntake({ authMode: resolvedAuth.mode, defaultMode, currentMode }); + } + // The mode that will be in effect after this step — the explicit pick, or + // the current `.env` value when the user keeps it. `effectiveEnv` mirrors + // what `.env` holds *after* any write so the prerequisite check below sees + // the freshly written secret/mode, not the pre-write snapshot. + let effectiveMode = currentMode; + let effectiveEnv = envNow; + let detail: string; + if (decision && !decision.keep && decision.mode) { + // buildIntakeEnvVars rejects an empty webhook secret — caught below and + // surfaced as a warning rather than writing a config the API won't boot. + const vars = buildIntakeEnvVars(decision.mode, { webhookSecret: decision.webhookSecret }); + actions.applyEnvSelection(rootDir, vars, { overwrite: true }); + effectiveMode = decision.mode; + effectiveEnv = { ...envNow, ...vars }; + detail = `intake: ${intakeModeLabel(decision.mode)}`; + } else { + detail = `intake: kept current (${intakeModeLabel(currentMode)})`; + } + // Validate the resolved mode against the shared prerequisite rules so a + // silently-broken intake config (most commonly routing_websocket without + // relay auth + a relay token) surfaces here instead of as a backend boot + // failure after `propr start`. + const prereq = validateIntakeModePrerequisites({ + intakeMode: effectiveMode, + authMode: resolvedAuth.mode, + routingUrl: effectiveEnv.PROPR_ROUTING_URL, + relayUrl: effectiveEnv.PROPR_GH_RELAY_URL, + relayToken: effectiveEnv.PROPR_GH_RELAY_TOKEN, + webhookSecret: effectiveEnv.GH_WEBHOOK_SECRET, + }); + if (prereq.valid) { + settle("intake", { status: "done", detail }); + } else { + settle("intake", { + status: "failed", + detail: `${detail} — ${prereq.errors.join("; ")}`, + nextAction: + effectiveMode === "routing_websocket" + ? "Enroll with the hosted relay (`propr relay enroll`) so routing_websocket has relay auth + a relay token, or choose polling." + : "Resolve the missing intake prerequisites in .env, then re-run setup.", + }); + return finish(); + } + } + } catch (error) { + // An IntakeConfigError (e.g. direct webhooks chosen with no secret) is + // non-blocking: leave intake as-is and tell the user how to finish it. + settle("intake", { + status: "warning", + detail: `could not configure GitHub intake: ${(error as Error).message}`, + nextAction: + "Set GITHUB_EVENT_INTAKE_MODE (and GH_WEBHOOK_SECRET for direct_webhook) in .env, then re-run setup.", + }); + } + + // 6. Start the stack and validate backend health. A running stack is reused, + // not recreated, so user data and live work are untouched. + begin("start-stack"); + try { + const alreadyRunning = await actions.isStackRunning(rootDir); + const startConfirmed = prompts.confirmStartStack ? await prompts.confirmStartStack({ rootDir, alreadyRunning }) : true; + if (!startConfirmed) { + settle("start-stack", { + status: "skipped", + detail: "stack not started — setup is incomplete until the backend is running", + nextAction: "Start it later with `propr start`, or re-run `propr setup` and confirm startup.", + }); + } else { + if (alreadyRunning) { + log("stack already running — leaving it intact"); + } else { + await actions.startStack({ rootDir, onLog: log, signal: options.signal }); + } + const health = await actions.checkBackendHealth({ rootDir, signal: options.signal }); + if (health.healthy) { + backendReady = true; + settle("start-stack", { + status: "done", + detail: alreadyRunning ? `stack already running — ${health.detail}` : health.detail, + }); + } else { + settle("start-stack", { + status: "failed", + detail: health.detail, + // The backend answered, so access failures need account-oriented + // remediation rather than service-health troubleshooting. A 401 calls + // for login; a 403 calls for permission/configuration checks. + nextAction: health.accessFailure === "unauthorized" + ? "Run `propr login` to obtain a GitHub user token, then re-run `propr setup`; the running stack will be reused." + : health.accessFailure === "forbidden" + ? "Check the authenticated account, the stack's bootstrap-admin configuration, and its access permissions, then re-run `propr setup`; the running stack will be reused." + : "Run `propr status` / `propr remote-status` and inspect the API logs, then re-run setup.", + }); + } + } + } catch (error) { + settle("start-stack", { + status: "failed", + detail: `could not start the stack: ${(error as Error).message}`, + nextAction: "Run `propr start` to see the full startup output.", + }); + return finish(); + } + + // 7. Enable agents in the running backend — add the selected agents that are + // missing (existing ones are never disabled or deleted) and, on + // confirmation, authenticate the ones that support an image login. This + // runs after startup because it talks to the live backend API. Any problem + // is a non-blocking warning: agents can always be configured later. + begin("enable-agents"); + // This step talks to the live backend API, so it only makes sense once the + // stack is up. When the backend is unavailable, skip rather than fire + // doomed API calls that would surface as confusing warnings. + if (!backendReady) { + settle("enable-agents", { + status: "skipped", + detail: "backend is not healthy — agents are enabled through the running backend", + nextAction: "Start the stack (`propr start`), then re-run `propr setup` to enable and authenticate the selected agents.", + }); + } else { + try { + const outcome = await runAgentSetup({ + rootDir, + selectedAgents, + actions, + confirmLogin: prompts.confirmAgentLogin, + onLog: log, + }); + if (selectedAgents.length === 0) { + settle("enable-agents", { + status: "skipped", + detail: "no agents selected", + nextAction: "Enable agents later in the UI or with `propr agent add`.", + }); + } else { + const parts: string[] = []; + if (outcome.added.length > 0) parts.push(`enabled ${outcome.added.join(", ")}`); + if (outcome.alreadyConfigured.length > 0) parts.push(`${outcome.alreadyConfigured.length} already configured`); + if (outcome.authenticated.length > 0) parts.push(`authenticated ${outcome.authenticated.join(", ")}`); + if (outcome.authFailed.length > 0) parts.push(`${outcome.authFailed.length} login(s) did not complete`); + if (outcome.validated.length > 0) parts.push(`connectivity verified: ${outcome.validated.join(", ")}`); + if (outcome.validationFailed.length > 0) parts.push(`${outcome.validationFailed.length} connectivity check(s) need attention`); + const detail = parts.length > 0 ? parts.join("; ") : "no changes needed"; + if (outcome.errors.length > 0 || outcome.authFailed.length > 0 || outcome.validationFailed.length > 0) { + settle("enable-agents", { + status: "warning", + detail: outcome.errors.length > 0 ? `${detail}; ${outcome.errors.join("; ")}` : detail, + nextAction: outcome.nextCommands.length > 0 + ? `Run: ${outcome.nextCommands.map((command) => `\`${command}\``).join("; then ")}` + : "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", + }); + } else { + settle("enable-agents", { status: "done", detail }); + } + } + } catch (error) { + // runAgentSetup is built not to throw for expected conditions; anything that + // escapes is treated as a non-blocking warning so it can't abort setup. + settle("enable-agents", { + status: "warning", + detail: `could not configure agents: ${(error as Error).message}`, + nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.", + }); + } + } + + // 8. Whitelist — restrict who can trigger ProPR. Written non-destructively. + begin("whitelist"); + try { + const envNow = actions.readEnvVars(rootDir); + const currentWhitelist = (envNow.GITHUB_USER_WHITELIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const demoMode = resolvedAuth.mode === "demo"; + let whitelist: string[] | null = null; + if (prompts.configureWhitelist) whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); + if (whitelist !== null) { + // Trim, drop blanks, and de-dupe (first occurrence wins) so the value + // matches saveWhitelist's "cleaned, de-duped usernames" contract — a + // duplicate entry would otherwise inflate the saved count and settings. + const cleaned = [...new Set(whitelist.map((s) => s.trim()).filter(Boolean))]; + // Prefer the settings API when the backend is up so the change applies + // immediately (and never overwrites unrelated settings); always mirror into + // .env so it survives a restart. Falls back to .env if the API is down. + const backendRunning = backendReady && await actions.isStackRunning(rootDir); + const saved = await saveWhitelist({ + users: cleaned, + backendRunning, + saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users), + saveViaEnv: (users) => { + // A non-empty list is written; clearing to "none" must *remove* the key + // rather than blank it. applyEnvSelection ignores blank values (so it + // never clobbers a value), which means `GITHUB_USER_WHITELIST=""` would + // be skipped and the old list would survive on the next restart — so we + // delete the key outright instead. + if (users.length > 0) { + actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }); + } else { + actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]); + } + }, + }); + const where = saved.target === "settings" ? "via settings API" : "in .env"; + const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`; + if (saved.error) { + settle("whitelist", { + status: "warning", + detail: `${summary}; settings update failed: ${saved.error}`, + nextAction: "The whitelist is in .env; it will apply when the backend restarts.", + }); + } else { + settle("whitelist", { status: "done", detail: summary }); + } + } else if (currentWhitelist.length > 0) { + settle("whitelist", { status: "done", detail: `${currentWhitelist.length} user(s) already allowed` }); + } else if (demoMode) { + settle("whitelist", { status: "skipped", detail: "demo mode — whitelist not required" }); + } else { + settle("whitelist", { + status: "warning", + detail: "no whitelist configured — any authenticated GitHub user could trigger processing", + nextAction: "Set GITHUB_USER_WHITELIST in .env to a comma-separated list of allowed usernames.", + }); + } + } catch (error) { + settle("whitelist", { + status: "failed", + detail: `could not configure the whitelist: ${(error as Error).message}`, + nextAction: "Check .env access, then re-run setup.", + }); + return finish(); + } + + // 9. Repository (optional) — adding a repo must never fail the whole run. + begin("repo"); + // Adding a repo goes through the running backend's API, so skip it (without + // even prompting) when the backend is unavailable — there is nothing + // to add it to yet. + if (!backendReady) { + settle("repo", { + status: "skipped", + detail: "backend is not healthy — a repository is connected through the running backend", + nextAction: "Start the stack (`propr start`), then add one with `propr repo add `.", + }); + } else { + try { + // The prompt itself is part of this optional step — a renderer that throws + // while collecting the repo must degrade to a warning, not abort the run. + const repoSelection = prompts.addRepository ? await prompts.addRepository({ rootDir }) : null; + if (!repoSelection) { + settle("repo", { status: "skipped", detail: "no repository added" }); + } else { + try { + await actions.addRepository(repoSelection, rootDir); + settle("repo", { status: "done", detail: `monitoring ${repoSelection.fullName}` }); + } catch (error) { + settle("repo", { + status: "warning", + detail: `could not add ${repoSelection.fullName}: ${(error as Error).message}`, + nextAction: "Add it later with `propr repo add `.", + }); + } + } + } catch (error) { + settle("repo", { + status: "warning", + detail: `could not collect a repository to add: ${(error as Error).message}`, + nextAction: "Add it later with `propr repo add `.", + }); + } + } + + // 10. UI (optional) — surface the URL and, when the user confirms, actually + // open it in their default browser. + begin("launch-ui"); + if (!backendReady) { + settle("launch-ui", { + status: "skipped", + detail: "UI not opened — the backend is not healthy", + nextAction: "Resolve the startup failure, then re-run `propr setup`.", + }); + return finish(); + } + let uiUrl = ""; + try { + uiUrl = await actions.resolveUiUrl(rootDir); + } catch { + /* non-fatal: just omit the URL */ + } + let opened = false; + let openFailed = false; + try { + // The prompt only asks *whether* to open; the engine performs the open so + // both renderers behave identically and neither has to import a launcher. + const wantsOpen = uiUrl && prompts.launchUi ? await prompts.launchUi({ url: uiUrl }) : false; + if (wantsOpen) { + try { + await actions.openUrl(uiUrl); + opened = true; + } catch { + // Headless host, no launcher, etc. — fall back to just printing the URL. + openFailed = true; + } + } + } catch { + /* opening the UI is best-effort; a failed launch prompt must not fail setup */ + } + settle("launch-ui", { + status: opened ? "done" : "skipped", + detail: uiUrl + ? openFailed + ? `UI available at ${uiUrl} (could not open a browser automatically)` + : opened + ? `opened ${uiUrl}` + : `UI available at ${uiUrl}` + : "UI URL unavailable", + }); + + return finish(); +} + +/** Run or safely re-run the setup state machine. Existing host state is re-inspected on every call. */ +export async function runSetup(options: RunSetupOptions): Promise { + try { + return await runSetupAttempt(options); + } catch (error) { + if (!(error instanceof SetupCancellation)) throw error; + const capability = getLocalSetupCapability(options.platform); + return { + rootDir: error.state.rootDir, + state: error.state, + capability, + completed: false, + cancelled: true, + errors: [{ code: "cancelled", message: error.message, retryable: true }], + }; + } +} + +/** Retry/resume is intentionally a fresh inspection; completed work is detected and preserved by host operations. */ +export function retrySetup(previous: SetupRunResult, options: Omit): Promise { + return runSetup({ ...options, root: previous.rootDir }); +} + +/** + * Detect an environment problem that blocks the entire flow: Docker missing or + * its daemon unreachable. Other failures (e.g. GitHub auth) are addressed by + * later steps and must not abort setup here. + * + * Keyed off the structured `Docker` check group rather than exact check names, + * so re-wording a check in checkCommands.ts can't silently let setup continue + * past a missing/unreachable engine. Within that group only the engine checks + * ("Docker installed", "Docker daemon") ever report `fail`; the socket check is + * informational and tops out at `warn`, so a `fail` here always means Docker + * itself cannot run the stack. + */ +function blockingDockerFailure(outcome: ChecksOutcome): string | undefined { + return outcome.results.find((r) => r.group === "Docker" && r.status === "fail")?.detail; +} diff --git a/packages/local-setup/src/envFile.ts b/packages/local-setup/src/envFile.ts new file mode 100644 index 000000000..963504b14 --- /dev/null +++ b/packages/local-setup/src/envFile.ts @@ -0,0 +1,117 @@ +/** + * Minimal .env upsert helper. + * + * Sets each KEY to a value in a Docker --env-file-compatible dotenv file: replaces the first + * uncommented `KEY=` assignment if present, otherwise appends it. Other lines + * (comments, blank lines, commented examples) are preserved. + * + * Docker does not strip quotes in --env-file values, so values are written + * literally and must fit on one line. + */ + +import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function upsertEnvVars(envPath: string, vars: Record): void { + for (const [key, value] of Object.entries(vars)) { + if (/[\r\n]/.test(value)) { + throw new Error(`${key} cannot contain newlines; Docker --env-file only supports one KEY=VALUE assignment per line.`); + } + if (/^\s|\s$/.test(value)) { + throw new Error(`${key} cannot contain leading or trailing whitespace in ${envPath}; Docker --env-file does not strip quotes.`); + } + if (/\s#/.test(value)) { + // The orchestrator's env-file reader strips a trailing " #comment" from + // unquoted values, so such a value would not survive a read-back round trip. + throw new Error(`${key} cannot contain whitespace followed by '#' in ${envPath}; it would be read back as a truncated value (inline-comment syntax).`); + } + } + + const raw = existsSync(envPath) ? readFileSync(envPath, "utf-8") : ""; + const lines = raw.split(/\r?\n/); + + // Drop trailing blank lines so appends stay tidy; we re-add one newline at the end. + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + for (const [key, value] of Object.entries(vars)) { + const pattern = new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`); + const index = lines.findIndex((line) => pattern.test(line)); + const preserveExport = index >= 0 && /^\s*export\s+/.test(lines[index]); + const assignment = `${preserveExport ? "export " : ""}${key}=${value}`; + if (index >= 0) { + lines[index] = assignment; + } else { + lines.push(assignment); + } + } + + const isNew = !existsSync(envPath); + let tightenedFrom: number | null = null; + if (!isNew) { + try { + const before = statSync(envPath).mode & 0o777; + if (before !== 0o600) { + chmodSync(envPath, 0o600); + tightenedFrom = before; + } + } catch { + // Best-effort — may fail on Windows or non-owned files. + } + } + + writeFileSync(envPath, `${lines.join("\n")}\n`, { encoding: "utf-8", mode: isNew ? 0o600 : undefined }); + if (tightenedFrom !== null) { + console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); + } +} + +/** + * Remove the given keys from a .env file entirely. + * + * Deletes every uncommented `KEY=` assignment for each key — so a key that was + * accidentally assigned more than once is fully cleared, not just thinned to its + * last duplicate; every other line — comments, blanks, and unrelated keys — is + * preserved verbatim. A missing file, an empty key list, and keys that aren't + * present are all no-ops. + * + * This exists because {@link upsertEnvVars} can only *set* a value: writing a + * blank (e.g. `GITHUB_USER_WHITELIST=`) still leaves the key in the file, where + * it reads back as an empty value rather than as "unset". Setup flows that must + * genuinely clear a stale key (clearing the user whitelist, dropping a key when + * switching auth/intake modes) use this so the value does not silently return on + * the next read or restart. + */ +export function clearEnvKeys(envPath: string, keys: string[]): void { + if (keys.length === 0 || !existsSync(envPath)) return; + + const lines = readFileSync(envPath, "utf-8").split(/\r?\n/); + const patterns = keys.map((key) => new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`)); + const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line))); + + // Nothing matched → leave the file (and its mode) untouched. + if (kept.length === lines.length) return; + + // Tighten permissions like upsertEnvVars does — this is still the secrets file. + let tightenedFrom: number | null = null; + try { + const before = statSync(envPath).mode & 0o777; + if (before !== 0o600) { + chmodSync(envPath, 0o600); + tightenedFrom = before; + } + } catch { + // Best-effort — may fail on Windows or non-owned files. + } + + // Drop trailing blank lines, then re-add exactly one terminating newline. + while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); + writeFileSync(envPath, `${kept.join("\n")}\n`, "utf-8"); + if (tightenedFrom !== null) { + console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); + } +} diff --git a/packages/local-setup/src/github.ts b/packages/local-setup/src/github.ts new file mode 100644 index 000000000..ede47e447 --- /dev/null +++ b/packages/local-setup/src/github.ts @@ -0,0 +1,269 @@ +/** + * GitHub event-intake + user-whitelist helpers for local setup. + * + * Two concerns the setup wizard must guide a new user through, factored out of + * the engine so the decision logic lives in one tested place and both renderers + * (Ink + readline) share it: + * + * - **Intake mode** — how the backend learns about GitHub events, selected by + * the `GITHUB_EVENT_INTAKE_MODE` `.env` key (the legacy `ENABLE_GITHUB_WEBHOOKS` + * boolean is deprecated and no longer selects the mode). Three paths: + * routing_websocket — events stream over the hosted ProPR routing + * WebSocket; no inbound webhook listener and no own + * GitHub App required. The default, and only usable + * with relay auth (PROPR_GH_RELAY_TOKEN). + * polling — the daemon polls the GitHub API on an interval; works + * with any usable GitHub auth and needs no inbound URL. + * direct_webhook — GitHub posts directly to the local API; requires an + * own GitHub App plus a signing secret so forged + * payloads are rejected. + * {@link buildIntakeEnvVars} turns a chosen mode into the exact `.env` keys + * (`GITHUB_EVENT_INTAKE_MODE`, and `GH_WEBHOOK_SECRET` for direct webhooks), + * refusing to produce a direct_webhook config without a secret — the API + * would otherwise refuse to boot. + * + * - **User whitelist** — which GitHub users may trigger ProPR. Saved through + * the settings API when the backend is running (a partial update that never + * clobbers unrelated settings), and mirrored into `.env` so the value + * survives a restart. {@link saveWhitelist} owns that routing and degrades to + * an `.env`-only write when the backend is down or the API call fails. + * + * Like the rest of the setup module these helpers are UI-agnostic and free of + * Docker/network imports: side effects are passed in as callbacks so the engine + * binds them to the real API/`.env` and tests drive the whole thing in memory. + */ + +import type { GithubAuthMode, GithubEventIntakeMode } from "@propr/shared"; + +/** + * How the backend ingests GitHub events. Aliased to the shared + * {@link GithubEventIntakeMode} so the wizard and the backend boot path can't + * drift on the values the `GITHUB_EVENT_INTAKE_MODE` `.env` key accepts: + * routing_websocket — events stream over the ProPR routing WebSocket (default) + * polling — the daemon polls the GitHub API; no inbound exposure + * direct_webhook — GitHub posts to a local /webhook endpoint (needs a secret) + */ +export type GithubIntakeMode = GithubEventIntakeMode; + +/** Documentation surfaced in the intake prompt's detail text. */ +export const INTAKE_DOCS_URL = "https://docs.propr.dev/docs/architecture/daemon"; +/** Documentation for configuring direct webhook delivery. */ +export const WEBHOOK_DOCS_URL = "https://docs.propr.dev/docs/tutorials/setup-server"; + +/** + * Outcome of the intake prompt the renderer hands back to the engine. Mirrors + * {@link GithubAuthDecision}: a `keep` leaves the current `.env` untouched, + * otherwise the chosen `mode` (plus a secret for webhooks) is applied. + */ +export interface GithubIntakeDecision { + /** Keep the existing intake configuration untouched. */ + keep?: boolean; + /** The intake mode the user picked. */ + mode?: GithubIntakeMode; + /** Signing secret, required (and only used) when `mode === "direct_webhook"`. */ + webhookSecret?: string; +} + +/** Thrown when an intake selection is missing required input (e.g. a webhook secret). */ +export class IntakeConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "IntakeConfigError"; + } +} + +/** + * The intake mode to pre-select for a given GitHub auth mode. The hosted routing + * WebSocket is the product default, but it only works with relay auth (it needs + * a relay token and the shared ProPR App), so it's recommended only when relay + * auth is configured. Every other auth mode falls back to polling, which works + * with any usable GitHub auth and needs no inbound network exposure — and unlike + * direct webhooks requires no public URL or own GitHub App. + */ +export function defaultIntakeMode(authMode: GithubAuthMode): GithubIntakeMode { + return authMode === "relay" ? "routing_websocket" : "polling"; +} + +/** + * The intake choice the prompt should pre-select. + * + * On a re-run where `.env` already carries an intake decision + * (`GITHUB_EVENT_INTAKE_MODE` is set), the safe default is `"keep"`: a blank Enter + * must never silently rewrite a working config — e.g. an existing + * `direct_webhook` install must not flip to `routing_websocket` just because the + * auth-derived recommendation differs. This upholds the setup engine's re-run + * safety model (keep existing config unless the user explicitly changes it). Only + * on a fresh install, with no intake config yet, do we fall back to the + * auth-derived recommendation from {@link defaultIntakeMode}. + */ +export function defaultIntakeChoice( + authMode: GithubAuthMode, + opts: { intakeConfigured: boolean } +): GithubIntakeMode | "keep" { + return opts.intakeConfigured ? "keep" : defaultIntakeMode(authMode); +} + +/** + * Translate a chosen {@link GithubIntakeMode} into the `.env` keys it implies. + * The mode is selected by `GITHUB_EVENT_INTAKE_MODE`, the value the backend boot + * path resolves (see resolveGithubEventIntakeMode); the deprecated + * `ENABLE_GITHUB_WEBHOOKS` boolean is intentionally never written here. + * + * - `routing_websocket` / `polling` set `GITHUB_EVENT_INTAKE_MODE` to the mode + * and nothing else — routing events arrive over the relay WebSocket and + * polling pulls them from the API, neither needing a local webhook listener. + * A previously recorded `GH_WEBHOOK_SECRET` is intentionally *not* cleared: + * `applyEnvSelection`/`upsertEnvVars` only set keys, never remove them. The + * leftover secret is inert while not in direct_webhook mode (the API never + * reads it), but callers wanting a pristine `.env` must remove it by hand. + * - `direct_webhook` records the signing secret alongside the mode. An + * empty/whitespace secret is rejected with {@link IntakeConfigError}: the API + * refuses to boot in direct_webhook mode with no secret, so writing it would + * only break startup. + */ +export function buildIntakeEnvVars( + mode: GithubIntakeMode, + opts: { webhookSecret?: string } = {} +): Record { + switch (mode) { + case "routing_websocket": + case "polling": + return { GITHUB_EVENT_INTAKE_MODE: mode }; + case "direct_webhook": { + const secret = (opts.webhookSecret ?? "").trim(); + if (!secret) { + throw new IntakeConfigError( + "A webhook secret is required for direct webhooks — the API refuses to start without one." + ); + } + return { GITHUB_EVENT_INTAKE_MODE: "direct_webhook", GH_WEBHOOK_SECRET: secret }; + } + } +} + +/** A short, human-readable label for an intake mode, shared by both renderers. */ +export function intakeModeLabel(mode: GithubIntakeMode): string { + switch (mode) { + case "routing_websocket": + return "ProPR routing WebSocket (hosted relay)"; + case "polling": + return "polling (no inbound webhooks)"; + case "direct_webhook": + return "direct webhooks (signing secret recorded)"; + } +} + +/** + * One intake mode's availability under a given GitHub auth mode, for the intake + * prompt. Each renderer maps this onto a selectable (or inactive) option. + */ +export interface IntakeModeOption { + /** The intake mode this entry describes. */ + mode: GithubIntakeMode; + /** False when the chosen auth mode cannot support this intake path. */ + available: boolean; + /** + * A short note for the renderer to surface next to the option: when + * `available` is false this is *why* the path is closed; when true it is an + * optional caveat (e.g. polling's production-suitability warning). + */ + note?: string; +} + +/** + * The intake modes to show for a given GitHub auth mode, in display order, each + * flagged available or not. Unavailable modes are intentionally still returned + * so the prompt can show them inactive with the reason — a new user sees the + * full set and learns why a path is closed rather than wondering where it went. + * + * The availability rules mirror {@link validateIntakeModePrerequisites} so the + * prompt and the backend boot-time check can never disagree: + * - routing_websocket needs the ProPR token relay; a custom GitHub App can't use it. + * - direct_webhook needs your own GitHub App; the ProPR relay can't deliver to it. + * - polling works with either usable auth, but is not recommended for production. + */ +export function intakeModeOptions(authMode: GithubAuthMode): IntakeModeOption[] { + const relay = authMode === "relay"; + const app = authMode === "app"; + return [ + { + mode: "routing_websocket", + available: relay, + note: relay + ? undefined + : "needs the ProPR GitHub App (token relay); not available with a custom GitHub App", + }, + { + mode: "polling", + available: relay || app, + note: + relay || app + ? "not recommended for production: subject to GitHub API rate limits and delayed event detection (depends on the polling interval and the number of repos/PRs/issues)" + : "needs usable GitHub auth — configure the token relay or a custom GitHub App first", + }, + { + mode: "direct_webhook", + available: app, + note: app + ? undefined + : "needs your own custom GitHub App; not available with the ProPR token relay", + }, + ]; +} + +// --------------------------------------------------------------------------- +// Whitelist persistence. +// --------------------------------------------------------------------------- + +/** Where {@link saveWhitelist} persisted the whitelist. */ +export interface SaveWhitelistResult { + /** The store the value was written to as its source of truth. */ + target: "settings" | "env"; + /** Number of users in the saved whitelist (0 means cleared). */ + count: number; + /** + * Set when a settings-API save was attempted but failed, after which the + * helper fell back to `.env`. Surfaced as a warning by the caller. + */ + error?: string; +} + +/** Inputs for {@link saveWhitelist}. Side effects are injected so it stays pure-ish and testable. */ +export interface SaveWhitelistParams { + /** The cleaned, de-duped usernames to persist (may be empty to clear). */ + users: string[]; + /** Whether the local backend is up — gates the settings-API path. */ + backendRunning: boolean; + /** Persist through the running backend's settings API (partial update). */ + saveViaSettings(users: string[]): Promise; + /** Persist into `.env` (non-destructive, single key). */ + saveViaEnv(users: string[]): void; +} + +/** + * Persist the user whitelist, preferring the settings API when the backend is + * running so the change takes effect immediately without a restart, and always + * mirroring into `.env` so it survives one. If the API call fails we fall back + * to the `.env` write and report the error rather than abort setup. + * + * The settings-API path issues a *partial* update (only the whitelist key), so + * unrelated settings are never overwritten. + */ +export async function saveWhitelist(params: SaveWhitelistParams): Promise { + const { users, backendRunning, saveViaSettings, saveViaEnv } = params; + if (backendRunning) { + try { + await saveViaSettings(users); + // Mirror into `.env` so the whitelist persists across `propr start`. + saveViaEnv(users); + return { target: "settings", count: users.length }; + } catch (error) { + // The backend rejected the update (or was unreachable after all) — keep + // the value in `.env` so it is not lost, and surface why. + saveViaEnv(users); + return { target: "env", count: users.length, error: (error as Error).message }; + } + } + saveViaEnv(users); + return { target: "env", count: users.length }; +} diff --git a/packages/local-setup/src/index.ts b/packages/local-setup/src/index.ts new file mode 100644 index 000000000..fe3c12fb6 --- /dev/null +++ b/packages/local-setup/src/index.ts @@ -0,0 +1,6 @@ +export * from "./agents.js"; +export * from "./engine.js"; +export * from "./github.js"; +export * from "./publicInstanceIdentity.js"; +export * from "./state.js"; +export * from "./types.js"; diff --git a/packages/local-setup/src/publicInstanceIdentity.ts b/packages/local-setup/src/publicInstanceIdentity.ts new file mode 100644 index 000000000..19c15de64 --- /dev/null +++ b/packages/local-setup/src/publicInstanceIdentity.ts @@ -0,0 +1,654 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + opendirSync, + openSync, + readSync, + realpathSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import type { Stats } from "node:fs"; +import { dirname, join, parse, resolve, sep } from "node:path"; +import { + PUBLIC_INSTANCE_IDENTITY_FILENAME, + PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION, + parsePublicInstanceIdentityDocument, +} from "@propr/shared"; + +export const PUBLIC_IDENTITY_DIRECTORY_MODE = 0o700; +export const PUBLIC_IDENTITY_FILE_MODE = 0o644; +const PUBLIC_IDENTITY_TEMPORARY_MODE = 0o600; +export const PUBLIC_IDENTITY_MAX_BYTES = 1024; + +const READY_NAME = `.${PUBLIC_INSTANCE_IDENTITY_FILENAME}.ready-v1`; +const TEMP_PREFIX = `.${PUBLIC_INSTANCE_IDENTITY_FILENAME}.creating-v1-`; +const MAX_DIRECTORY_ENTRIES = 4096; + +export type PublicIdentityRole = "host" | "root-container"; +export type PublicIdentityBoundary = + | "temporary-opened" + | "temporary-written" + | "temporary-synced" + | "recovery-published" + | "identity-published" + | "identity-read-statted" + | "directory-synced"; + +export interface PublicIdentityOptions { + generate?: () => string; + role?: PublicIdentityRole; + onBoundary?: (boundary: PublicIdentityBoundary) => void | Promise; +} + +export interface PinnedPublicIdentityDirectory { + readonly fd: number; + readonly ownerUid: number; + open(name: string, flags: number, mode?: number): number; + identify(name: string): { + device: string; + file: string; + kind: "file" | "directory" | "symbolic-link" | "other"; + }; + /** Validate native owner/ACL/no-reparse authority for this exact open file. */ + validateEntry(name: string, fd: number, newlyCreated?: boolean): void | Promise; + /** Bounded names in this exact pinned directory, when crash recovery needs them. */ + listNames?(): readonly string[]; + publishNoReplace(oldName: string, newName: string): void; + unlink(name: string): void; +} + +class IdentityBusyError extends Error {} + +function errno(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException).code; +} + +export interface ExactPublicFileIdentity { + readonly device: string; + readonly file: string; +} + +function exactIdentity(fd: number): ExactPublicFileIdentity { + const stat = fstatSync(fd, { bigint: true }); + return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; +} + +function canonicalIdentityPart(value: string): bigint { + if (!/^(?:0|[1-9]\d{0,19})$/.test(value) || BigInt(value) > 0xffffffffffffffffn) { + throw new Error("public instance identity metadata is not a canonical 64-bit identity"); + } + return BigInt(value); +} + +export function samePublicFileIdentity( + left: ExactPublicFileIdentity, + right: ExactPublicFileIdentity, +): boolean { + return canonicalIdentityPart(left.device) === canonicalIdentityPart(right.device) + && canonicalIdentityPart(left.file) === canonicalIdentityPart(right.file); +} + +function sameIdentity(left: ExactPublicFileIdentity, right: ExactPublicFileIdentity): boolean { + return samePublicFileIdentity(left, right); +} + +function validateDirectoryMode(stat: Stats): void { + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("public identity data directory is invalid"); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PUBLIC_IDENTITY_DIRECTORY_MODE) { + throw new Error("public identity data directory is not private"); + } +} + +export function publicIdentityFilePermissionsAllowed( + metadata: { uid: number; mode: number }, + directoryOwnerUid: number, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform === "win32") return false; + return (metadata.uid === directoryOwnerUid || metadata.uid === 0) + && (metadata.mode & 0o777) === PUBLIC_IDENTITY_FILE_MODE; +} + +function validateFileStat(stat: Stats, directoryOwnerUid: number, allowedLinks = 1): void { + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== allowedLinks) { + if (stat.isFile() && stat.nlink === 2) throw new IdentityBusyError(); + throw new Error("public instance identity file is not a private single-link regular file"); + } + if (stat.size <= 0 || stat.size > PUBLIC_IDENTITY_MAX_BYTES) { + throw new Error("public instance identity file has an invalid size"); + } + if (process.platform !== "win32") { + if (!publicIdentityFilePermissionsAllowed(stat, directoryOwnerUid)) { + throw new Error("public instance identity file has an unexpected owner or unsafe permissions"); + } + } +} + +async function readIdentity( + directory: PinnedPublicIdentityDirectory, + name: string, + options: Pick = {}, + allowedLinks = 1, +): Promise { + let fd: number | undefined; + try { + fd = directory.open(name, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(fd); + const beforeIdentity = exactIdentity(fd); + validateFileStat(before, directory.ownerUid, allowedLinks); + await directory.validateEntry(name, fd); + await options.onBoundary?.("identity-read-statted"); + const bytes = Buffer.allocUnsafe(PUBLIC_IDENTITY_MAX_BYTES + 1); + let length = 0; + while (length < bytes.byteLength) { + const count = readSync(fd, bytes, length, bytes.byteLength - length, null); + if (count === 0) break; + length += count; + } + const after = fstatSync(fd); + const afterIdentity = exactIdentity(fd); + validateFileStat(after, directory.ownerUid, allowedLinks); + await directory.validateEntry(name, fd); + const namedAfter = directory.identify(name); + if ( + !sameIdentity(beforeIdentity, afterIdentity) + || before.size !== after.size + || length !== before.size + || length > PUBLIC_IDENTITY_MAX_BYTES + || namedAfter.kind !== "file" + || !sameIdentity(afterIdentity, namedAfter) + ) { + throw new Error("public instance identity changed while it was read"); + } + const value = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)), + ) as unknown; + if ( + !value + || typeof value !== "object" + || Array.isArray(value) + || Object.keys(value as Record).sort().join(",") + !== "publicInstanceIdentity,schemaVersion" + ) throw new Error("public instance identity document is invalid"); + const parsed = parsePublicInstanceIdentityDocument(value); + if (!parsed) throw new Error("public instance identity document is invalid"); + return parsed.publicInstanceIdentity; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +async function readIdentityIfPresent( + directory: PinnedPublicIdentityDirectory, + name: string, + options: Pick = {}, +): Promise { + try { + return await readIdentity(directory, name, options); + } catch (error) { + if (errno(error) === "ENOENT") return undefined; + throw error; + } +} + +/** + * Repair only the exact Darwin/Windows link-then-unlink crash remnant. The + * fixed recovery slot and final name must be the only two links to one valid + * inode; a hardlink at any other name is deliberately indistinguishable from + * an attack and remains rejected. + */ +async function recoverPublishedLinkRemnant( + directory: PinnedPublicIdentityDirectory, + options: Pick = {}, +): Promise { + let finalFd: number | undefined; + let recoveryFd: number | undefined; + try { + try { + finalFd = directory.open(PUBLIC_INSTANCE_IDENTITY_FILENAME, constants.O_RDONLY | constants.O_NOFOLLOW); + recoveryFd = directory.open(READY_NAME, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if (errno(error) === "ENOENT") return undefined; + throw error; + } + const finalStat = fstatSync(finalFd); + const recoveryStat = fstatSync(recoveryFd); + const finalIdentity = exactIdentity(finalFd); + const recoveryIdentity = exactIdentity(recoveryFd); + validateFileStat(finalStat, directory.ownerUid, 2); + validateFileStat(recoveryStat, directory.ownerUid, 2); + await directory.validateEntry(PUBLIC_INSTANCE_IDENTITY_FILENAME, finalFd); + await directory.validateEntry(READY_NAME, recoveryFd); + if (!sameIdentity(finalIdentity, recoveryIdentity)) { + throw new Error("public identity hardlink state is ambiguous"); + } + const recovered = await readIdentity(directory, PUBLIC_INSTANCE_IDENTITY_FILENAME, options, 2); + // Revalidate both held handles immediately before removing the private name. + const finalAfter = fstatSync(finalFd); + const recoveryAfter = fstatSync(recoveryFd); + const finalAfterIdentity = exactIdentity(finalFd); + const recoveryAfterIdentity = exactIdentity(recoveryFd); + const namedFinal = directory.identify(PUBLIC_INSTANCE_IDENTITY_FILENAME); + const namedRecovery = directory.identify(READY_NAME); + if ( + !sameIdentity(finalIdentity, finalAfterIdentity) + || !sameIdentity(recoveryIdentity, recoveryAfterIdentity) + || !sameIdentity(finalAfterIdentity, recoveryAfterIdentity) + || finalAfter.nlink !== 2 + || recoveryAfter.nlink !== 2 + || namedFinal.kind !== "file" + || namedRecovery.kind !== "file" + || !sameIdentity(finalAfterIdentity, namedFinal) + || !sameIdentity(recoveryAfterIdentity, namedRecovery) + ) throw new Error("public identity hardlink state changed during recovery"); + directory.unlink(READY_NAME); + syncDirectory(directory.fd); + await options.onBoundary?.("directory-synced"); + return await readIdentity(directory, PUBLIC_INSTANCE_IDENTITY_FILENAME, options) ?? recovered; + } finally { + if (recoveryFd !== undefined) closeSync(recoveryFd); + if (finalFd !== undefined) closeSync(finalFd); + } +} + +function isCreationTemporaryName(name: string): boolean { + if (!name.startsWith(TEMP_PREFIX)) return false; + return /^[1-9]\d{0,19}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + name.slice(TEMP_PREFIX.length), + ); +} + +/** + * Repair the exact link-then-unlink remnant left when temporary publication + * reached READY but the process stopped before removing its private source + * name. Both names must be the only links to one valid identity inode. + */ +async function recoverTemporaryLinkRemnant( + directory: PinnedPublicIdentityDirectory, + options: Pick = {}, +): Promise { + if (!directory.listNames) throw new Error("public identity recovery state is ambiguous"); + const names = directory.listNames(); + if (names.length > MAX_DIRECTORY_ENTRIES) { + throw new Error("public identity recovery directory is too large"); + } + + let readyFd: number | undefined; + let temporaryFd: number | undefined; + try { + readyFd = directory.open(READY_NAME, constants.O_RDONLY | constants.O_NOFOLLOW); + const readyStat = fstatSync(readyFd); + const readyIdentity = exactIdentity(readyFd); + validateFileStat(readyStat, directory.ownerUid, 2); + await directory.validateEntry(READY_NAME, readyFd); + + let temporaryName: string | undefined; + for (const name of names) { + if (!isCreationTemporaryName(name)) continue; + let candidateFd: number | undefined; + try { + candidateFd = directory.open(name, constants.O_RDONLY | constants.O_NOFOLLOW); + if (!sameIdentity(readyIdentity, exactIdentity(candidateFd))) continue; + if (temporaryName !== undefined) { + throw new Error("public identity recovery state is ambiguous"); + } + validateFileStat(fstatSync(candidateFd), directory.ownerUid, 2); + await directory.validateEntry(name, candidateFd); + temporaryName = name; + temporaryFd = candidateFd; + candidateFd = undefined; + } finally { + if (candidateFd !== undefined) closeSync(candidateFd); + } + } + if (temporaryName === undefined || temporaryFd === undefined) { + throw new Error("public identity recovery state is ambiguous"); + } + + await readIdentity(directory, READY_NAME, options, 2); + const readyAfter = fstatSync(readyFd); + const temporaryAfter = fstatSync(temporaryFd); + const namedReady = directory.identify(READY_NAME); + const namedTemporary = directory.identify(temporaryName); + if ( + readyAfter.nlink !== 2 + || temporaryAfter.nlink !== 2 + || !sameIdentity(readyIdentity, exactIdentity(readyFd)) + || !sameIdentity(readyIdentity, exactIdentity(temporaryFd)) + || namedReady.kind !== "file" + || namedTemporary.kind !== "file" + || !sameIdentity(readyIdentity, namedReady) + || !sameIdentity(readyIdentity, namedTemporary) + ) throw new Error("public identity hardlink state changed during recovery"); + + directory.unlink(temporaryName); + syncDirectory(directory.fd); + await options.onBoundary?.("directory-synced"); + await readIdentity(directory, READY_NAME, options); + } finally { + if (temporaryFd !== undefined) closeSync(temporaryFd); + if (readyFd !== undefined) closeSync(readyFd); + } +} + +function unlinkIfPresent(directory: PinnedPublicIdentityDirectory, name: string): void { + try { + directory.unlink(name); + } catch (error) { + if (errno(error) !== "ENOENT") throw error; + } +} + +function syncDirectory(fd: number): void { + // FlushFileBuffers does not support directory handles on Windows. The + // identity file itself is flushed before publication; retain directory + // syncing on platforms where the operation is supported. + if (process.platform !== "win32") fsyncSync(fd); +} + +async function publishRecovery( + directory: PinnedPublicIdentityDirectory, + onBoundary?: PublicIdentityOptions["onBoundary"], +): Promise { + let recovered: string; + try { + recovered = await readIdentity(directory, READY_NAME, { onBoundary }); + } catch (error) { + if (errno(error) === "ENOENT") return undefined; + if (error instanceof IdentityBusyError) return undefined; + // Only the fixed, fully-written recovery slot is eligible for cleanup. + // An unsafe owner/type/link is deliberately left untouched and rejected. + let recoveryFd: number | undefined; + try { + recoveryFd = directory.open(READY_NAME, constants.O_RDONLY | constants.O_NOFOLLOW); + const stat = fstatSync(recoveryFd); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw error; + if (!publicIdentityFilePermissionsAllowed(stat, directory.ownerUid)) throw error; + await directory.validateEntry(READY_NAME, recoveryFd); + } finally { + if (recoveryFd !== undefined) closeSync(recoveryFd); + } + unlinkIfPresent(directory, READY_NAME); + syncDirectory(directory.fd); + return undefined; + } + + try { + directory.publishNoReplace(READY_NAME, PUBLIC_INSTANCE_IDENTITY_FILENAME); + await onBoundary?.("identity-published"); + } catch (error) { + if (errno(error) !== "EEXIST") throw error; + unlinkIfPresent(directory, READY_NAME); + } + syncDirectory(directory.fd); + await onBoundary?.("directory-synced"); + try { + return await readIdentity(directory, PUBLIC_INSTANCE_IDENTITY_FILENAME, { onBoundary }) ?? recovered; + } catch (error) { + if (error instanceof IdentityBusyError) return undefined; + throw error; + } +} + +/** Central CLI/API creation algorithm operating only through a held data-directory handle. */ +export async function getOrCreatePublicInstanceIdentityPinned( + directory: PinnedPublicIdentityDirectory, + options: PublicIdentityOptions = {}, +): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + let recoveryEntryBusy = false; + try { + // READY is public state in the same authority boundary as the final + // identity. Validate it even when a healthy final file already exists; + // otherwise a hostile stale entry could remain outside the policy. + await readIdentityIfPresent(directory, READY_NAME, options); + } catch (error) { + if (!(error instanceof IdentityBusyError)) throw error; + recoveryEntryBusy = true; + } + try { + const existing = await readIdentityIfPresent(directory, PUBLIC_INSTANCE_IDENTITY_FILENAME, options); + if (existing) { + if (recoveryEntryBusy) throw new Error("public identity recovery state is ambiguous"); + return existing; + } + } catch (error) { + if (!(error instanceof IdentityBusyError)) throw error; + const repaired = await recoverPublishedLinkRemnant(directory, options); + if (repaired) return repaired; + } + if (recoveryEntryBusy) { + await recoverTemporaryLinkRemnant(directory, options); + } + + const recovered = await publishRecovery(directory, options.onBoundary); + if (recovered) return recovered; + + const generated = (options.generate ?? randomUUID)(); + const parsedGenerated = parsePublicInstanceIdentityDocument({ + schemaVersion: PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION, + publicInstanceIdentity: generated, + }); + if (!parsedGenerated) throw new Error("identity generator returned an invalid UUIDv4"); + const document = Buffer.from(`${JSON.stringify(parsedGenerated)}\n`, "utf8"); + if (document.byteLength > PUBLIC_IDENTITY_MAX_BYTES) throw new Error("public identity document is too large"); + + const temporaryName = `${TEMP_PREFIX}${process.pid}-${randomUUID()}`; + let temporaryFd: number | undefined; + let temporaryPresent = false; + try { + temporaryFd = directory.open( + temporaryName, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + PUBLIC_IDENTITY_TEMPORARY_MODE, + ); + temporaryPresent = true; + if (process.platform !== "win32") fchmodSync(temporaryFd, PUBLIC_IDENTITY_TEMPORARY_MODE); + await options.onBoundary?.("temporary-opened"); + writeFileSync(temporaryFd, document); + await options.onBoundary?.("temporary-written"); + fsyncSync(temporaryFd); + if (process.platform !== "win32") { + fchmodSync(temporaryFd, PUBLIC_IDENTITY_FILE_MODE); + fsyncSync(temporaryFd); + } + await directory.validateEntry(temporaryName, temporaryFd, true); + await options.onBoundary?.("temporary-synced"); + closeSync(temporaryFd); + temporaryFd = undefined; + + try { + directory.publishNoReplace(temporaryName, READY_NAME); + temporaryPresent = false; + await options.onBoundary?.("recovery-published"); + } catch (error) { + if (errno(error) !== "EEXIST") throw error; + } + } finally { + if (temporaryFd !== undefined) closeSync(temporaryFd); + if (temporaryPresent) unlinkIfPresent(directory, temporaryName); + } + + const winner = await publishRecovery(directory, options.onBoundary); + if (winner) return winner; + } + throw new Error("public instance identity remained a non-single-link file or creation did not settle"); +} + +/** Read the existing public identity without creating, repairing, or unlinking anything. */ +export async function readPublicInstanceIdentityPinned( + directory: PinnedPublicIdentityDirectory, + options: Pick = {}, +): Promise { + const value = await readIdentityIfPresent(directory, PUBLIC_INSTANCE_IDENTITY_FILENAME, options); + if (!value) throw new Error("public instance identity is absent"); + return value; +} + +function descriptorRoot(): string { + for (const candidate of ["/proc/self/fd", "/dev/fd"]) { + try { + if (lstatSync(candidate).isDirectory()) return candidate; + } catch { + // Try the next platform descriptor filesystem. + } + } + throw new Error("safe directory-handle access is unavailable"); +} + +function validateAncestorOwnership(stats: Stats[], terminalOwner: number, role: PublicIdentityRole): void { + const caller = process.getuid?.(); + if (role === "host" && caller !== undefined && terminalOwner !== caller) { + throw new Error("public identity data directory is not owned by the host caller"); + } + for (let index = 0; index < stats.length; index += 1) { + const stat = stats[index]; + const terminal = index === stats.length - 1; + if (terminal) { + validateDirectoryMode(stat); + continue; + } + if (process.platform === "win32") throw new Error("Windows directory ACL authority is unavailable"); + if (stat.uid !== 0 && stat.uid !== terminalOwner) { + throw new Error("public identity ancestry has an unexpected owner"); + } + const writableByOthers = (stat.mode & 0o022) !== 0; + const sticky = (stat.mode & 0o1000) !== 0; + if (writableByOthers && !sticky) throw new Error("public identity ancestry is replaceable"); + } +} + +function openPinnedDataDirectory(dataDir: string, role: PublicIdentityRole): { + directory: PinnedPublicIdentityDirectory; + close(): void; + validateVisible(): void; +} { + if (process.platform !== "linux") { + throw new Error(`safe public identity directory access is not supported on ${process.platform}`); + } + const absolute = resolve(dataDir); + if (absolute === parse(absolute).root) { + throw new Error("public identity data directory cannot be the filesystem root"); + } + const parent = dirname(absolute); + try { + lstatSync(absolute); + } catch (error) { + if (errno(error) !== "ENOENT") throw error; + if (role === "root-container") { + throw new Error("root container cannot establish the host-owned public identity directory"); + } + mkdirSync(absolute, { recursive: false, mode: PUBLIC_IDENTITY_DIRECTORY_MODE }); + chmodSync(absolute, PUBLIC_IDENTITY_DIRECTORY_MODE); + } + + const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; + const fdRoot = descriptorRoot(); + let fd = openSync(parse(absolute).root, flags); + const ancestry: Stats[] = []; + try { + let visible = parse(absolute).root; + for (const component of absolute.slice(parse(absolute).root.length).split(sep).filter(Boolean)) { + const next = openSync(join(fdRoot, String(fd), component), flags); + closeSync(fd); + fd = next; + visible = join(visible, component); + const visibleStat = lstatSync(visible); + const visibleIdentityStat = lstatSync(visible, { bigint: true }); + if (visibleStat.isSymbolicLink() || !sameIdentity( + { device: visibleIdentityStat.dev.toString(10), file: visibleIdentityStat.ino.toString(10) }, + exactIdentity(fd), + )) { + throw new Error("public identity directory changed during acquisition"); + } + ancestry.push(visibleStat); + } + const terminal = fstatSync(fd); + validateAncestorOwnership(ancestry, terminal.uid, role); + if (realpathSync.native(absolute) !== absolute) throw new Error("public identity directory uses a symbolic-link ancestor"); + const anchor = join(fdRoot, String(fd)); + const listNames = (): readonly string[] => { + const names: string[] = []; + const entries = opendirSync(anchor); + try { + for (;;) { + const entry = entries.readSync(); + if (entry === null) return names; + names.push(entry.name); + if (names.length > MAX_DIRECTORY_ENTRIES) { + throw new Error("public identity recovery directory is too large"); + } + } + } finally { + entries.closeSync(); + } + }; + const directory: PinnedPublicIdentityDirectory = { + fd, + ownerUid: terminal.uid, + open: (name, openFlags, mode = 0) => openSync(join(anchor, name), openFlags, mode), + identify: (name) => { + const stat = lstatSync(join(anchor, name), { bigint: true }); + return { + device: stat.dev.toString(10), + file: stat.ino.toString(10), + kind: stat.isFile() + ? "file" + : stat.isDirectory() + ? "directory" + : stat.isSymbolicLink() + ? "symbolic-link" + : "other", + }; + }, + validateEntry: () => undefined, + listNames, + publishNoReplace: (oldName, newName) => { + linkSync(join(anchor, oldName), join(anchor, newName)); + unlinkSync(join(anchor, oldName)); + }, + unlink: (name) => unlinkSync(join(anchor, name)), + }; + return { + directory, + close: () => closeSync(fd), + validateVisible: () => { + const visible = lstatSync(absolute); + const visibleIdentity = lstatSync(absolute, { bigint: true }); + if (visible.isSymbolicLink() || !sameIdentity( + { device: visibleIdentity.dev.toString(10), file: visibleIdentity.ino.toString(10) }, + exactIdentity(fd), + )) { + throw new Error("public identity data directory was replaced"); + } + }, + }; + } catch (error) { + closeSync(fd); + throw error; + } +} + +/** Path entry point used by both host initialization and the root API container. */ +export async function getOrCreatePublicInstanceIdentity( + dataDir: string, + options: PublicIdentityOptions = {}, +): Promise { + const pinned = openPinnedDataDirectory(dataDir, options.role ?? "host"); + try { + const identity = await getOrCreatePublicInstanceIdentityPinned(pinned.directory, options); + pinned.validateVisible(); + return identity; + } finally { + pinned.close(); + } +} diff --git a/packages/local-setup/src/state.test.ts b/packages/local-setup/src/state.test.ts new file mode 100644 index 000000000..36e528def --- /dev/null +++ b/packages/local-setup/src/state.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { applyEnvSelection, clearEnvKeys, inspectStackInit, readEnvVars } from "./state.js"; + +function withStack(run: (rootDir: string) => void): void { + const rootDir = mkdtempSync(join(tmpdir(), "propr-local-setup-test-")); + try { + run(rootDir); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } +} + +test("environment writes are private and re-runs preserve existing secrets", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + const first = applyEnvSelection(rootDir, { API_TOKEN: "first-secret" }); + assert.deepEqual(first.written, ["API_TOKEN"]); + assert.equal(statSync(envPath).mode & 0o777, 0o600); + + const rerun = applyEnvSelection(rootDir, { API_TOKEN: "replacement", SAFE_VALUE: "yes" }); + assert.deepEqual(rerun.skipped, ["API_TOKEN"]); + assert.deepEqual(readEnvVars(rootDir), { API_TOKEN: "first-secret", SAFE_VALUE: "yes" }); + assert.doesNotMatch(readFileSync(envPath, "utf8"), /replacement/); +})); + +test("clearing a setup-owned key preserves unrelated values and private permissions", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + writeFileSync(envPath, "TOKEN=secret\nKEEP=value\n", { mode: 0o644 }); + clearEnvKeys(rootDir, ["TOKEN"]); + assert.equal(readFileSync(envPath, "utf8"), "KEEP=value\n"); + assert.equal(statSync(envPath).mode & 0o777, 0o600); +})); + +test("stack inspection requires the env file and every launcher directory", () => withStack((rootDir) => { + writeFileSync(join(rootDir, ".env"), "A=b\n", { mode: 0o600 }); + mkdirSync(join(rootDir, "data")); + mkdirSync(join(rootDir, "logs")); + assert.equal(inspectStackInit(rootDir).initialized, false); + mkdirSync(join(rootDir, "repos")); + assert.equal(inspectStackInit(rootDir).initialized, true); +})); diff --git a/packages/local-setup/src/state.ts b/packages/local-setup/src/state.ts new file mode 100644 index 000000000..aa190f4a6 --- /dev/null +++ b/packages/local-setup/src/state.ts @@ -0,0 +1,420 @@ +/** + * Local setup domain helpers. + * + * Pure, side-effect-light helpers that the `propr setup` driver and both + * renderers (Ink TUI and readline fallback) build on: + * - resolving the stack root (reusing the orchestrator's precedence rules), + * - inspecting whether the stack is already initialized, + * - reading and *safely* editing .env (non-destructive by default), + * - constructing and transitioning the {@link SetupState} step model. + * + * Nothing here loads a launcher or renders UI, so the module can be imported + * and unit-tested without Docker, Ink, or readline. + */ + +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { resolveGithubAuthMode, type GithubAuthModeResult } from "@propr/shared"; +import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "./envFile.js"; +import { + SETUP_STEP_DEFINITIONS, + type SetupState, + type SetupStep, + type SetupStepId, + type SetupStepPatch, +} from "./types.js"; + +/** + * Sub-directories scaffoldStack creates under the stack root. Exported so the + * setup driver and tests can create/check the same scaffold shape without + * duplicating these names. + */ +export const STACK_SUBDIRS = ["data", "logs", "repos"] as const; + +/** True only when `path` exists and is a directory. Missing paths read false. */ +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** True only when `path` exists and is a regular file. Missing paths read false. */ +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** True when a value is missing or contains only whitespace. */ +function isBlank(value: string | undefined): boolean { + return value === undefined || value.trim() === ""; +} + +/** + * Resolve the stack root for setup, reusing the orchestrator's precedence: + * explicit flag → PROPR_ROOT env → saved config stackRoot → cwd. Does not load + * Docker. + */ +export function resolveSetupRoot( + configManager: { getStackRoot(): string | undefined } | undefined, + flagRoot?: string +): string { + if (flagRoot) return resolve(flagRoot); + if (process.env.PROPR_ROOT) return resolve(process.env.PROPR_ROOT); + const saved = configManager?.getStackRoot(); + return saved ? resolve(saved) : process.cwd(); +} + +/** Absolute path to the .env file for a given stack root. */ +export function envPathFor(rootDir: string): string { + return join(rootDir, ".env"); +} + +/** Snapshot of which scaffolded pieces of a stack root already exist. */ +export interface StackInitState { + rootDir: string; + envExists: boolean; + /** Per-subdir existence (data/, logs/, repos/). */ + dirs: Record<(typeof STACK_SUBDIRS)[number], boolean>; + /** True when .env and all expected sub-directories are present. */ + initialized: boolean; +} + +/** + * Inspect whether the stack at `rootDir` looks initialized. Read-only — never + * creates anything — so callers can decide whether to skip or re-run + * scaffolding. A plain file standing in for an expected directory (or vice + * versa) counts as *not* initialized, matching what the runtime requires. + */ +export function inspectStackInit(rootDir: string): StackInitState { + const envExists = isFile(envPathFor(rootDir)); + const dirs = {} as StackInitState["dirs"]; + for (const sub of STACK_SUBDIRS) { + dirs[sub] = isDirectory(join(rootDir, sub)); + } + const initialized = envExists && STACK_SUBDIRS.every((sub) => dirs[sub]); + return { rootDir, envExists, dirs, initialized }; +} + +export type DatastoreAdminStatus = "absent" | "no-admin" | "has-admin" | "uninspectable"; + +/** Result of inspecting the configured SQLite datastore for a durable administrator. */ +export interface DatastoreAdminInspection { + status: DatastoreAdminStatus; + /** Host path inspected, when the configured path could be resolved. */ + databasePath?: string; + /** Actionable diagnostic when inspection could not be completed safely. */ + detail?: string; +} + +/** Runtime paths used by the app image started by the CLI launcher. */ +const APP_WORKDIR = "/usr/src/app"; +const CONTAINER_DATA_DIR = join(APP_WORKDIR, "data"); + +/** + * Resolve the API's SQLite filename to the corresponding host bind-mount path. + * This mirrors @propr/core's DB_FILENAME/DATA_DIR precedence and resolves + * relative values from the app image's working directory. Only files below + * /usr/src/app/data are inspectable from the host because that is the sole data + * bind mount supplied by the CLI launcher. + */ +function resolveDatastorePath( + rootDir: string, + configuredPath: string | undefined, + configuredDataDir: string | undefined +): string { + const dbFilename = configuredPath; + const runtimePath = dbFilename + ? resolve(APP_WORKDIR, dbFilename) + : resolve(APP_WORKDIR, join(configuredDataDir ?? CONTAINER_DATA_DIR, "propr.sqlite")); + const childPath = relative(CONTAINER_DATA_DIR, runtimePath); + const outsideDataDir = + childPath === ".." || childPath.startsWith(`..${sep}`) || isAbsolute(childPath); + if (outsideDataDir) { + throw new Error( + `runtime path ${runtimePath} is outside the mounted data directory ${CONTAINER_DATA_DIR}` + ); + } + return resolve(rootDir, "data", childPath); +} + +/** + * Reject symbolic links between the host bind-mount root and the configured + * datastore. A link that is valid in the host namespace may resolve to a + * different target inside the container, so following it cannot establish + * bootstrap eligibility for the datastore the API will actually use. + */ +function assertDatastorePathHasNoSymlinks(rootDir: string, databasePath: string): void { + const dataRoot = resolve(rootDir, "data"); + const childPath = relative(dataRoot, databasePath); + let currentPath = dataRoot; + + for (const component of childPath.split(sep).filter(Boolean)) { + currentPath = join(currentPath, component); + try { + if (lstatSync(currentPath).isSymbolicLink()) { + throw new Error(`configured datastore path contains a symbolic link: ${currentPath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + } +} + +/** + * Inspect the configured SQLite datastore without creating or migrating it. + * Missing databases and databases conclusively lacking a durable administrator + * are bootstrap-eligible. Every resolution, I/O, schema, and query failure is + * reported as uninspectable so callers can fail closed. + */ +export async function inspectDatastoreAdministrators(rootDir: string): Promise { + let databasePath: string; + try { + const env = readEnvVars(rootDir); + databasePath = resolveDatastorePath(rootDir, env.DB_FILENAME, env.DATA_DIR); + } catch (error) { + return { + status: "uninspectable", + detail: `could not resolve configured datastore: ${(error as Error).message}`, + }; + } + + try { + assertDatastorePathHasNoSymlinks(rootDir, databasePath); + const stat = statSync(databasePath); + if (!stat.isFile()) { + return { status: "uninspectable", databasePath, detail: "configured datastore is not a regular file" }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { status: "absent", databasePath }; + } + return { + status: "uninspectable", + databasePath, + detail: `could not inspect configured datastore: ${(error as Error).message}`, + }; + } + + let database: import("node:sqlite").DatabaseSync | undefined; + try { + const { DatabaseSync } = await import("node:sqlite"); + database = new DatabaseSync(databasePath, { readOnly: true, timeout: 5_000 }); + const membersTable = database.prepare( + "SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'instance_members' LIMIT 1" + ).get(); + if (!membersTable) return { status: "no-admin", databasePath }; + + const durableAdmin = database.prepare( + "SELECT 1 AS found FROM instance_members WHERE role = 'admin' LIMIT 1" + ).get(); + return { status: durableAdmin ? "has-admin" : "no-admin", databasePath }; + } catch (error) { + return { + status: "uninspectable", + databasePath, + detail: `could not query configured datastore: ${(error as Error).message}`, + }; + } finally { + try { + database?.close(); + } catch { + // The read query already produced a conclusive result; closing the + // read-only handle cannot widen authorization and needs no retry here. + } + } +} + +/** Convenience predicate over {@link inspectStackInit}. */ +export function isStackInitialized(rootDir: string): boolean { + return inspectStackInit(rootDir).initialized; +} + +/** + * Parse the .env at `rootDir` into a flat map. Returns `{}` when the file is + * absent. Mirrors the assignment shape the rest of the stack relies on: + * `KEY=value`, optionally `export `-prefixed, ignoring blanks and comments. + * For unquoted values a trailing ` # comment` is stripped, matching the + * orchestrator's env-file reader (and the round-trip that {@link upsertEnvVars} + * guards against); surrounding quotes on quoted values are stripped and their + * contents kept verbatim. This is intentionally a lightweight reader, not a + * full dotenv implementation — it does not handle escaped quotes or multiline + * values. + */ +export function readEnvVars(rootDir: string): Record { + const envPath = envPathFor(rootDir); + // Treat anything that is not a regular file (absent, a directory, a broken + // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a + // malformed stack surfaces as not-initialized instead of crashing the read. + if (!isFile(envPath)) return {}; + const vars: Record = {}; + for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + const trimmed = rawValue.trim(); + const quoted = trimmed.match(/^(["'])(.*)\1$/); + // Quoted values keep their contents verbatim; unquoted values drop a + // trailing inline comment so reads agree with what upsertEnvVars allows. + vars[key] = quoted ? quoted[2] : trimmed.replace(/\s+#.*$/, ""); + } + return vars; +} + +/** True when `key` is present in .env with a non-blank value. */ +export function hasEnvValue(rootDir: string, key: string): boolean { + return !isBlank(readEnvVars(rootDir)[key]); +} + +/** Outcome of a {@link applyEnvSelection} call. */ +export interface EnvSelectionResult { + /** Keys actually written to .env this call. */ + written: string[]; + /** Keys left untouched because a value already existed (non-overwrite mode). */ + skipped: string[]; +} + +/** + * Safely edit .env for a setup step. + * + * Non-destructive by default: a key is only written when it is currently + * absent/empty, so re-running `propr setup` never clobbers values the user + * already set. Pass `{ overwrite: true }` for steps where the user explicitly + * selected a new value and intends to replace whatever is there. + * + * Blank selections (empty or whitespace-only) are ignored entirely — a step + * that has nothing to write must not blank out an existing value. Writes go + * through + * {@link upsertEnvVars}, which preserves unrelated lines and tightens the + * file's permissions. + */ +export function applyEnvSelection( + rootDir: string, + vars: Record, + opts: { overwrite?: boolean } = {} +): EnvSelectionResult { + const existing = readEnvVars(rootDir); + const toWrite: Record = {}; + const written: string[] = []; + const skipped: string[] = []; + + for (const [key, value] of Object.entries(vars)) { + if (isBlank(value)) continue; // never blank out an existing value + const alreadySet = !isBlank(existing[key]); + if (alreadySet && !opts.overwrite) { + skipped.push(key); + continue; + } + toWrite[key] = value; + written.push(key); + } + + if (written.length > 0) { + upsertEnvVars(envPathFor(rootDir), toWrite); + } + return { written, skipped }; +} + +/** + * Remove `keys` from the stack's `.env` entirely. + * + * {@link applyEnvSelection} can only set keys (and deliberately ignores blank + * values so it never clobbers a value the user set), so it cannot *clear* a key: + * writing `KEY=` would leave an empty assignment that reads back as a set-but- + * empty value. Setup steps that must genuinely drop a stale key — clearing the + * user whitelist back to "none", removing a key when switching modes — call this + * instead. A missing `.env` or absent keys are no-ops. + */ +export function clearEnvKeys(rootDir: string, keys: string[]): void { + clearEnvFileKeys(envPathFor(rootDir), keys); +} + +/** + * Infer the current GitHub auth mode from the stack's .env, so the github-auth + * step can show what is already configured (and skip prompting when valid). + * Reuses the shared resolver the backend uses, so the two can't drift. + */ +export function detectGithubAuthMode(rootDir: string): GithubAuthModeResult { + const env = readEnvVars(rootDir); + const truthy = /^(1|true|yes|on)$/i; + return resolveGithubAuthMode({ + demoMode: truthy.test(env.PROPR_DEMO_MODE ?? ""), + ghAuthMode: env.GH_AUTH_MODE, + relayUrl: env.PROPR_GH_RELAY_URL, + relayToken: env.PROPR_GH_RELAY_TOKEN, + appId: env.GH_APP_ID, + // The CLI stack records the App key as HOST_GH_PRIVATE_KEY (the orchestrator + // bind-mounts it and sets the in-container GH_PRIVATE_KEY_PATH to that path), + // so accept either when inferring app mode — otherwise a stack configured by + // `propr setup` would resolve as "none" despite being fully set up. + privateKeyPath: env.GH_PRIVATE_KEY_PATH ?? env.HOST_GH_PRIVATE_KEY, + installationId: env.GH_INSTALLATION_ID, + }); +} + +/** Build the initial, all-`pending` setup state for a resolved stack root. */ +export function createSetupState(rootDir: string): SetupState { + return { + rootDir, + steps: SETUP_STEP_DEFINITIONS.map((def) => ({ ...def, status: "pending" })), + }; +} + +/** Look up a step by id. */ +export function getStep(state: SetupState, id: SetupStepId): SetupStep | undefined { + return state.steps.find((step) => step.id === id); +} + +/** + * Return a new state with `id`'s step patched. Immutable so renderers can diff + * by reference; unknown ids return the state unchanged. + */ +export function updateStep( + state: SetupState, + id: SetupStepId, + patch: SetupStepPatch +): SetupState { + let changed = false; + const steps = state.steps.map((step) => { + if (step.id !== id) return step; + changed = true; + return { ...step, ...patch }; + }); + return changed ? { ...state, steps } : state; +} + +/** + * The next step the wizard should act on: the first one still `pending`. Used + * by the sequential renderer to drive the flow and by the TUI to highlight the + * current step. + * + * A failed required step blocks everything after it (see the `failed` status in + * ./types.ts), so once one is encountered there is no next step until it is + * retried — `undefined` is returned. Failed *optional* steps don't block. + */ +export function nextPendingStep(state: SetupState): SetupStep | undefined { + // Scan for a blocking failure first so the "a failed required step blocks + // everything after it" contract holds even if state was patched out of + // order (e.g. a later step failed before an earlier one finished). + if (state.steps.some((step) => !step.optional && step.status === "failed")) { + return undefined; + } + return state.steps.find((step) => step.status === "pending"); +} + +/** + * True once every required step has reached a terminal, non-failed state. + * Optional steps never block completion; a single failed required step does. + */ +export function isSetupComplete(state: SetupState): boolean { + return state.steps.every((step) => { + if (step.status === "failed") return false; + if (step.optional) return true; + return step.status === "done" || step.status === "skipped" || step.status === "warning"; + }); +} diff --git a/packages/local-setup/src/types.ts b/packages/local-setup/src/types.ts new file mode 100644 index 000000000..870bfe1e2 --- /dev/null +++ b/packages/local-setup/src/types.ts @@ -0,0 +1,154 @@ +/** + * Local setup engine domain types. + * + * `propr setup` walks a new user through getting a local control-plane stack + * running end to end. The flow coordinates several existing commands + * (environment checks, stack scaffolding, image pulls, agent + GitHub + * configuration, stack startup, whitelist + repo setup, and UI launch). + * + * These types are intentionally free of any rendering concern so the same + * step/status model can drive an Ink TUI and a plain readline fallback. They + * carry no Docker, Ink, or readline imports — see ./state.ts for the pure + * helpers that compute and transition this state. + */ + +/** Stable identifiers for each step of the setup flow, in run order. */ +export type SetupStepId = + | "check" + | "init-stack" + | "pull-images" + | "configure-agents" + | "github-auth" + | "intake" + | "start-stack" + | "enable-agents" + | "whitelist" + | "repo" + | "launch-ui"; + +/** + * Lifecycle status of a single step. + * pending — not started yet + * active — currently running + * done — completed successfully + * skipped — intentionally not run (already satisfied, or an optional step the + * user declined) + * warning — completed but with non-fatal issues the user should see + * failed — errored; blocks any step that depends on it + */ +export type SetupStepStatus = + | "pending" + | "active" + | "done" + | "skipped" + | "warning" + | "failed"; + +/** A single step in the setup flow plus its current presentation state. */ +export interface SetupStep { + id: SetupStepId; + /** Short label for progress lists. */ + title: string; + /** One-line explanation of what the step does. */ + description: string; + /** Optional steps may be skipped without blocking completion. */ + optional: boolean; + status: SetupStepStatus; + /** Live detail line (e.g. "pulled 6 images", "Docker daemon unreachable"). */ + detail?: string; + /** + * Suggested next action when the step is blocked, failed, or needs user + * input — shown by both renderers so the user knows how to proceed. + */ + nextAction?: string; +} + +/** Aggregate state for the whole setup flow. */ +export interface SetupState { + /** Resolved stack root where .env, data/, logs/, repos/ live. */ + rootDir: string; + /** Ordered steps; index order is the intended run order. */ + steps: SetupStep[]; +} + +/** + * Patch applied to a step when transitioning its state. Limited to runtime + * presentation fields — the static flow definition (title, description, + * optional) is canonical and cannot be altered through a patch. + */ +export type SetupStepPatch = Partial>; + +/** + * Canonical, ordered step definitions. All start `pending`; renderers and the + * command driver transition them via the helpers in ./state.ts. + */ +export const SETUP_STEP_DEFINITIONS: ReadonlyArray< + Pick +> = [ + { + id: "check", + title: "Environment checks", + description: "Verify Docker, images, and agent credentials are ready.", + optional: false, + }, + { + id: "init-stack", + title: "Initialize stack", + description: "Scaffold the stack root (.env, data/, logs/, repos/).", + optional: false, + }, + { + id: "pull-images", + title: "Pull images", + description: "Download the ProPR service and agent container images.", + optional: false, + }, + { + id: "configure-agents", + title: "Configure agents", + description: "Record detected host agent-credential directories in .env.", + optional: false, + }, + { + id: "github-auth", + title: "GitHub authentication", + description: "Choose how the backend authenticates to GitHub.", + optional: false, + }, + { + id: "intake", + title: "GitHub intake", + description: "Choose how the backend ingests GitHub events (routing WebSocket, polling, or direct webhooks).", + optional: false, + }, + { + id: "start-stack", + title: "Start stack", + description: "Launch the local control-plane services.", + optional: false, + }, + { + id: "enable-agents", + title: "Enable agents", + description: "Enable the selected agents in the backend and authenticate through their images.", + optional: false, + }, + { + id: "whitelist", + title: "Whitelist setup", + description: "Restrict which GitHub users may trigger ProPR.", + optional: false, + }, + { + id: "repo", + title: "Repository setup", + description: "Optionally connect a first repository to work on.", + optional: true, + }, + { + id: "launch-ui", + title: "Launch UI", + description: "Open the ProPR web UI.", + optional: true, + }, +]; diff --git a/packages/local-setup/tsconfig.json b/packages/local-setup/tsconfig.json new file mode 100644 index 000000000..43ad28167 --- /dev/null +++ b/packages/local-setup/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/local-setup/tsconfig.test.json b/packages/local-setup/tsconfig.test.json new file mode 100644 index 000000000..80b97064a --- /dev/null +++ b/packages/local-setup/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "declaration": false }, + "include": ["src/**/*"], + "exclude": [] +} diff --git a/packages/shared/src/apiOrigin.ts b/packages/shared/src/apiOrigin.ts new file mode 100644 index 000000000..5899f2bc1 --- /dev/null +++ b/packages/shared/src/apiOrigin.ts @@ -0,0 +1,115 @@ +export interface NormalizeProprApiOriginOptions { + /** Browser-hosted development may deliberately opt into non-loopback HTTP. */ + allowInsecureHttp?: boolean; + /** The browser client uses an empty value to mean same-origin. */ + allowEmpty?: boolean; +} + +/** One documented parity table consumed by client, Electron, store and UI tests. */ +export const PROPR_API_ORIGIN_PARITY_CASES = [ + ['https origin', 'https://propr.example.test', 'https://propr.example.test'], + ['https trailing slash', 'https://propr.example.test/', 'https://propr.example.test'], + ['localhost', 'http://localhost:3000', 'http://localhost:3000'], + ['localhost subdomain', 'http://api.dev.localhost:3000', 'http://api.dev.localhost:3000'], + ['IPv4 127/8', 'http://127.42.7.9:3000', 'http://127.42.7.9:3000'], + ['IPv6 loopback', 'http://[::1]:3000', 'http://[::1]:3000'], + ['credentials', 'https://user:secret@propr.example.test', null], + ['path', 'https://propr.example.test/api', null], + ['query', 'https://propr.example.test?token=x', null], + ['fragment', 'https://propr.example.test#x', null], + ['encoded host', 'http://local%68ost:3000', null], + ['trailing dot', 'http://localhost.:3000', null], + ['localhost subdomain trailing dot', 'http://api.dev.localhost.:3000', null], + ['short IPv4', 'http://127.1:3000', null], + ['octal IPv4', 'http://0177.0.0.1:3000', null], + ['hex IPv4', 'http://0x7f000001:3000', null], + ['mapped IPv6', 'http://[::ffff:127.0.0.1]:3000', null], + ['mapped IPv6 over HTTPS', 'https://[::ffff:127.0.0.1]:3000', null], + ['alternate IPv6 spelling', 'https://[0:0:0:0:0:0:0:1]:3000', null], + ['localhost lookalike', 'http://localhost.example.test:3000', null], + ['non-loopback HTTP', 'http://192.168.1.20:3000', null], +] as const; + +const DECIMAL_IPV4 = /^(0|[1-9][0-9]{0,2})(?:\.(0|[1-9][0-9]{0,2})){3}$/; + +const rawHostname = (authority: string): string | null => { + if (!authority || authority.includes('@') || authority.includes('%') || authority.includes('\\')) return null; + if (authority.startsWith('[')) { + const close = authority.indexOf(']'); + if (close < 0 || (authority.slice(close + 1) !== '' && !/^:[0-9]+$/.test(authority.slice(close + 1)))) { + return null; + } + return authority.slice(0, close + 1); + } + if ((authority.match(/:/g) ?? []).length > 1) return null; + return authority.split(':', 1)[0] ?? null; +}; + +/** True only for the deliberately supported, canonical HTTP loopback names. */ +export const isProprLoopbackHostname = (hostname: string): boolean => { + const normalized = hostname.toLowerCase(); + if (normalized === 'localhost' || normalized === '[::1]') return true; + if (normalized.endsWith('.localhost')) { + return normalized.slice(0, -'.localhost'.length).split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label) + ); + } + if (!DECIMAL_IPV4.test(normalized)) return false; + const octets = normalized.split('.').map(Number); + return octets[0] === 127 && octets.every(octet => octet <= 255); +}; + +/** + * Return one canonical HTTP(S) origin, or null. The lexical authority checks + * deliberately run before WHATWG URL parsing so numeric and encoded host + * aliases cannot be canonicalized into a broader credential scope. + */ +export const canonicalProprHttpUrlOrigin = ( + value: string | null | undefined, + options: NormalizeProprApiOriginOptions = {}, +): string | null => { + const candidate = value?.trim() ?? ''; + if (!candidate) return options.allowEmpty ? '' : null; + if (candidate.length > 2_048 || candidate.includes('\\')) return null; + + const lexical = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(candidate); + if (!lexical) return null; + const authorityHostname = rawHostname(lexical[2]); + if (!authorityHostname || authorityHostname.endsWith('.')) return null; + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + + const rawLower = authorityHostname.toLowerCase(); + const parsedLower = parsed.hostname.toLowerCase(); + const rawLooksNumeric = /^[0-9]/.test(rawLower) || rawLower.startsWith('0x') || rawLower.startsWith('['); + if (rawLooksNumeric && parsedLower !== rawLower) return null; + if (parsedLower.startsWith('[::ffff:')) return null; + + if (parsed.protocol === 'http:' + && options.allowInsecureHttp !== true + && !isProprLoopbackHostname(parsed.hostname)) return null; + + // For HTTP, require the exact supported lexical spelling too. This rejects + // expanded/mapped IPv6 and every WHATWG alternate IPv4 representation. + if (parsed.protocol === 'http:' && options.allowInsecureHttp !== true) { + if (rawLower !== parsedLower || !isProprLoopbackHostname(rawLower)) return null; + } + return parsed.origin; +}; + +export const normalizeProprApiOrigin = ( + value: string | null | undefined, + options: NormalizeProprApiOriginOptions = {}, +): string | null => { + const candidate = value?.trim() ?? ''; + if (!candidate) return options.allowEmpty ? '' : null; + if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*\/?$/.test(candidate)) return null; + return canonicalProprHttpUrlOrigin(candidate, options); +}; diff --git a/packages/shared/src/connectDiscovery.ts b/packages/shared/src/connectDiscovery.ts new file mode 100644 index 000000000..30498e241 --- /dev/null +++ b/packages/shared/src/connectDiscovery.ts @@ -0,0 +1,210 @@ +import type { ProprCompatibilityMetadata } from './proprCompatibility.js'; +import { canonicalProprProxyUrl } from './proprServiceUrls.js'; + +export const PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION = 1 as const; +export const PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION = 1 as const; +export const PUBLIC_INSTANCE_IDENTITY_FILENAME = 'public-instance-identity.json'; +export const PROPR_CONNECT_DISCOVERY_MAX_BYTES = 8 * 1024; + +export interface PublicInstanceIdentityDocument { + schemaVersion: typeof PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION; + publicInstanceIdentity: string; +} + +export interface ProprDesktopDiscovery extends ProprCompatibilityMetadata { + schemaVersion: typeof PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION; + product: 'ProPR'; + canonicalEndpoint: string | null; + publicInstanceIdentity: string; +} + +/** UUIDv4 is random, non-secret, bounded, and contains no installation data. */ +export function isPublicInstanceIdentity(value: unknown): value is string { + return typeof value === 'string' + && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value); +} + +export function parsePublicInstanceIdentityDocument(value: unknown): PublicInstanceIdentityDocument | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Record; + if ( + candidate.schemaVersion !== PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION + || !isPublicInstanceIdentity(candidate.publicInstanceIdentity) + ) return null; + return { + schemaVersion: PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION, + publicInstanceIdentity: candidate.publicInstanceIdentity, + }; +} + +const DISCOVERY_KEYS = [ + 'schemaVersion', + 'product', + 'version', + 'apiCompatibility', + 'uiCompatibility', + 'desktopAuthentication', + 'canonicalEndpoint', + 'publicInstanceIdentity', +] as const; +const DESKTOP_AUTHENTICATION_KEYS = [ + 'protocolVersion', + 'browserPairing', + 'instanceBearerTokens', + 'socketIoBearerAuthentication', +] as const; +const MAX_DISCOVERY_SCALAR_LENGTH = 64; +const CANONICAL_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const CANONICAL_COMPATIBILITY = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + return actual.length === expected.length + && actual.every((key, index) => key === [...expected].sort()[index]); +} + +function isCanonicalCompatibility(value: unknown): value is string { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_DISCOVERY_SCALAR_LENGTH + || !CANONICAL_COMPATIBILITY.test(value) + ) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value; +} + +/** Strictly parse the schema-v1 discovery document with desktop-auth protocol v2. */ +export function parseProprDesktopDiscovery(value: unknown): ProprDesktopDiscovery | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Record; + if (!hasExactKeys(candidate, DISCOVERY_KEYS)) return null; + + const authentication = candidate.desktopAuthentication; + if (!authentication || typeof authentication !== 'object' || Array.isArray(authentication)) return null; + const capabilities = authentication as Record; + if (!hasExactKeys(capabilities, DESKTOP_AUTHENTICATION_KEYS)) return null; + + const endpoint = candidate.canonicalEndpoint; + if ( + candidate.schemaVersion !== PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION + || candidate.product !== 'ProPR' + || typeof candidate.version !== 'string' + || candidate.version.length === 0 + || candidate.version.length > MAX_DISCOVERY_SCALAR_LENGTH + || !CANONICAL_SEMVER.test(candidate.version) + || !isCanonicalCompatibility(candidate.apiCompatibility) + || !isCanonicalCompatibility(candidate.uiCompatibility) + || !isPublicInstanceIdentity(candidate.publicInstanceIdentity) + || (endpoint !== null && ( + typeof endpoint !== 'string' + || canonicalProprProxyUrl(endpoint) !== endpoint + )) + || capabilities.protocolVersion !== 2 + || typeof capabilities.browserPairing !== 'boolean' + || typeof capabilities.instanceBearerTokens !== 'boolean' + || typeof capabilities.socketIoBearerAuthentication !== 'boolean' + ) return null; + + return { + schemaVersion: PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + product: 'ProPR', + version: candidate.version, + apiCompatibility: candidate.apiCompatibility, + uiCompatibility: candidate.uiCompatibility, + canonicalEndpoint: endpoint as string | null, + publicInstanceIdentity: candidate.publicInstanceIdentity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: capabilities.browserPairing, + instanceBearerTokens: capabilities.instanceBearerTokens, + socketIoBearerAuthentication: capabilities.socketIoBearerAuthentication, + }, + }; +} + +/** + * Parse discovery from its bounded wire representation. JSON.parse silently + * accepts duplicate object members, so discovery uses this small structural + * pass before the schema parser. Keeping it here makes CLI, client and desktop + * consumers agree on duplicate, size and schema rejection. + */ +export function parseProprDesktopDiscoveryJson(contents: string): ProprDesktopDiscovery | null { + if (typeof contents !== 'string' + || new TextEncoder().encode(contents).byteLength > PROPR_CONNECT_DISCOVERY_MAX_BYTES) return null; + + let offset = 0; + const whitespace = (): void => { + while (offset < contents.length && /[\x20\t\r\n]/.test(contents[offset])) offset += 1; + }; + const stringToken = (): string | null => { + if (contents[offset] !== '"') return null; + const start = offset; + offset += 1; + while (offset < contents.length) { + const character = contents[offset++]; + if (character === '"') { + try { return JSON.parse(contents.slice(start, offset)) as string; } catch { return null; } + } + if (character === '\\') { + const escape = contents[offset++]; + if (escape === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(contents.slice(offset, offset + 4))) return null; + offset += 4; + } else if (!escape || !'"\\/bfnrt'.includes(escape)) return null; + } else if (character.charCodeAt(0) < 0x20) return null; + } + return null; + }; + const value = (): boolean => { + whitespace(); + if (contents[offset] === '{') { + offset += 1; + whitespace(); + const keys = new Set(); + if (contents[offset] === '}') { offset += 1; return true; } + while (offset < contents.length) { + const key = stringToken(); + if (key === null || keys.has(key)) return false; + keys.add(key); + whitespace(); + if (contents[offset++] !== ':') return false; + if (!value()) return false; + whitespace(); + const separator = contents[offset++]; + if (separator === '}') return true; + if (separator !== ',') return false; + whitespace(); + } + return false; + } + if (contents[offset] === '[') { + offset += 1; + whitespace(); + if (contents[offset] === ']') { offset += 1; return true; } + while (offset < contents.length) { + if (!value()) return false; + whitespace(); + const separator = contents[offset++]; + if (separator === ']') return true; + if (separator !== ',') return false; + } + return false; + } + if (contents[offset] === '"') return stringToken() !== null; + const primitive = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/ + .exec(contents.slice(offset))?.[0]; + if (!primitive) return false; + offset += primitive.length; + return true; + }; + + if (!value()) return null; + whitespace(); + if (offset !== contents.length) return null; + try { + return parseProprDesktopDiscovery(JSON.parse(contents) as unknown); + } catch { + return null; + } +} diff --git a/packages/shared/src/desktopPairing.ts b/packages/shared/src/desktopPairing.ts new file mode 100644 index 000000000..fb78067fd --- /dev/null +++ b/packages/shared/src/desktopPairing.ts @@ -0,0 +1,117 @@ +import { normalizeProprApiOrigin } from './apiOrigin.js'; +import { + DEFAULT_PROPR_UI_ORIGIN, + isProprConnectReservedHostAttempt, + parseProprConnectEndpoint, +} from './proprServiceUrls.js'; + +const DESKTOP_PAIRING_ID_PATTERN = /^dpr_[A-Za-z0-9_-]{22}$/; + +export interface DesktopPairingApprovalUrlInput { + /** Canonical API origin returned by endpoint discovery. */ + apiBaseUrl: string; + /** Pairing id returned by the same pairing bootstrap response. */ + pairingId: string; + /** Approval URL returned by the API. Renderer input must never be used here. */ + approvalUrl: string; +} + +const rawAuthority = (value: string): string | null => + /^[a-z][a-z\d+.-]*:\/\/([^/?#]*)/i.exec(value)?.[1] ?? null; + +const bareHttpOrigin = (value: string): URL | null => { + const connectEndpoint = parseProprConnectEndpoint(value); + if (isProprConnectReservedHostAttempt(value) && !connectEndpoint) return null; + const normalized = normalizeProprApiOrigin(value); + if (normalized === null || normalized !== value) return null; + try { + const url = new URL(normalized); + // Callers must supply the already-normalized discovery origin. Binding an + // approval response to a second spelling would reintroduce encoded-host or + // explicit-default-port ambiguity at the browser boundary. + if (value !== url.origin || rawAuthority(value)?.toLowerCase() !== url.host.toLowerCase()) return null; + return url; + } catch { + return null; + } +}; + +const hasExactSearchParameters = (url: URL, names: readonly string[]): boolean => { + const actual = [...url.searchParams.keys()]; + return actual.length === names.length + && names.every(name => actual.filter(candidate => candidate === name).length === 1); +}; + +const hasCanonicalRawSearchParameters = ( + url: URL, + expected: Readonly>, +): boolean => { + const query = url.search.startsWith('?') ? url.search.slice(1) : url.search; + const parameters = query.split('&'); + const names = Object.keys(expected); + if (parameters.length !== names.length) return false; + + const actual = new Map(); + for (const parameter of parameters) { + const separator = parameter.indexOf('='); + if (separator === -1) return false; + const name = parameter.slice(0, separator); + if (!Object.hasOwn(expected, name) || actual.has(name)) return false; + actual.set(name, parameter.slice(separator + 1)); + } + return names.every(name => actual.get(name) === expected[name]); +}; + +/** + * Validate an API-returned browser approval URL against the pairing bootstrap + * that supplied it. Two existing server contracts are accepted: + * + * - the hosted approval page on `https://app.propr.dev/desktop/pairing`, bound + * to the exact verified Connect hostname; and + * - the exact `/api/desktop/pairings//browser` route on the API origin. + * + * No URL is synthesized. Unknown query parameters, fragments, credentials, + * alternate origins, private paths, and pairing ids are rejected. + */ +export function normalizeDesktopPairingApprovalUrl( + input: DesktopPairingApprovalUrlInput, +): string | null { + if (!DESKTOP_PAIRING_ID_PATTERN.test(input.pairingId)) return null; + const apiBase = bareHttpOrigin(input.apiBaseUrl); + if (!apiBase) return null; + + let approval: URL; + try { + approval = new URL(input.approvalUrl); + } catch { + return null; + } + if (approval.username || approval.password || approval.hash) return null; + const approvalAuthority = rawAuthority(input.approvalUrl)?.toLowerCase(); + + const fallbackPath = `/api/desktop/pairings/${input.pairingId}/browser`; + if ( + approval.origin === apiBase.origin + && approvalAuthority === apiBase.host.toLowerCase() + && approval.pathname === fallbackPath + && !approval.search + ) { + return approval.toString(); + } + + const connectEndpoint = parseProprConnectEndpoint(input.apiBaseUrl); + if ( + !connectEndpoint + || approval.origin !== DEFAULT_PROPR_UI_ORIGIN + || approvalAuthority !== new URL(DEFAULT_PROPR_UI_ORIGIN).host + ) return null; + if (approval.pathname !== '/desktop/pairing') return null; + if (!hasExactSearchParameters(approval, ['pairing_id', 'tunnel'])) return null; + if (approval.searchParams.get('pairing_id') !== input.pairingId) return null; + if (approval.searchParams.get('tunnel') !== connectEndpoint.hostname) return null; + if (!hasCanonicalRawSearchParameters(approval, { + pairing_id: input.pairingId, + tunnel: connectEndpoint.hostname, + })) return null; + return approval.toString(); +} diff --git a/packages/shared/src/desktopTokenRevocation.ts b/packages/shared/src/desktopTokenRevocation.ts new file mode 100644 index 000000000..7012d40f6 --- /dev/null +++ b/packages/shared/src/desktopTokenRevocation.ts @@ -0,0 +1,21 @@ +export const DESKTOP_TOKEN_REVOCATION_ENDPOINT = '/api/desktop/tokens/current'; +export const DESKTOP_REVOCATION_BINDING_HEADER = 'X-ProPR-Desktop-Revocation-Binding'; +export const DESKTOP_TOKEN_REVOCATION_SCHEMA = 'propr.desktop-token-revocation'; +export const DESKTOP_TOKEN_REVOCATION_VERSION = 1; + +export const DESKTOP_TOKEN_TERMINAL_CODES = [ + 'TOKEN_NOT_FOUND', + 'INSTANCE_TOKEN_REVOKED', + 'INSTANCE_TOKEN_EXPIRED', +] as const; + +export type DesktopTokenTerminalCode = typeof DESKTOP_TOKEN_TERMINAL_CODES[number]; + +export interface DesktopTokenTerminalRevocation { + schema: typeof DESKTOP_TOKEN_REVOCATION_SCHEMA; + version: typeof DESKTOP_TOKEN_REVOCATION_VERSION; + endpoint: typeof DESKTOP_TOKEN_REVOCATION_ENDPOINT; + terminal: true; + code: DesktopTokenTerminalCode; + credentialGeneration: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 592fa8422..0d15fde8d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -58,6 +58,24 @@ export { DEMO_MODE_READ_ONLY_CODE, parseTruthyEnvValue } from './demoMode.js'; export { MIN_SESSION_SECRET_LENGTH, validateSessionSecret } from './sessionSecret.js'; +export { + canonicalProprHttpUrlOrigin, + isProprLoopbackHostname, + normalizeProprApiOrigin, + PROPR_API_ORIGIN_PARITY_CASES, + type NormalizeProprApiOriginOptions, +} from './apiOrigin.js'; + +export { + DESKTOP_REVOCATION_BINDING_HEADER, + DESKTOP_TOKEN_REVOCATION_ENDPOINT, + DESKTOP_TOKEN_REVOCATION_SCHEMA, + DESKTOP_TOKEN_REVOCATION_VERSION, + DESKTOP_TOKEN_TERMINAL_CODES, + type DesktopTokenTerminalCode, + type DesktopTokenTerminalRevocation, +} from './desktopTokenRevocation.js'; + export { INSTANCE_PERMISSIONS, type AuthenticatedInstanceUser, @@ -112,15 +130,43 @@ export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, DEFAULT_PROPR_UI_ORIGIN, + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + DESKTOP_TRANSPORT_SCOPE_QUERY, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, + MAX_PROPR_API_BASE_URL_LENGTH, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, + canonicalProprProxySelector, + canonicalProprProxyUrl, isValidProprInstanceId, + parseProprConnectEndpoint, + isCanonicalProprConnectHostname, + isProprConnectReservedHostAttempt, + type ProprConnectEndpoint, isProprProxyUrl, proprTunnelEndpoints, } from './proprServiceUrls.js'; +export { + normalizeDesktopPairingApprovalUrl, + type DesktopPairingApprovalUrlInput, +} from './desktopPairing.js'; + +export { + PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, + PROPR_CONNECT_DISCOVERY_MAX_BYTES, + PUBLIC_INSTANCE_IDENTITY_SCHEMA_VERSION, + PUBLIC_INSTANCE_IDENTITY_FILENAME, + isPublicInstanceIdentity, + parseProprDesktopDiscovery, + parseProprDesktopDiscoveryJson, + parsePublicInstanceIdentityDocument, + type PublicInstanceIdentityDocument, + type ProprDesktopDiscovery, +} from './connectDiscovery.js'; + // Export routing URL validation (shared by intake prerequisites and the daemon // routing service so the boot/CLI checks and the dialer agree on one policy) export { validateRoutingUrl } from './validateRoutingUrl.js'; @@ -167,6 +213,7 @@ export { getProprCompatibilityMetadata, evaluateProprApiCompatibility, type ProprCompatibilityMetadata, + type ProprDesktopAuthenticationCapabilities, type ProprApiCompatibilityInput, type ProprApiCompatibilityResult, } from './proprCompatibility.js'; diff --git a/packages/shared/src/proprCompatibility.ts b/packages/shared/src/proprCompatibility.ts index 4b7ccc176..ba6348137 100644 --- a/packages/shared/src/proprCompatibility.ts +++ b/packages/shared/src/proprCompatibility.ts @@ -18,6 +18,14 @@ export interface ProprCompatibilityMetadata { version: string; apiCompatibility: string; uiCompatibility: string; + desktopAuthentication: ProprDesktopAuthenticationCapabilities; +} + +export interface ProprDesktopAuthenticationCapabilities { + protocolVersion: 2; + browserPairing: boolean; + instanceBearerTokens: boolean; + socketIoBearerAuthentication: boolean; } export interface ProprApiCompatibilityInput { @@ -39,11 +47,17 @@ export type ProprApiCompatibilityResult = message: string; }; -export function getProprCompatibilityMetadata(): ProprCompatibilityMetadata { +export function getProprCompatibilityMetadata(desktopAuthenticationEnabled = true): ProprCompatibilityMetadata { return { version: PROPR_VERSION, apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: desktopAuthenticationEnabled, + instanceBearerTokens: desktopAuthenticationEnabled, + socketIoBearerAuthentication: desktopAuthenticationEnabled, + }, }; } diff --git a/packages/shared/src/proprServiceUrls.ts b/packages/shared/src/proprServiceUrls.ts index 447573de9..670616e23 100644 --- a/packages/shared/src/proprServiceUrls.ts +++ b/packages/shared/src/proprServiceUrls.ts @@ -35,6 +35,18 @@ export const DEFAULT_PROPR_GH_RELAY_URL = 'https://webhook.propr.dev/v1'; */ export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev'; +/** + * Exact browser origin used by the packaged Electron renderer. The API uses + * this value as a narrow CORS exception for desktop REST and Socket.IO calls. + */ +export const DESKTOP_RENDERER_ORIGIN = 'propr-app://renderer'; + +/** Opaque activation binding carried by packaged renderer REST requests. */ +export const DESKTOP_TRANSPORT_SCOPE_HEADER = 'X-ProPR-Desktop-Transport-Scope'; + +/** Opaque activation binding carried by packaged renderer Socket.IO upgrades. */ +export const DESKTOP_TRANSPORT_SCOPE_QUERY = 'proprDesktopTransportScope'; + /** * DNS suffix and label prefix for per-instance UI/API tunnel hostnames. Each * local stack with an instance id is reachable at @@ -43,6 +55,18 @@ export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev'; */ export const PROPR_UI_PROXY_SUFFIX = 'propr.dev'; export const PROPR_UI_PROXY_LABEL_PREFIX = 't-'; +export const MAX_PROPR_API_BASE_URL_LENGTH = 2048; + +const CANONICAL_PROPR_CONNECT_HOST_PATTERN = + /^(t-([a-z0-9]|[a-z0-9][a-z0-9-]{0,59}[a-z0-9]))\.propr\.dev$/; + +/** A verified, canonical ProPR Connect API origin. */ +export interface ProprConnectEndpoint { + kind: 'propr-connect'; + origin: string; + hostname: string; + instanceId: string; +} /** * Default Cloudflare Tunnel image used to expose the local stack's UI/API to @@ -56,13 +80,13 @@ export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2'; /** * Whether an instance id is usable as a single DNS label in the per-instance * proxy hostname (`t-.propr.dev`). Enforces the standard label rules: - * 1–63 characters, ASCII letters/digits/hyphens only, and no leading or - * trailing hyphen. This rejects spaces, slashes, dots, underscores, and other - * characters that would produce an invalid or ambiguous hostname. + * 1–61 characters (leaving room for the `t-` prefix), ASCII + * letters/digits/hyphens only, and no leading or trailing hyphen. This rejects + * values that would produce an invalid or ambiguous complete DNS label. */ export function isValidProprInstanceId(instanceId: string | undefined | null): boolean { const id = (instanceId ?? '').trim(); - return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id); + return /^[a-z0-9]([a-z0-9-]{0,59}[a-z0-9])?$/i.test(id); } /** @@ -82,42 +106,161 @@ export function proprInstanceProxyUrl(instanceId: string | undefined | null): st return `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`; } +/** + * Normalize one scheme-less Connect tunnel selector. Connect deep links carry + * only the DNS hostname (`t-.propr.dev`), never a URL or a bare instance + * id. Every spelling must already be exact, including lowercase DNS case: + * no whitespace, percent encoding, userinfo, port, path, query, fragment, + * trailing dot, extra label, or non-ASCII character is accepted. + */ +export function canonicalProprProxySelector(selector: string | undefined | null): string | undefined { + if (!selector || selector !== selector.trim() || /[^\x20-\x7e]/.test(selector)) return undefined; + const normalized = selector.toLowerCase(); + const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; + if (!normalized.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) || !normalized.endsWith(suffix)) { + return undefined; + } + const label = normalized.slice(0, -suffix.length); + if (label.length > 63 || label.includes('.')) return undefined; + const id = label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length); + return isValidProprInstanceId(id) + && /^[a-z0-9.-]+$/.test(selector) + && selector === normalized + ? selector + : undefined; +} + +/** + * Return the one canonical Connect proxy origin, or undefined for anything + * else. The raw value must be ASCII and carry no userinfo, port, path, query, + * fragment, IDNA spelling, or alternate DNS representation. This is the + * authority parser used by setup, local discovery, and remote identity checks. + */ +export function canonicalProprProxyUrl(url: string | undefined | null): string | undefined { + if (!url || url !== url.trim() || /[^\x20-\x7e]/.test(url)) return undefined; + try { + const parsed = new URL(url); + if ( + parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.port !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + ) return undefined; + + const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; + if (!parsed.hostname.endsWith(suffix)) return undefined; + const label = parsed.hostname.slice(0, -suffix.length); + if ( + label.length > 63 + || label.includes('.') + || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) + ) return undefined; + const id = label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length); + if (!isValidProprInstanceId(id)) return undefined; + + const canonical = `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`; + // Compare the raw spelling: URL parsing must not normalize case or any + // number of trailing slashes into authority at this trust boundary. + return url === canonical ? canonical : undefined; + } catch { + return undefined; + } +} + /** * Whether a URL is a hosted per-instance proxy URL (`https://t-.propr.dev`). * propr-routing only forwards `/api/*` and `/socket.io/*` on these hosts, so the * tunnel base URL must be one of them. Requires https and *exactly one* valid * `t-` label in front of the shared {@link PROPR_UI_PROXY_SUFFIX}. * Other propr.dev hosts like `app.propr.dev` and nested hosts are rejected. It - * must also be a bare origin: a non-root path, query, or fragment (e.g. + * must also be the exact raw bare origin: a slash, path, query, or fragment (e.g. * `https://t-abc.propr.dev/api`) is rejected because * {@link proprTunnelEndpoints} appends `/api/...` itself and a base path would * double it up (`.../api/api/status`). Returns false for a malformed URL. */ -export function isProprProxyUrl(url: string | undefined | null): boolean { - if (!url) return false; - try { - const { protocol, hostname, pathname, search, hash } = new URL(url); - if (protocol !== 'https:') return false; - // Must be a bare origin — the tunnel endpoint helpers own the path suffix. - // Trailing slashes (`/`, `//`) are tolerated (callers trim them); any real - // path segment, query, or fragment is rejected so a base path can't double - // up the appended `/api/...`. - if (/[^/]/.test(pathname) || search || hash) return false; +export function parseProprConnectEndpoint(url: string | undefined | null): ProprConnectEndpoint | null { + if (typeof url !== 'string' || url.length > MAX_PROPR_API_BASE_URL_LENGTH) return null; + // Trust only one byte-for-byte spelling. Avoid URL parsing before this match: + // WHATWG normalization would erase case, default ports, escapes, IDNA input, + // repeated slashes, and other distinctions that are security-significant for + // the reserved Connect namespace. + const match = /^https:\/\/(t-(?:[a-z0-9]|[a-z0-9][a-z0-9-]{0,59}[a-z0-9])\.propr\.dev)$/.exec(url); + if (!match) return null; + const hostname = match[1]; + const hostMatch = CANONICAL_PROPR_CONNECT_HOST_PATTERN.exec(hostname); + if (!hostMatch) return null; + return { + kind: 'propr-connect', + origin: url, + hostname, + instanceId: hostMatch[2], + }; +} + +/** Whether a raw host is the exact lowercase ASCII Connect shorthand. */ +export function isCanonicalProprConnectHostname(hostname: string | undefined | null): boolean { + return typeof hostname === 'string' + && hostname.length <= 253 + && CANONICAL_PROPR_CONNECT_HOST_PATTERN.test(hostname); +} + +/** + * Whether an absolute URL is trying to address the reserved ProPR Connect DNS + * namespace. This deliberately recognizes noncanonical spellings so a failed + * strict Connect parse cannot fall through and acquire ordinary remote-origin + * behavior. It does not reserve suffix lookalikes outside `*.propr.dev`. + */ +export function isProprConnectReservedHostAttempt(url: string | undefined | null): boolean { + if (typeof url !== 'string' || !url || url.length > MAX_PROPR_API_BASE_URL_LENGTH) return false; + if (parseProprConnectEndpoint(url)) return true; + + const isReservedHostname = (hostname: string): boolean => { + const normalized = hostname.toLowerCase().replace(/\.+$/, ''); const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; - if (!hostname.endsWith(suffix)) return false; - const label = hostname.slice(0, -suffix.length); - if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) { - return false; - } - return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)); + if (!normalized.endsWith(suffix)) return false; + const labels = normalized.slice(0, -suffix.length).split('.'); + return labels.some(label => label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)); + }; + + try { + if (isReservedHostname(new URL(url).hostname)) return true; + } catch { + // Raw authority inspection below still catches malformed reserved attempts. + } + + const authority = /^[a-z][a-z\d+.-]*:\/\/([^/?#]*)/i.exec(url)?.[1]; + if (!authority) return false; + const spellings = [authority]; + try { + const decoded = decodeURIComponent(authority); + if (decoded !== authority) spellings.push(decoded); } catch { - return false; + // A malformed escape cannot become a canonical endpoint, but the literal + // spelling can still identify an attempted reserved hostname. } + return spellings.some(spelling => spelling + .split('@') + .flatMap(part => part.split('\\')) + .some(part => isReservedHostname(part.replace(/:\d+$/, '')))); +} + +/** + * Whether a URL is the exact hosted endpoint shape used by ProPR Connect. + * + * The legacy function name remains part of the tunnel configuration contract; + * new desktop-facing code should prefer {@link parseProprConnectEndpoint} so + * user-visible copy can consistently use the ProPR Connect name. + */ +export function isProprProxyUrl(url: string | undefined | null): boolean { + return parseProprConnectEndpoint(url) !== null; } function normalizeProprInstanceId(instanceId: string | undefined | null): string { const id = (instanceId ?? '').trim(); - return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) + return id.toLowerCase().startsWith(PROPR_UI_PROXY_LABEL_PREFIX) ? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length) : id; } diff --git a/propr-ui/README.md b/propr-ui/README.md index 1a0543e75..ff82e4dd7 100644 --- a/propr-ui/README.md +++ b/propr-ui/README.md @@ -51,6 +51,26 @@ npm run dev The application will be available at `http://localhost:5173` +### Desktop presentation fixtures + +Desktop mode is enabled explicitly by the typed `window.__PROPR_DESKTOP__` +preload bridge. The normal hosted and self-hosted web UI never relies on user +agent detection and continues to use the standard presentation. + +For browser-based development and deterministic screenshots, open one of these +fixture URLs after starting Vite: + +- `/?desktop-fixture=first-run` +- `/?desktop-fixture=recents` +- `/?desktop-fixture=offline` +- `/?desktop-fixture=incompatible` +- `/?desktop-fixture=connected` + +The preload-facing adapter contract lives in `src/desktop/types.ts`. Browser +fixtures implement the same profile persistence, discovery, authentication, +external-browser, local-setup, and connection interfaces without exposing host +commands to React. + ### Building for Production ```bash diff --git a/propr-ui/package.json b/propr-ui/package.json index c7b660a27..9c41ae965 100644 --- a/propr-ui/package.json +++ b/propr-ui/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@propr/client": "*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -33,8 +34,7 @@ "react-textarea-autosize": "^8.5.9", "recharts": "^3.6.0", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.0", - "socket.io-client": "^4.7.5" + "remark-gfm": "^4.0.0" }, "devDependencies": { "@eslint/js": "^9.30.1", diff --git a/propr-ui/postcss.config.js b/propr-ui/postcss.config.js index e99ebc2c0..46297d023 100644 --- a/propr-ui/postcss.config.js +++ b/propr-ui/postcss.config.js @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; + export default { plugins: { - tailwindcss: {}, + tailwindcss: { + config: fileURLToPath(new URL('./tailwind.config.js', import.meta.url)), + }, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/propr-ui/src/App.hostedCompletion.test.tsx b/propr-ui/src/App.hostedCompletion.test.tsx index 191c8b078..32bc79ce0 100644 --- a/propr-ui/src/App.hostedCompletion.test.tsx +++ b/propr-ui/src/App.hostedCompletion.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from './App'; const runtimeConfigMock = vi.hoisted(() => ({ + getRuntimeApiBaseUrlState: vi.fn(() => ({ apiBaseUrl: '', issue: null })), hostedUiConnectionIssue: vi.fn(), isHostedOAuthCompletionRoute: vi.fn(), })); @@ -23,6 +24,7 @@ const ioMock = vi.hoisted(() => vi.fn(() => ({ vi.mock('./config/runtimeConfig', () => ({ getApiBaseUrl: vi.fn(() => ''), + getRuntimeApiBaseUrlState: runtimeConfigMock.getRuntimeApiBaseUrlState, hostedUiConnectionIssue: runtimeConfigMock.hostedUiConnectionIssue, isHostedOAuthCompletionRoute: runtimeConfigMock.isHostedOAuthCompletionRoute, isHostedUiOrigin: vi.fn(() => true), @@ -53,6 +55,7 @@ describe('hosted OAuth completion route', () => { pathname === '/login' && new URLSearchParams(search).get('oauth_complete') === 'true' ); runtimeConfigMock.hostedUiConnectionIssue.mockReturnValue({ + code: 'HOSTED_STACK_REQUIRED', title: 'Connect a ProPR stack', message: 'This hosted UI needs a selected local stack before it can make API calls.', }); diff --git a/propr-ui/src/App.invalidConfiguration.test.tsx b/propr-ui/src/App.invalidConfiguration.test.tsx new file mode 100644 index 000000000..914c95c5c --- /dev/null +++ b/propr-ui/src/App.invalidConfiguration.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const sentinels = [ + 'https://user:password-sentinel@t-invalid.propr.dev', + 'https://t-invalid.propr.dev?token=query-token-sentinel', + `https://example.test/${'private-path-sentinel'.repeat(200)}`, + 'this is not a URL malformed-url-sentinel', +]; + +describe('invalid eager API configuration', () => { + afterEach(() => { + cleanup(); + delete window.__PROPR_CONFIG__; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it.each(sentinels)('renders a bounded safe connection screen without leaking configured input', async configured => { + vi.resetModules(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + window.__PROPR_CONFIG__ = { apiBaseUrl: configured }; + + const runtimeConfig = await import('./config/runtimeConfig'); + console.warn(runtimeConfig.runtimeConfigWarning('app.propr.dev', window.__PROPR_CONFIG__)); + const apiClient = await import('./api/apiClient'); + expect(apiClient.proprClient).toBeNull(); + let thrown: unknown; + try { apiClient.getProprClient(); } catch (error) { thrown = error; } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe('The ProPR connection configuration is invalid.'); + + const { default: App } = await import('./App'); + render(); + + expect(screen.getByRole('heading', { name: 'Invalid ProPR configuration' })).toBeInTheDocument(); + const visible = document.body.textContent || ''; + const diagnostics = JSON.stringify([ + ...warn.mock.calls, + ...error.mock.calls, + thrown, + ]); + for (const secret of ['password-sentinel', 'query-token-sentinel', 'private-path-sentinel', 'malformed-url-sentinel']) { + expect(visible).not.toContain(secret); + expect(diagnostics).not.toContain(secret); + } + expect(visible.length).toBeLessThan(1000); + }); +}); diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 76cebf0f3..5f64329be 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -1,5 +1,5 @@ import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' -import { BrowserRouter as Router, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom' +import { BrowserRouter, HashRouter, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom' import Layout from './components/Layout' import { ToastProvider } from './components/ui/Toast' import { SocketProvider } from './contexts/SocketProvider' @@ -11,6 +11,7 @@ import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from './api/prop import { checkProprApiCompatibility, ProprCompatibilityCheckError } from './api/compatibility' import { hostedUiConnectionIssue, + getRuntimeApiBaseUrlState, isHostedOAuthCompletionRoute, isHostedUiOrigin, pathWithActiveHostedTunnelFlow, @@ -21,6 +22,10 @@ import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary' import { ConnectAccountProvider } from './contexts/ConnectAccountContext' import { BrowserPushProvider } from './hooks/useBrowserPush' import { NotificationCenterProvider } from './contexts/NotificationCenterContext' +import { currentUiPathname, isDesktopRuntime, publicAssetUrl } from './config/runtimeMode' +import { DesktopPresentationBoundary } from './desktop/DesktopPresentationBoundary' + +const Router = isDesktopRuntime() ? HashRouter : BrowserRouter; const AiAgentsPage = lazy(() => import('./pages/AiAgentsPage')) const AccessManagementPage = lazy(() => import('./pages/AccessManagementPage')) @@ -28,6 +33,7 @@ const Dashboard = lazy(() => import('./components/Dashboard')) const LlmLogsPage = lazy(() => import('./pages/LlmLogsPage')) const InboxPage = lazy(() => import('./pages/InboxPage')) const LoginPage = lazy(() => import('./pages/LoginPage')) +const DesktopPairingPage = lazy(() => import('./pages/DesktopPairingPage')) const PlansPage = lazy(() => import('./pages/PlansPage')) const PlanStudioPage = lazy(() => import('./pages/PlanStudioPage')) const RepositoriesPage = lazy(() => import('./pages/RepositoriesPage')) @@ -36,10 +42,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')) const SummaryBrowserPage = lazy(() => import('./pages/SummaryBrowserPage')) const TasksPage = lazy(() => import('./pages/TasksPage')) -type CompatibilityState = - | { status: 'checking' } - | { status: 'ready' } - | { status: 'blocked'; title: string; message: string }; +type CompatibilityState = { status: 'checking' } | { status: 'ready' } | { status: 'blocked'; title: string; message: string }; const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000; @@ -92,7 +95,7 @@ const HostedConnectionBlocked: React.FC<{ title: string; message: string }> = ({ const HostedOAuthCompletion: React.FC = () => (
- ProPR + ProPR

GitHub sign-in complete

You can close this window and return to ProPR.

@@ -141,7 +144,7 @@ export const NotFoundRouteContent: React.FC<{ hostname?: string }> = ({ hostname const AppContent: React.FC = () => { const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode(); // Auth check state - start loading unless already on login page - const [isLoading, setIsLoading] = useState(window.location.pathname !== '/login'); + const [isLoading, setIsLoading] = useState(currentUiPathname() !== '/login'); const [currentUser, setCurrentUser] = useState(null); const refreshPromiseRef = useRef | null>(null); @@ -162,7 +165,7 @@ const AppContent: React.FC = () => { const checkSession = async () => { // Don't check if we are already on login page - if (window.location.pathname === '/login') { + if (currentUiPathname() === '/login') { setIsLoading(false); return; } @@ -195,7 +198,7 @@ const AppContent: React.FC = () => { }, [refreshCurrentUser]); useEffect(() => { - if (isDemoMode || window.location.pathname === '/login') return; + if (isDemoMode || currentUiPathname() === '/login') return; const refreshAuthorization = () => { if (document.visibilityState === 'hidden') return; void refreshCurrentUser().catch(error => { @@ -235,6 +238,7 @@ const AppContent: React.FC = () => { }> } /> + } /> } /> { ); }; -const App: React.FC = () => { +const WebApp: React.FC = () => { // The compatibility gate only applies to the hosted UI — a single static bundle // serving many per-instance proxies, where the UI and API are versioned // independently. On a local/self-hosted origin the UI and API ship together, so @@ -374,7 +378,7 @@ const App: React.FC = () => { ); const connectionIssue = isHostedOAuthCompletion ? null - : hostedUiConnectionIssue( + : getRuntimeApiBaseUrlState().issue ?? hostedUiConnectionIssue( window.location.hostname, window.__PROPR_CONFIG__, window.location.search @@ -382,7 +386,6 @@ const App: React.FC = () => { const [compatibility, setCompatibility] = useState( isHosted && !isHostedOAuthCompletion && !connectionIssue ? { status: 'checking' } : { status: 'ready' } ); - useEffect(() => { if (!isHosted || isHostedOAuthCompletion || connectionIssue) return; let cancelled = false; @@ -452,4 +455,4 @@ const App: React.FC = () => { ) } -export default App +export default function App() { return } desktop={} />; } diff --git a/propr-ui/src/api/apiClient.ts b/propr-ui/src/api/apiClient.ts index f76d203b8..b12397f58 100644 --- a/propr-ui/src/api/apiClient.ts +++ b/propr-ui/src/api/apiClient.ts @@ -1,7 +1,85 @@ -import { DEMO_MODE_READ_ONLY_CODE } from '@propr/shared'; -import { getApiBaseUrl, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig'; +import { DEMO_MODE_READ_ONLY_CODE, DESKTOP_TRANSPORT_SCOPE_HEADER } from '@propr/shared'; +import { normalizeApiBaseUrl, ProprClient, ProprClientError } from '@propr/client'; +import type { DesktopBridge } from '../../../apps/desktop/src/shared/contract'; +import { getRuntimeApiBaseUrlState, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig'; +import { currentUiPathname, isDesktopRuntime, navigateToUiPath } from '../config/runtimeMode'; +import { DESKTOP_ACCESS_INVALID_EVENT } from '../desktop/types'; -export const API_BASE_URL = getApiBaseUrl(); +export interface DesktopConnectionScope { + bridge: DesktopBridge; + profileId: string; + transportScope: string; +} + +let desktopConnectionScope: DesktopConnectionScope | null = null; +const desktopScopeListeners = new Set<() => void>(); +const responseScopes = new WeakMap(); +const DEFINITIVE_INSTANCE_TOKEN_CODES = new Set([ + 'INVALID_INSTANCE_TOKEN', + 'INSTANCE_TOKEN_EXPIRED', + 'INSTANCE_TOKEN_REVOKED', +]); +const AUTHORIZATION_CHANGE_CODES = new Set([ + 'AUTHORIZATION_CHANGED', + 'USER_NOT_WHITELISTED', + 'INSUFFICIENT_INSTANCE_PERMISSION', +]); + +const createProprClient = (baseUrl: string): ProprClient => new ProprClient({ + baseUrl, + authentication: isDesktopRuntime() + ? { type: 'none' } + : { type: 'session', applyByDefault: false }, +}); + +const initialApiConfiguration = getRuntimeApiBaseUrlState(); + +export let API_BASE_URL = initialApiConfiguration.apiBaseUrl; +// This live binding is null only while hosted runtime configuration is blocked; +// callers that can run in that state must use getProprClient(). Packaged desktop +// transport is initialized only after a valid activation and can use the binding. +export let proprClient: ProprClient = initialApiConfiguration.issue + ? null as never + : createProprClient(API_BASE_URL); + +export const getProprClient = (): ProprClient => { + if (proprClient) return proprClient; + throw new ProprClientError('The ProPR connection configuration is invalid.', { + kind: 'configuration', + code: 'INVALID_RUNTIME_CONFIGURATION', + }); +}; + +/** Update the live bindings used by existing API modules when desktop profiles switch. */ +export const setApiBaseUrl = (value: string): void => { + const nextApiBaseUrl = normalizeApiBaseUrl(value); + const nextProprClient = createProprClient(nextApiBaseUrl); + API_BASE_URL = nextApiBaseUrl; + proprClient = nextProprClient; + desktopScopeListeners.forEach(listener => listener()); +}; + +export const setDesktopConnectionScope = ( + scope: DesktopConnectionScope | null, + apiBaseUrl?: string, +): void => { + const nextApiBaseUrl = apiBaseUrl === undefined ? API_BASE_URL : normalizeApiBaseUrl(apiBaseUrl); + const nextProprClient = createProprClient(nextApiBaseUrl); + API_BASE_URL = nextApiBaseUrl; + desktopConnectionScope = scope; + proprClient = nextProprClient; + desktopScopeListeners.forEach(listener => listener()); +}; + +export const getDesktopConnectionScope = (): DesktopConnectionScope | null => desktopConnectionScope; +export const subscribeDesktopConnectionScope = (listener: () => void): (() => void) => { + desktopScopeListeners.add(listener); + return () => desktopScopeListeners.delete(listener); +}; +export const getDesktopSocketConfigurationKey = (): string => { + const scope = desktopConnectionScope; + return `${isDesktopRuntime() ? 'desktop' : 'browser'}\u0000${API_BASE_URL}\u0000${scope?.profileId ?? ''}\u0000${scope?.transportScope ?? ''}`; +}; export const INSTANCE_AUTHORIZATION_CHANGED_EVENT = 'propr:instance-authorization-changed'; const TOKEN_REFRESHED_CODE = 'TOKEN_REFRESHED'; const SAFE_PUBLIC_ERROR_CODES = new Set(['AGENT_VERSION_LOOKUP_UNAVAILABLE']); @@ -94,14 +172,61 @@ const parseApiErrorBody = async (response: Response): Promise data?.message || data?.error; -const throwUnauthorizedResponse = (data: ApiErrorBody | null): never => { +const isCurrentDesktopScope = (scope: DesktopConnectionScope | null): boolean => { + if (!scope) return !isDesktopRuntime(); + return desktopConnectionScope?.profileId === scope.profileId + && desktopConnectionScope.transportScope === scope.transportScope; +}; + +const scopeForResponse = (response: Response): DesktopConnectionScope | null => + responseScopes.has(response) ? responseScopes.get(response) ?? null : desktopConnectionScope; + +export const handleDesktopAccessCode = async ( + code: string | undefined, + scope: DesktopConnectionScope | null, +): Promise<'invalidated' | 'authorization-changed' | 'retryable'> => { + if (!code) return 'retryable'; + if (AUTHORIZATION_CHANGE_CODES.has(code)) { + if (!isCurrentDesktopScope(scope)) return 'retryable'; + window.dispatchEvent(new Event(INSTANCE_AUTHORIZATION_CHANGED_EVENT)); + return 'authorization-changed'; + } + if (!scope) return 'retryable'; + if (DEFINITIVE_INSTANCE_TOKEN_CODES.has(code)) { + const result = await scope.bridge.connection.invalidate({ + profileId: scope.profileId, + transportScope: scope.transportScope, + code, + }); + if (result.invalidated && isCurrentDesktopScope(scope)) { + window.dispatchEvent(new CustomEvent(DESKTOP_ACCESS_INVALID_EVENT, { + detail: { + profileId: scope.profileId, + transportScope: scope.transportScope, + code, + }, + })); + return 'invalidated'; + } + return 'retryable'; + } + return 'retryable'; +}; + +const throwUnauthorizedResponse = async (data: ApiErrorBody | null, response: Response): Promise => { if (data?.code === TOKEN_REFRESHED_CODE) { throw new TokenRefreshRetryRequiredError(getApiErrorMessage(data)); } - if (window.location.pathname === '/login') throw new Error('Authentication required'); + if (isDesktopRuntime()) { + await handleDesktopAccessCode(data?.code, scopeForResponse(response)); + throw new Error(data?.code === 'INVALID_INSTANCE_TOKEN' + ? 'This desktop connection was revoked or expired.' + : 'Desktop authentication is required.'); + } + if (currentUiPathname() === '/login') throw new Error('Authentication required'); // Preserve only the validated active flow so login/OAuth cannot be driven by // arbitrary raw URL input or copied sessionStorage. - window.location.href = pathWithActiveHostedTunnelFlow('/login'); + navigateToUiPath(pathWithActiveHostedTunnelFlow('/login')); throw new Error('Authentication required'); }; @@ -125,13 +250,37 @@ const isReplayableApiRequest = ( && (init?.body == null || typeof init.body === 'string'); }; +const scopedRequestInit = ( + input: RequestInfo | URL, + init: RequestInit | undefined, + scope: DesktopConnectionScope | null, +): RequestInit | undefined => { + if (!scope) return init; + const headers = new Headers(typeof Request !== 'undefined' && input instanceof Request + ? input.headers + : undefined); + new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); + headers.set(DESKTOP_TRANSPORT_SCOPE_HEADER, scope.transportScope); + return { ...init, headers }; +}; + export const apiFetch = async ( input: RequestInfo | URL, init?: RequestInit, options: ApiFetchOptions = {} ): Promise => { - const response = await fetch(input, init); - if (isReplayableApiRequest(input, init, options) && await shouldRetryAfterTokenRefresh(response)) return fetch(input, init); + const requestScope = desktopConnectionScope; + const requestClient = getProprClient(); + const requestInit = scopedRequestInit(input, init, requestScope); + const response = await requestClient.fetch(input, requestInit); + responseScopes.set(response, requestScope); + if (isReplayableApiRequest(input, init, options) + && await shouldRetryAfterTokenRefresh(response) + && isCurrentDesktopScope(requestScope)) { + const retried = await requestClient.fetch(input, requestInit); + responseScopes.set(retried, requestScope); + return retried; + } return response; }; @@ -139,14 +288,14 @@ export const handleApiResponse = async (response: Response): Promise = if (response.ok) return response; const data = await parseApiErrorBody(response); - if (response.status === 401) throwUnauthorizedResponse(data); + if (response.status === 401) return await throwUnauthorizedResponse(data, response); const errorMessage = getApiErrorMessage(data); if (data?.code === DEMO_MODE_READ_ONLY_CODE) { throw new DemoModeReadOnlyError(errorMessage); } if (data?.code === 'INSUFFICIENT_INSTANCE_PERMISSION') { - window.dispatchEvent(new Event(INSTANCE_AUTHORIZATION_CHANGED_EVENT)); + await handleDesktopAccessCode(data.code, scopeForResponse(response)); } if (data?.committed === true) { throw new CommittedConfigWriteError(response.status, { diff --git a/propr-ui/src/api/compatibility.ts b/propr-ui/src/api/compatibility.ts index 98a0a791c..d3c332eec 100644 --- a/propr-ui/src/api/compatibility.ts +++ b/propr-ui/src/api/compatibility.ts @@ -1,11 +1,8 @@ import { - evaluateProprApiCompatibility, type ProprApiCompatibilityResult, - type ProprCompatibilityMetadata, } from '@propr/shared'; -import { getApiBaseUrl } from '../config/runtimeConfig'; - -const API_BASE_URL = getApiBaseUrl(); +import { isProprClientError } from '@propr/client'; +import { getProprClient } from './apiClient'; // Bound the pre-render compatibility probe so a slow/unreachable API can't trap // the user on a spinner waiting out the browser's default fetch timeout. On @@ -21,34 +18,25 @@ export class ProprCompatibilityCheckError extends Error { } export async function checkProprApiCompatibility(): Promise { - let response: Response; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), COMPATIBILITY_CHECK_TIMEOUT_MS); try { - response = await fetch(`${API_BASE_URL}/api/compatibility`, { - credentials: 'include', - cache: 'no-store', - signal: controller.signal, + return await getProprClient().negotiateCompatibility({ + timeoutMs: COMPATIBILITY_CHECK_TIMEOUT_MS, }); - } catch { - throw new ProprCompatibilityCheckError('Cannot reach the local ProPR API. Check that the stack is running and the tunnel is connected.'); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - if (response.status === 404) { - return evaluateProprApiCompatibility({}); + } catch (error) { + if (isProprClientError(error)) { + if (error.kind === 'http') { + throw new ProprCompatibilityCheckError( + `Cannot check local ProPR compatibility: HTTP ${error.status}.` + ); + } + if (error.kind === 'invalid_response') { + throw new ProprCompatibilityCheckError( + 'The local ProPR API returned invalid compatibility metadata.' + ); + } } - throw new ProprCompatibilityCheckError(`Cannot check local ProPR compatibility: HTTP ${response.status}.`); + throw new ProprCompatibilityCheckError( + 'Cannot reach the local ProPR API. Check that the stack is running and the tunnel is connected.' + ); } - - let metadata: Partial; - try { - metadata = await response.json() as Partial; - } catch { - throw new ProprCompatibilityCheckError('The local ProPR API returned invalid compatibility metadata.'); - } - - return evaluateProprApiCompatibility(metadata); } diff --git a/propr-ui/src/api/demoMode.test.ts b/propr-ui/src/api/demoMode.test.ts index 9f7cca722..316a2ec0c 100644 --- a/propr-ui/src/api/demoMode.test.ts +++ b/propr-ui/src/api/demoMode.test.ts @@ -1,19 +1,35 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEMO_MODE_READ_ONLY_CODE } from '@propr/shared'; +import { DEMO_MODE_READ_ONLY_CODE, PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; import { apiFetch, CommittedConfigWriteError, getDemoModeStatus, handleApiResponse, + handleDesktopAccessCode, INSTANCE_AUTHORIZATION_CHANGED_EVENT, + API_BASE_URL, + setApiBaseUrl, + setDesktopConnectionScope, TokenRefreshRetryRequiredError, } from './proprApi'; describe('demo mode API helpers', () => { afterEach(() => { + setDesktopConnectionScope(null); + setApiBaseUrl(''); vi.restoreAllMocks(); }); + it('applies the shared canonical origin parity table to REST and Socket.IO client configuration', () => { + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + if (expected === null) expect(() => setApiBaseUrl(input), name).toThrow(); + else { + setApiBaseUrl(input); + expect(API_BASE_URL, name).toBe(expected); + } + } + }); + it('discovers demo mode from the backend metadata endpoint', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ demoMode: true }), { @@ -95,6 +111,42 @@ describe('demo mode API helpers', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('does not replay profile A work with profile B after a same-origin scope switch', async () => { + let parsingStarted!: () => void; + let releaseParsing!: () => void; + const started = new Promise(resolve => { parsingStarted = resolve; }); + const released = new Promise(resolve => { releaseParsing = resolve; }); + const refreshed = new Response(JSON.stringify({ code: 'TOKEN_REFRESHED' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + vi.spyOn(refreshed, 'clone').mockReturnValue({ + json: async () => { + parsingStarted(); + await released; + return { code: 'TOKEN_REFRESHED' }; + }, + } as Response); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(refreshed); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + + const pending = apiFetch('/api/tasks'); + await started; + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-b', + transportScope: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + releaseParsing(); + + await expect(pending).resolves.toBe(refreshed); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('surfaces an unreplayed token refresh as retry-required without logging out', async () => { const response = new Response(JSON.stringify({ code: 'TOKEN_REFRESHED', @@ -124,7 +176,7 @@ describe('demo mode API helpers', () => { headers: { 'Content-Type': 'application/json' }, })); - const request = new Request('http://localhost/api/github/repos'); + const request = new Request(new URL('/api/github/repos', window.location.origin)); const response = await apiFetch(request); expect(response.status).toBe(200); @@ -133,6 +185,39 @@ describe('demo mode API helpers', () => { expect(fetchMock).toHaveBeenNthCalledWith(2, request, undefined); }); + it('preserves Request and init headers plus the captured scope on retry', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ code: 'TOKEN_REFRESHED' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'SSSSSSSSSSSSSSSSSSSSSS', + }); + const request = new Request(new URL('/api/tasks', window.location.origin), { + headers: { 'X-From-Request': 'request', Authorization: 'Bearer renderer' }, + }); + + await apiFetch(request, { + credentials: 'include', + headers: { 'X-From-Init': 'init', Cookie: 'renderer=session' }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const [input, init] of fetchMock.mock.calls) { + expect(input).toBe(request); + const headers = new Headers(init?.headers); + expect(headers.get('X-From-Request')).toBe('request'); + expect(headers.get('X-From-Init')).toBe('init'); + expect(headers.get('X-ProPR-Desktop-Transport-Scope')).toBe('SSSSSSSSSSSSSSSSSSSSSS'); + expect(headers.get('Authorization')).toBe('Bearer renderer'); + expect(headers.get('Cookie')).toBe('renderer=session'); + } + }); + it('does not retry GitHub re-authentication failures', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 'GITHUB_REAUTH_REQUIRED', @@ -181,6 +266,57 @@ describe('demo mode API helpers', () => { window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, listener); }); + it('does not dispatch a stale authorization change after the desktop profile generation switches', async () => { + const listener = vi.fn(); + const scopeA = { + bridge: { connection: { invalidate: vi.fn() } } as never, + profileId: 'profile-a', + transportScope: 'DDDDDDDDDDDDDDDDDDDDDD', + }; + const scopeB = { + bridge: { connection: { invalidate: vi.fn() } } as never, + profileId: 'profile-b', + transportScope: 'EEEEEEEEEEEEEEEEEEEEEE', + }; + setDesktopConnectionScope(scopeA); + window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, listener); + const response = new Response(JSON.stringify({ + code: 'INSUFFICIENT_INSTANCE_PERMISSION', + message: 'Forbidden', + }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + + const scopedResponse = await apiFetch('/api/tasks'); + setDesktopConnectionScope(scopeB); + await expect(handleApiResponse(scopedResponse)).rejects.toThrow('Forbidden'); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, listener); + }); + + it('preserves desktop credentials for authorization changes and transient authentication failures', async () => { + const invalidate = vi.fn(async () => ({ invalidated: false })); + const scope = { + bridge: { connection: { invalidate } } as never, + profileId: 'profile-a', + transportScope: 'IIIIIIIIIIIIIIIIIIIIII', + }; + const listener = vi.fn(); + window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, listener); + setDesktopConnectionScope(scope); + + await expect(handleDesktopAccessCode('AUTHORIZATION_CHANGED', scope)).resolves.toBe('authorization-changed'); + await expect(handleDesktopAccessCode('AUTHENTICATION_FAILED', scope)).resolves.toBe('retryable'); + + expect(listener).toHaveBeenCalledOnce(); + expect(invalidate).not.toHaveBeenCalled(); + window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, listener); + }); + it.each([ { status: 409, lockLostAfterCommit: true }, { status: 500, lockLostAfterCommit: false }, diff --git a/propr-ui/src/api/desktopAuth.ts b/propr-ui/src/api/desktopAuth.ts new file mode 100644 index 000000000..acfecf316 --- /dev/null +++ b/propr-ui/src/api/desktopAuth.ts @@ -0,0 +1,32 @@ +import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; + +export interface DesktopPairingApproval { + pairingId: string; + clientName: string; + status: 'pending' | 'approved' | 'consumed'; + createdAt: string; + expiresAt: string; +} + +const pairingPath = (pairingId: string): string => + `${API_BASE_URL}/api/desktop/pairings/${encodeURIComponent(pairingId)}`; + +export async function getDesktopPairingApproval(pairingId: string): Promise { + const response = await apiFetch(`${pairingPath(pairingId)}/approval`, { + credentials: 'include', + cache: 'no-store', + }); + await handleApiResponse(response); + return response.json() as Promise; +} + +export async function approveDesktopPairing(pairingId: string): Promise { + const response = await apiFetch(`${pairingPath(pairingId)}/approve`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + await handleApiResponse(response); + return response.json() as Promise; +} diff --git a/propr-ui/src/api/proprApi.logout.test.ts b/propr-ui/src/api/proprApi.logout.test.ts index 40a9ddff9..cd249051e 100644 --- a/propr-ui/src/api/proprApi.logout.test.ts +++ b/propr-ui/src/api/proprApi.logout.test.ts @@ -28,6 +28,10 @@ interface TestWindow { search: string; }; name: string; + proprDesktop?: { + auth: { logout: ReturnType }; + external: { open: ReturnType }; + }; sessionStorage: MemoryStorage; } @@ -175,4 +179,28 @@ describe('logout', () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(testWindow.location.href).toBe('http://localhost:4000/api/auth/logout'); }); + + it('logs out the active Electron session and uses hash-aware login navigation', async () => { + const testWindow = stubTestWindow({ + apiBaseUrl: 'http://localhost:4000', + hostname: 'renderer', + href: 'propr-app://renderer/renderer.html#/tasks', + pathname: '/renderer.html', + }); + testWindow.location.hash = '#/tasks'; + const sessionLogout = vi.fn().mockResolvedValue(undefined); + const openExternal = vi.fn(); + testWindow.proprDesktop = { + auth: { logout: sessionLogout }, + external: { open: openExternal }, + }; + const { logout } = await importProprApi(); + + await Promise.resolve(logout()); + + expect(sessionLogout).toHaveBeenCalledWith('http://localhost:4000'); + expect(openExternal).not.toHaveBeenCalled(); + expect(testWindow.location.href).toBe('propr-app://renderer/renderer.html#/tasks'); + expect(testWindow.location.hash).toBe('/login?logged_out=true'); + }); }); diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts index d211e13a3..30cb14a53 100644 --- a/propr-ui/src/api/proprApi.ts +++ b/propr-ui/src/api/proprApi.ts @@ -274,6 +274,11 @@ const hostedLogout = async (): Promise => { }; export const logout = (): void | Promise => { + if (typeof window !== 'undefined' && window.proprDesktop) { + return window.proprDesktop.auth.logout(API_BASE_URL).then(() => { + window.location.hash = '/login?logged_out=true'; + }); + } if (typeof window !== 'undefined' && isHostedUiOrigin(window.location.hostname) && isProprProxyUrl(API_BASE_URL)) { hostedLogoutInFlight ??= hostedLogout(); return hostedLogoutInFlight; diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index 66c5b7998..b419c804f 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -14,6 +14,9 @@ import { QueueStatsUpdatePayload, IndexingUpdatePayload, DraftUpdatePayload } fr import { useCurrentUser, userHasPermission } from '../contexts/AuthContext'; import { ConnectCapacityBanner } from './ConnectPlusBanner'; import { useNotificationCenter } from '../contexts/NotificationCenterContext'; +import { publicAssetUrl } from '../config/runtimeMode'; +import { DesktopTitleBar } from '../desktop/DesktopTitleBar'; +import { useDesktop } from '../desktop/DesktopContext'; interface LayoutProps { children: React.ReactNode; @@ -35,6 +38,7 @@ const Layout: React.FC = ({ children }) => { const user = useCurrentUser(); const { unreadCount } = useNotificationCenter(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const desktop = useDesktop(); // Track repository indexing statuses for toast notifications const repoStatusesRef = useRef>(new Map()); @@ -164,7 +168,9 @@ const Layout: React.FC = ({ children }) => { }; return ( -
+
+ {desktop && } +
{/* Mobile Overlay */} {isSidebarOpen && (
= ({ children }) => { `}>
- ProPR + ProPR
+
); }; diff --git a/propr-ui/src/components/TaskPlanner/AttachmentUploader.test.tsx b/propr-ui/src/components/TaskPlanner/AttachmentUploader.test.tsx new file mode 100644 index 000000000..32ffa75e4 --- /dev/null +++ b/propr-ui/src/components/TaskPlanner/AttachmentUploader.test.tsx @@ -0,0 +1,85 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setDesktopConnectionScope } from '../../api/apiClient'; +import { AttachmentUploader } from './AttachmentUploader'; + +vi.mock('../../config/runtimeMode', async importOriginal => ({ + ...await importOriginal(), + isDesktopRuntime: () => true, +})); + +describe('AttachmentUploader previews', () => { + afterEach(() => { + cleanup(); + setDesktopConnectionScope(null); + vi.restoreAllMocks(); + }); + + it('clears and reloads a text preview when the desktop scope changes', async () => { + let fetchCalls = 0; + let resolveSecondFetch!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + fetchCalls += 1; + if (fetchCalls === 1) return Promise.resolve(new Response('profile A preview', { status: 200 })); + return new Promise(resolve => { resolveSecondFetch = resolve; }); + }); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + render( undefined} + onRemove={async () => undefined} + />); + + const filename = screen.getByText('notes.txt'); + await waitFor(() => expect(filename).toHaveAttribute('title', 'profile A preview')); + + await act(async () => { + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-b', + transportScope: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + }); + + await waitFor(() => expect(fetchCalls).toBe(2)); + expect(filename).toHaveAttribute('title', 'Loading preview…'); + await act(async () => { resolveSecondFetch(new Response('profile B preview', { status: 200 })); }); + await waitFor(() => expect(filename).toHaveAttribute('title', 'profile B preview')); + }); + + it('invalidates a revoked desktop credential and uses the preview error fallback', async () => { + const invalidate = vi.fn(async () => ({ invalidated: true })); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 'INSTANCE_TOKEN_REVOKED', + }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + })); + setDesktopConnectionScope({ + bridge: { connection: { invalidate } } as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + + render( undefined} + onRemove={async () => undefined} + />); + + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + code: 'INSTANCE_TOKEN_REVOKED', + })); + await waitFor(() => expect(screen.getByText('notes.txt')).toHaveAttribute('title', 'Unable to load preview')); + }); +}); diff --git a/propr-ui/src/components/TaskPlanner/AttachmentUploader.tsx b/propr-ui/src/components/TaskPlanner/AttachmentUploader.tsx index dbd24c745..e0b124b29 100644 --- a/propr-ui/src/components/TaskPlanner/AttachmentUploader.tsx +++ b/propr-ui/src/components/TaskPlanner/AttachmentUploader.tsx @@ -1,7 +1,14 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect, useSyncExternalStore } from 'react'; import { PlannerAttachment, getAttachmentUrl } from '../../api/proprApi'; import { X, FileText, Loader2, Paperclip } from 'lucide-react'; import { resizeImage } from './imageUtils'; +import { + apiFetch, + getDesktopConnectionScope, + handleApiResponse, + subscribeDesktopConnectionScope, +} from '../../api/apiClient'; +import { AuthenticatedAttachmentImage } from './AuthenticatedAttachmentImage'; interface AttachmentPreviewProps { file: PlannerAttachment; @@ -12,38 +19,56 @@ interface AttachmentPreviewProps { const AttachmentPreview: React.FC = ({ file, draftId, onRemove }) => { const [textPreview, setTextPreview] = useState(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const desktopScopeKey = useSyncExternalStore( + subscribeDesktopConnectionScope, + () => { + const scope = getDesktopConnectionScope(); + return `${scope?.profileId ?? ''}\u0000${scope?.transportScope ?? ''}`; + }, + () => '', + ); const isImage = file.type === 'image' || file.mimeType?.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(file.originalName); useEffect(() => { - if (!isImage && !textPreview && !isLoadingPreview) { - setIsLoadingPreview(true); - fetch(getAttachmentUrl(draftId, file.id), { credentials: 'include' }) - .then(res => res.text()) - .then(text => { - const preview = text.length > 100 ? text.slice(0, 100) + '...' : text; - setTextPreview(preview); - }) - .catch(() => setTextPreview('Unable to load preview')) - .finally(() => setIsLoadingPreview(false)); - } - }, [file.id, draftId, isImage, textPreview, isLoadingPreview]); + if (isImage) return; + const controller = new AbortController(); + setTextPreview(null); + setIsLoadingPreview(true); + void apiFetch(getAttachmentUrl(draftId, file.id), { credentials: 'include', signal: controller.signal }) + .then(handleApiResponse) + .then(res => res.text()) + .then(text => { + if (controller.signal.aborted) return; + const preview = text.length > 100 ? text.slice(0, 100) + '...' : text; + setTextPreview(preview); + }) + .catch(() => { if (!controller.signal.aborted) setTextPreview('Unable to load preview'); }) + .finally(() => { if (!controller.signal.aborted) setIsLoadingPreview(false); }); + return () => { + controller.abort(); + setTextPreview(null); + setIsLoadingPreview(false); + }; + }, [file.id, draftId, isImage, desktopScopeKey]); return (
{isImage ? (
- {file.originalName}
) : ( )} - + {file.originalName} {file.tokenEstimate}t diff --git a/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.test.tsx b/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.test.tsx new file mode 100644 index 000000000..f8bc4032e --- /dev/null +++ b/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.test.tsx @@ -0,0 +1,89 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setDesktopConnectionScope } from '../../api/apiClient'; +import { AuthenticatedAttachmentImage } from './AuthenticatedAttachmentImage'; + +describe('AuthenticatedAttachmentImage', () => { + afterEach(() => { + cleanup(); + setDesktopConnectionScope(null); + vi.restoreAllMocks(); + }); + + it('clears the previous image and fetches it again under the new scope', async () => { + let fetchCalls = 0; + let resolveSecondFetch!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + fetchCalls += 1; + if (fetchCalls === 1) return Promise.resolve(new Response('image-a', { status: 200 })); + return new Promise(resolve => { resolveSecondFetch = resolve; }); + }); + const createObjectURL = vi.spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('blob:attachment-a') + .mockReturnValueOnce('blob:attachment-b'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + const { unmount } = render(); + + expect(await screen.findByRole('img', { name: 'attachment' })).toHaveAttribute('src', 'blob:attachment-a'); + expect(createObjectURL).toHaveBeenCalledOnce(); + await act(async () => { + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-b', + transportScope: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + }); + + await waitFor(() => expect(fetchCalls).toBe(2)); + expect(screen.queryByRole('img', { name: 'attachment' })).not.toBeInTheDocument(); + expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:attachment-a'); + await act(async () => { resolveSecondFetch(new Response('image-b', { status: 200 })); }); + expect(await screen.findByRole('img', { name: 'attachment' })).toHaveAttribute('src', 'blob:attachment-b'); + unmount(); + expect(revokeObjectURL).toHaveBeenNthCalledWith(2, 'blob:attachment-b'); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + expect(createObjectURL).toHaveBeenCalledTimes(2); + }); + + it('aborts the old request on scope change and the replacement request on unmount', async () => { + const requestSignals: AbortSignal[] = []; + const resolveFetches: Array<(response: Response) => void> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + if (init?.signal) requestSignals.push(init.signal); + return new Promise(resolve => { resolveFetches.push(resolve); }); + }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:late'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + const { unmount } = render(); + await waitFor(() => expect(requestSignals).toHaveLength(1)); + const requestSignal = requestSignals[0]; + if (!requestSignal) throw new Error('Expected attachment fetch to capture an AbortSignal'); + + await act(async () => { + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-b', + transportScope: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + }); + await waitFor(() => expect(requestSignals).toHaveLength(2)); + expect(requestSignal.aborted).toBe(true); + expect(requestSignals[1]?.aborted).toBe(false); + resolveFetches[0]?.(new Response('late', { status: 200 })); + await Promise.resolve(); + expect(screen.queryByRole('img', { name: 'attachment' })).not.toBeInTheDocument(); + unmount(); + expect(requestSignals[1]?.aborted).toBe(true); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); +}); diff --git a/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.tsx b/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.tsx new file mode 100644 index 000000000..bdd6653ce --- /dev/null +++ b/propr-ui/src/components/TaskPlanner/AuthenticatedAttachmentImage.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState, useSyncExternalStore } from 'react'; +import { + apiFetch, + getDesktopConnectionScope, + handleApiResponse, + subscribeDesktopConnectionScope, +} from '../../api/apiClient'; + +interface AuthenticatedAttachmentImageProps extends Omit, 'src'> { + src: string; +} + +export const AuthenticatedAttachmentImage: React.FC = ({ src, ...props }) => { + const [objectUrl, setObjectUrl] = useState(null); + const desktopScopeKey = useSyncExternalStore( + subscribeDesktopConnectionScope, + () => { + const scope = getDesktopConnectionScope(); + return `${scope?.profileId ?? ''}\u0000${scope?.transportScope ?? ''}`; + }, + () => '', + ); + + useEffect(() => { + const controller = new AbortController(); + let disposed = false; + let loadedObjectUrl: string | null = null; + setObjectUrl(null); + const release = (): void => { + controller.abort(); + if (loadedObjectUrl) { + URL.revokeObjectURL(loadedObjectUrl); + loadedObjectUrl = null; + } + if (!disposed) setObjectUrl(null); + }; + void apiFetch(src, { credentials: 'include', signal: controller.signal }) + .then(handleApiResponse) + .then(response => response.blob()) + .then(blob => { + if (disposed || controller.signal.aborted) return; + loadedObjectUrl = URL.createObjectURL(blob); + setObjectUrl(loadedObjectUrl); + }) + .catch(() => { + if (!disposed && !controller.signal.aborted) setObjectUrl(null); + }); + + return () => { + release(); + disposed = true; + }; + }, [src, desktopScopeKey]); + + return objectUrl ? : null; +}; diff --git a/propr-ui/src/components/TaskPlanner/ComposerControls.tsx b/propr-ui/src/components/TaskPlanner/ComposerControls.tsx index 82a828054..0e83e4eac 100644 --- a/propr-ui/src/components/TaskPlanner/ComposerControls.tsx +++ b/propr-ui/src/components/TaskPlanner/ComposerControls.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { X, FileText, Square, Layers, LayoutGrid } from 'lucide-react'; import { Granularity } from '../../api/proprApi'; +import { AuthenticatedAttachmentImage } from './AuthenticatedAttachmentImage'; // Helper to estimate issue count based on granularity // Single: always exactly 1 issue @@ -129,11 +130,10 @@ export const RemoteAttachmentChip: React.FC<{
{isImage && previewUrl ? (
- {name}
) : isImage ? ( diff --git a/propr-ui/src/components/TaskPlanner/PlanIssueRowComponents.tsx b/propr-ui/src/components/TaskPlanner/PlanIssueRowComponents.tsx index f8ce0923d..5693bf08d 100644 --- a/propr-ui/src/components/TaskPlanner/PlanIssueRowComponents.tsx +++ b/propr-ui/src/components/TaskPlanner/PlanIssueRowComponents.tsx @@ -10,6 +10,7 @@ import { ProviderLogo } from '../ui/ProviderLogo'; import AgentModelSelector from './AgentModelSelector'; import MarkdownRenderer from '../TaskDetails/MarkdownRenderer'; import { getModelName, getImplementButtonClassName, getImplementButtonTitle } from './planIssueRowUtils'; +import { AuthenticatedAttachmentImage } from './AuthenticatedAttachmentImage'; interface UltrafixSettingsControlsProps { enabled: boolean; goal: number | null | undefined; maxCycles: number | null | undefined; onGoalChange: (value: number | null) => void; onMaxCyclesChange: (value: number | null) => void; goalPlaceholder: string; maxPlaceholder: string; inputClassName: string; goalInputWidthClassName: string; maxInputWidthClassName: string; containerClassName?: string; errorClassName?: string; } @@ -311,7 +312,7 @@ export const ExpandedContent: React.FC = ({ task, draftId return (
- {isImage ?
{attachment.originalName}
: renderAttachmentIcon()} + {isImage ?
: renderAttachmentIcon()} {attachment.originalName} diff --git a/propr-ui/src/config/hostedTunnelConfig.ts b/propr-ui/src/config/hostedTunnelConfig.ts new file mode 100644 index 000000000..9a7df521d --- /dev/null +++ b/propr-ui/src/config/hostedTunnelConfig.ts @@ -0,0 +1,160 @@ +import { + DEFAULT_PROPR_UI_ORIGIN, + isCanonicalProprConnectHostname, + isProprProxyUrl, + MAX_PROPR_API_BASE_URL_LENGTH, +} from '@propr/shared'; + +export const HOSTED_TUNNEL_API_BASE_STORAGE_KEY = 'propr.hostedTunnelApiBaseUrl'; +export const HOSTED_TUNNEL_FLOW_ID_KEY = 'propr.hostedTunnelFlowId'; +export const HOSTED_TUNNEL_CONTEXT_ID_KEY = 'propr.hostedTunnelContextId'; + +const WINDOW_NAME_CONTEXT_PREFIX = 'propr-hosted-flow-context:'; +const WINDOW_NAME_CONTEXT_SEPARATOR = '|'; +const MAX_HOSTED_QUERY_LENGTH = 4096; +const MAX_HOSTED_FLOW_ID_LENGTH = 128; +const HOSTED_UI_HOSTNAME = new URL(DEFAULT_PROPR_UI_ORIGIN).hostname; + +export type HostedTunnelStorage = Pick; + +export const isHostedUiOrigin = (hostname: string): boolean => hostname === HOSTED_UI_HOSTNAME; + +export const hostedTunnelQueryApiBaseUrl = (hostname: string, search: string): string | null => { + if (!isHostedUiOrigin(hostname) || search.length > MAX_HOSTED_QUERY_LENGTH) return null; + const query = search.startsWith('?') ? search.slice(1) : search; + const rawValues = query.split('&').flatMap(parameter => { + const separator = parameter.indexOf('='); + const name = separator === -1 ? parameter : parameter.slice(0, separator); + return name === 'tunnel' ? [separator === -1 ? '' : parameter.slice(separator + 1)] : []; + }); + const decodedValues = new URLSearchParams(search).getAll('tunnel'); + if (rawValues.length !== 1 || decodedValues.length !== 1) return null; + + const rawComponent = rawValues[0]; + const value = decodedValues[0]; + if (!value || value.length > MAX_PROPR_API_BASE_URL_LENGTH || /[^\x21-\x7e]/.test(value)) return null; + if (rawComponent !== value) return null; + if (/^https:\/\//.test(value)) return isProprProxyUrl(value) ? value : null; + if (!isCanonicalProprConnectHostname(value)) return null; + return `https://${value}`; +}; + +export const hasHostedTunnelQueryParameter = (search: string): boolean => { + if (search.length > MAX_HOSTED_QUERY_LENGTH) return true; + return new URLSearchParams(search).has('tunnel'); +}; + +export const storageForWindow = (): HostedTunnelStorage | undefined => { + if (typeof window === 'undefined') return undefined; + try { return window.sessionStorage; } catch { return undefined; } +}; + +const generateFlowId = (): string => { + try { return crypto.randomUUID(); } catch { return Math.random().toString(36).slice(2) + Date.now().toString(36); } +}; + +const isValidHostedFlowToken = (value: string | null | undefined): value is string => + typeof value === 'string' && /^[A-Za-z0-9-]{1,128}$/.test(value); + +const contextIdFromWindowName = (name: string): string | null => { + if (!name.startsWith(WINDOW_NAME_CONTEXT_PREFIX)) return null; + const rest = name.slice(WINDOW_NAME_CONTEXT_PREFIX.length); + const separatorIndex = rest.indexOf(WINDOW_NAME_CONTEXT_SEPARATOR); + const contextId = separatorIndex === -1 ? rest : rest.slice(0, separatorIndex); + return isValidHostedFlowToken(contextId) ? contextId : null; +}; + +const currentHostedTunnelContextId = (): string | null => { + if (typeof window === 'undefined') return null; + try { return contextIdFromWindowName(window.name); } catch { return null; } +}; + +const setHostedTunnelContextId = (contextId: string): string | null => { + if (typeof window === 'undefined') return contextId; + try { + const existing = window.name || ''; + const separatorIndex = existing.indexOf(WINDOW_NAME_CONTEXT_SEPARATOR); + const preservedName = existing.startsWith(WINDOW_NAME_CONTEXT_PREFIX) + ? (separatorIndex === -1 ? '' : existing.slice(separatorIndex + 1)) + : existing; + window.name = `${WINDOW_NAME_CONTEXT_PREFIX}${contextId}${WINDOW_NAME_CONTEXT_SEPARATOR}${preservedName}`; + return contextId; + } catch { + return null; + } +}; + +const ensureHostedTunnelContextId = (): string | null => + currentHostedTunnelContextId() || setHostedTunnelContextId(generateFlowId()); + +export const flowIdFromSearch = (search: string): string | null => { + if (search.length > MAX_HOSTED_QUERY_LENGTH) return null; + const value = new URLSearchParams(search).get('flow'); + return isValidHostedFlowToken(value) ? value : null; +}; + +export const rememberHostedTunnelApiBaseUrl = ( + hostname: string, + apiBaseUrl: string, + storage: HostedTunnelStorage | undefined = storageForWindow(), + contextId: string | null = ensureHostedTunnelContextId(), +): string | null => { + if (!isHostedUiOrigin(hostname) || !storage || !contextId || !isValidHostedFlowToken(contextId)) return null; + if (apiBaseUrl.length > MAX_PROPR_API_BASE_URL_LENGTH || !isProprProxyUrl(apiBaseUrl)) return null; + try { + const flowId = generateFlowId(); + storage.setItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY, apiBaseUrl); + storage.setItem(HOSTED_TUNNEL_FLOW_ID_KEY, flowId); + storage.setItem(HOSTED_TUNNEL_CONTEXT_ID_KEY, contextId); + return flowId; + } catch { + return null; + } +}; + +interface StoredFlowBinding { + flowId: string; + contextId: string; +} + +const readStoredFlowBinding = (storage: HostedTunnelStorage): StoredFlowBinding | null => { + const flowId = storage.getItem(HOSTED_TUNNEL_FLOW_ID_KEY); + const contextId = storage.getItem(HOSTED_TUNNEL_CONTEXT_ID_KEY); + if ((flowId?.length ?? 0) > MAX_HOSTED_FLOW_ID_LENGTH) return null; + if ((contextId?.length ?? 0) > MAX_HOSTED_FLOW_ID_LENGTH) return null; + if (!isValidHostedFlowToken(flowId) || !isValidHostedFlowToken(contextId)) return null; + return { flowId, contextId }; +}; + +const currentContextMatches = (storedContextId: string, contextId: string | null | undefined): boolean => { + const currentContextId = contextId === undefined ? currentHostedTunnelContextId() : contextId; + return Boolean(currentContextId && currentContextId === storedContextId); +}; + +const readCanonicalStoredEndpoint = (storage: HostedTunnelStorage): string | null => { + const stored = storage.getItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY); + if ((stored?.length ?? 0) > MAX_PROPR_API_BASE_URL_LENGTH || stored !== stored?.trim()) { + storage.removeItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY); + return null; + } + if (stored && isProprProxyUrl(stored)) return stored; + if (stored) storage.removeItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY); + return null; +}; + +export const readStoredHostedTunnelApiBaseUrl = ( + hostname: string, + flowId: string | null, + storage: HostedTunnelStorage | undefined = storageForWindow(), + contextId?: string | null, +): string | null => { + if (!isHostedUiOrigin(hostname) || !storage) return null; + try { + const binding = readStoredFlowBinding(storage); + if (!binding || binding.flowId !== flowId) return null; + if (!currentContextMatches(binding.contextId, contextId)) return null; + return readCanonicalStoredEndpoint(storage); + } catch { + return null; + } +}; diff --git a/propr-ui/src/config/runtimeConfig.test.ts b/propr-ui/src/config/runtimeConfig.test.ts index 953724d02..ea7fd8415 100644 --- a/propr-ui/src/config/runtimeConfig.test.ts +++ b/propr-ui/src/config/runtimeConfig.test.ts @@ -114,16 +114,16 @@ describe('getApiBaseUrl', () => { expect(getApiBaseUrl()).toBe(''); }); - it('strips a trailing slash from the runtime value so paths do not double up', async () => { + it('rejects a trailing slash on a reserved Connect runtime value', async () => { window.__PROPR_CONFIG__ = { apiBaseUrl: 'https://t-abc123.propr.dev/' }; const getApiBaseUrl = await loadGetApiBaseUrl(); - expect(getApiBaseUrl()).toBe('https://t-abc123.propr.dev'); + expect(getApiBaseUrl()).toBe(''); }); - it('strips multiple trailing slashes', async () => { + it('rejects repeated trailing slashes on a reserved Connect runtime value', async () => { window.__PROPR_CONFIG__ = { apiBaseUrl: 'https://t-abc123.propr.dev///' }; const getApiBaseUrl = await loadGetApiBaseUrl(); - expect(getApiBaseUrl()).toBe('https://t-abc123.propr.dev'); + expect(getApiBaseUrl()).toBe(''); }); it('strips a trailing slash from the build-time env var', async () => { @@ -138,6 +138,45 @@ describe('getApiBaseUrl', () => { expect(getApiBaseUrl()).toBe('https://app.propr.dev'); }); + it('does not normalize noncanonical managed origins into hosted authority', async () => { + const { resolveApiBaseUrl } = await import('./runtimeConfig'); + for (const apiBaseUrl of [ + 'https://t-abc123.propr.dev/', + 'https://t-abc123.propr.dev//', + ' https://t-abc123.propr.dev', + 'https://T-AbC123.ProPR.dev', + 'http://t-abc123.propr.dev', + 'https://t-abc123.propr.dev:444', + 'https://user:password@t-abc123.propr.dev', + 'https://t-abc123.propr.dev/api', + 'https://extra.t-abc123.propr.dev', + 'https://t-é.propr.dev', + 'https://t-é.propr.dev:443', + 'https://t-é.propr.dev:444', + 'https://user:password@t-é.propr.dev/api', + 'https://t-é.nested.propr.dev', + ]) { + expect(resolveApiBaseUrl( + 'app.propr.dev', + '', + { apiBaseUrl }, + undefined, + )).toBe(''); + } + + for (const unrelated of [ + 'https://t-x.propr.dev.example.com', + 'https://nested.t-x.propr.dev.example.com', + ]) { + expect(resolveApiBaseUrl( + 'app.propr.dev', + '', + { apiBaseUrl: unrelated }, + undefined, + )).toBe(unrelated); + } + }); + it('returns empty on the hosted OAuth completion route with a tunnel without touching hosted session state', async () => { const hostedWindow = stubHostedWindow({ search: '?oauth_complete=true&tunnel=t-attacker.propr.dev', @@ -189,10 +228,16 @@ describe('getApiBaseUrl', () => { search: '?oauth_complete=true', }); - const { getActiveHostedTunnelFlowId, getApiBaseUrl, HOSTED_TUNNEL_API_BASE_STORAGE_KEY } = + const { + getActiveHostedTunnelFlowId, + getApiBaseUrl, + getRuntimeApiBaseUrlState, + HOSTED_TUNNEL_API_BASE_STORAGE_KEY, + } = await import('./runtimeConfig'); expect(getApiBaseUrl()).toBe(''); + expect(getRuntimeApiBaseUrlState()).toEqual({ apiBaseUrl: '', issue: null }); expectNoSessionStorageAccess(hostedWindow.sessionStorage); expect(hostedWindow.localStorage.getItem).not.toHaveBeenCalled(); expect(hostedWindow.localStorage.setItem).not.toHaveBeenCalled(); @@ -216,6 +261,37 @@ describe('getApiBaseUrl', () => { expect(hostedWindow.name).not.toBe('original-window-name'); expect(getActiveHostedTunnelFlowId()).toBeTruthy(); }); + + it('blocks API client construction when the hosted UI has no selected stack', async () => { + stubHostedWindow({ search: '', pathname: '/' }); + + const { getRuntimeApiBaseUrlState } = await import('./runtimeConfig'); + expect(getRuntimeApiBaseUrlState()).toMatchObject({ + apiBaseUrl: '', + issue: { code: 'HOSTED_STACK_REQUIRED' }, + }); + + const { getProprClient, proprClient } = await import('../api/apiClient'); + expect(proprClient).toBeNull(); + expect(() => getProprClient()).toThrow('The ProPR connection configuration is invalid.'); + }); + + it('blocks API client construction for a non-Connect hosted runtime URL', async () => { + stubHostedWindow({ + config: { apiBaseUrl: 'https://custom.example.com' }, + search: '', + pathname: '/', + }); + + const { getRuntimeApiBaseUrlState } = await import('./runtimeConfig'); + expect(getRuntimeApiBaseUrlState()).toMatchObject({ + apiBaseUrl: '', + issue: { code: 'INVALID_RUNTIME_CONFIGURATION' }, + }); + + const { proprClient } = await import('../api/apiClient'); + expect(proprClient).toBeNull(); + }); }); describe('hosted tunnel query API base', () => { @@ -225,6 +301,10 @@ describe('hosted tunnel query API base', () => { vi.resetModules(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('accepts the Connect tunnel hostname on the hosted UI origin', async () => { const { hostedTunnelQueryApiBaseUrl } = await load(); expect( @@ -232,18 +312,91 @@ describe('hosted tunnel query API base', () => { ).toBe('https://t-abc123.propr.dev'); }); - it('accepts a full hosted proxy URL and strips trailing slashes', async () => { + it('accepts only a literal exact full hosted proxy URL', async () => { const { hostedTunnelQueryApiBaseUrl } = await load(); expect( - hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=https%3A%2F%2Ft-abc123.propr.dev%2F%2F') + hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=https://t-abc123.propr.dev') ).toBe('https://t-abc123.propr.dev'); + expect(hostedTunnelQueryApiBaseUrl( + 'app.propr.dev', + '?tunnel=https%3A%2F%2Ft-abc123.propr.dev', + )).toBeNull(); }); - it('accepts an instance id for manually built hosted UI links', async () => { + it('rejects a bare instance id because shorthand must include the complete canonical host', async () => { const { hostedTunnelQueryApiBaseUrl } = await load(); - expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=abc123')).toBe( - 'https://t-abc123.propr.dev' - ); + expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=abc123')).toBeNull(); + }); + + it('rejects every slash on scheme-less shorthand', async () => { + const { hostedTunnelQueryApiBaseUrl } = await load(); + expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=t-abc123.propr.dev%2F%2F')).toBeNull(); + expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', '?tunnel=t-abc123.propr.dev//')).toBeNull(); + }); + + it('rejects exact noncanonical Connect shorthand reproductions without storing flow state', async () => { + const { hostedTunnelQueryApiBaseUrl, resolveApiBaseUrl } = await load(); + for (const search of [ + '?tunnel=user:secret@t-abc123.propr.dev', + '?tunnel=t-abc123.propr.dev:443', + '?tunnel=t-abc123.propr.dev:8443', + '?tunnel=t-%61bc123.propr.dev', + '?tunnel=t%2Dabc123.propr.dev', + '?%74unnel=t-abc123.propr.dev', + '?tunnel=T-abc123.propr.dev', + '?tunnel=t-abc123.propr.dev.', + '?tunnel=t-abc123.propr.dev.evil.example', + '?tunnel=t-abc123.foo.propr.dev', + '?tunnel=t-abc123.propr.dev%5Cpath', + '?tunnel=t-abc123.propr.dev%2Fpath', + '?tunnel=t-abc123.propr.dev%3Ftoken%3Dsecret', + '?tunnel=t-abc123.propr.dev%23fragment', + '?tunnel=%20t-abc123.propr.dev', + '?tunnel=t-%C3%A1bc123.propr.dev', + '?tunnel=xn--t-bca123.propr.dev', + ]) { + const storage = memoryStorage(); + expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', search), search).toBeNull(); + expect(resolveApiBaseUrl('app.propr.dev', search, undefined, undefined, storage), search).toBe(''); + expect(storage.setItem, search).not.toHaveBeenCalled(); + } + }); + + it('does not let an encoded tunnel name fall through to valid runtime configuration', async () => { + stubHostedWindow({ + config: { apiBaseUrl: 'https://t-configured.propr.dev' }, + pathname: '/', + search: '?%74unnel=t-selected.propr.dev', + }); + const { getRuntimeApiBaseUrlState, hostedTunnelQueryApiBaseUrl } = await load(); + + expect(hostedTunnelQueryApiBaseUrl( + 'app.propr.dev', + '?%74unnel=t-selected.propr.dev', + )).toBeNull(); + expect(getRuntimeApiBaseUrlState()).toMatchObject({ + apiBaseUrl: '', + issue: { code: 'INVALID_RUNTIME_CONFIGURATION' }, + }); + }); + + it('does not let an encoded tunnel name fall through to a valid stored endpoint', async () => { + stubHostedWindow({ + name: 'propr-hosted-flow-context:stored-context|preserved', + pathname: '/', + search: '?%74unnel=t-selected.propr.dev&flow=stored-flow', + sessionInitial: { + 'propr.hostedTunnelApiBaseUrl': 'https://t-stored.propr.dev', + 'propr.hostedTunnelContextId': 'stored-context', + 'propr.hostedTunnelFlowId': 'stored-flow', + }, + }); + const { getRuntimeApiBaseUrlState } = await load(); + + expect(getRuntimeApiBaseUrlState()).toMatchObject({ + apiBaseUrl: '', + issue: { code: 'INVALID_RUNTIME_CONFIGURATION' }, + }); }); it('ignores tunnel query params off the hosted UI origin', async () => { @@ -262,6 +415,11 @@ describe('hosted tunnel query API base', () => { '?tunnel=t-abc123.propr.dev%2Fapi', '?tunnel=t-abc123.propr.dev%3Ffrom%3Dconnect', '?tunnel=t-abc123.propr.dev%23fragment', + '?tunnel=user%40t-abc123.propr.dev', + '?tunnel=t-abc123.propr.dev%3A443', + '?tunnel=t-%D0%B0bc.propr.dev', + '?tunnel=t-abc123%2Epropr.dev', + '?tunnel=%20t-abc123.propr.dev', '?tunnel=%2Fapi' ]) { expect(hostedTunnelQueryApiBaseUrl('app.propr.dev', bad)).toBeNull(); @@ -287,7 +445,7 @@ describe('stored hosted tunnel API base (flow-token-gated sessionStorage)', () = const flowId = rememberHostedTunnelApiBaseUrl( 'app.propr.dev', - 'https://t-abc123.propr.dev/', + 'https://t-abc123.propr.dev', storage, 'tab-context' ); @@ -310,7 +468,7 @@ describe('stored hosted tunnel API base (flow-token-gated sessionStorage)', () = readStoredHostedTunnelApiBaseUrl, } = await load(); const storage = memoryStorage({ - [HOSTED_TUNNEL_API_BASE_STORAGE_KEY]: 'https://t-abc123.propr.dev/', + [HOSTED_TUNNEL_API_BASE_STORAGE_KEY]: 'https://t-abc123.propr.dev', [HOSTED_TUNNEL_CONTEXT_ID_KEY]: 'test-context-id', [HOSTED_TUNNEL_FLOW_ID_KEY]: 'test-flow-id', }); @@ -709,7 +867,7 @@ describe('runtimeConfigWarning', () => { it('warns on the hosted UI origin when config.js did not load', async () => { const runtimeConfigWarning = await loadWarning(); - expect(runtimeConfigWarning('app.propr.dev', undefined)).toContain('config.js did not load'); + expect(runtimeConfigWarning('app.propr.dev', undefined)).toBe('[propr] HOSTED_STACK_REQUIRED'); }); it('does not warn about missing config when a valid Connect tunnel deep link is present', async () => { @@ -750,8 +908,8 @@ describe('runtimeConfigWarning', () => { it('warns on the hosted UI origin when apiBaseUrl is empty', async () => { const runtimeConfigWarning = await loadWarning(); - expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: '' })).toContain('apiBaseUrl is empty'); - expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: ' ' })).toContain('apiBaseUrl is empty'); + expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: '' })).toBe('[propr] HOSTED_STACK_REQUIRED'); + expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: ' ' })).toBe('[propr] INVALID_RUNTIME_CONFIGURATION'); }); it('does not warn when apiBaseUrl is configured', async () => { @@ -762,14 +920,14 @@ describe('runtimeConfigWarning', () => { it('warns on the hosted UI origin when apiBaseUrl is not a valid http(s) URL', async () => { const runtimeConfigWarning = await loadWarning(); for (const bad of ['t-abc123.propr.dev', '/api', 'ftp://t-abc123.propr.dev', 'not a url']) { - expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: bad })).toContain('not a valid http(s) URL'); + expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: bad })).toBe('[propr] INVALID_RUNTIME_CONFIGURATION'); } }); it('warns on the hosted UI origin when apiBaseUrl is a valid URL but not a ProPR proxy URL', async () => { const runtimeConfigWarning = await loadWarning(); for (const notProxy of ['https://custom.example.com', 'http://t-abc123.propr.dev', 'https://t-a.b.propr.dev']) { - expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: notProxy })).toContain('not a hosted ProPR proxy URL'); + expect(runtimeConfigWarning('app.propr.dev', { apiBaseUrl: notProxy })).toBe('[propr] INVALID_RUNTIME_CONFIGURATION'); } }); @@ -838,11 +996,11 @@ describe('hosted UI connection issue', () => { it('blocks invalid hosted runtime API URLs', async () => { const hostedUiConnectionIssue = await loadIssue(); expect(hostedUiConnectionIssue('app.propr.dev', { apiBaseUrl: '/api' })?.title).toBe( - 'Invalid hosted UI configuration' + 'Invalid ProPR configuration' ); expect( hostedUiConnectionIssue('app.propr.dev', { apiBaseUrl: 'https://custom.example.com' })?.title - ).toBe('Invalid hosted UI tunnel'); + ).toBe('Invalid ProPR configuration'); }); it('does not block local or self-hosted origins', async () => { diff --git a/propr-ui/src/config/runtimeConfig.ts b/propr-ui/src/config/runtimeConfig.ts index 1cde62247..b89e82aa9 100644 --- a/propr-ui/src/config/runtimeConfig.ts +++ b/propr-ui/src/config/runtimeConfig.ts @@ -30,7 +30,35 @@ // - A new tab opened to app.propr.dev (no tunnel/flow in URL) never has URL // authority, even if sessionStorage was copied from an existing tab. -import { DEFAULT_PROPR_UI_ORIGIN, isProprProxyUrl, proprInstanceProxyUrl } from '@propr/shared'; +import { + canonicalProprProxyUrl, + DEFAULT_PROPR_UI_ORIGIN, + isProprProxyUrl, + MAX_PROPR_API_BASE_URL_LENGTH, + PROPR_UI_PROXY_LABEL_PREFIX, + PROPR_UI_PROXY_SUFFIX, +} from '@propr/shared'; +import { normalizeApiBaseUrl } from '@propr/client'; +import { + flowIdFromSearch, + hasHostedTunnelQueryParameter, + HOSTED_TUNNEL_API_BASE_STORAGE_KEY, + hostedTunnelQueryApiBaseUrl, + isHostedUiOrigin, + readStoredHostedTunnelApiBaseUrl, + rememberHostedTunnelApiBaseUrl, + storageForWindow, + type HostedTunnelStorage, +} from './hostedTunnelConfig'; +export { + HOSTED_TUNNEL_API_BASE_STORAGE_KEY, + HOSTED_TUNNEL_CONTEXT_ID_KEY, + HOSTED_TUNNEL_FLOW_ID_KEY, + hostedTunnelQueryApiBaseUrl, + isHostedUiOrigin, + readStoredHostedTunnelApiBaseUrl, + rememberHostedTunnelApiBaseUrl, +} from './hostedTunnelConfig'; export interface ProprRuntimeConfig { /** Base URL for REST and Socket.IO. Empty string means same-origin. */ @@ -38,10 +66,16 @@ export interface ProprRuntimeConfig { } export interface HostedUiConnectionIssue { + code: 'HOSTED_STACK_REQUIRED' | 'INVALID_RUNTIME_CONFIGURATION'; title: string; message: string; } +export interface RuntimeApiBaseUrlState { + apiBaseUrl: string; + issue: HostedUiConnectionIssue | null; +} + declare global { interface Window { __PROPR_CONFIG__?: ProprRuntimeConfig; @@ -51,22 +85,16 @@ declare global { const runtimeConfig: ProprRuntimeConfig = (typeof window !== 'undefined' && window.__PROPR_CONFIG__) || {}; -export const HOSTED_TUNNEL_API_BASE_STORAGE_KEY = 'propr.hostedTunnelApiBaseUrl'; -/** Paired with HOSTED_TUNNEL_API_BASE_STORAGE_KEY; must match the URL ?flow= param to be trusted. */ -export const HOSTED_TUNNEL_FLOW_ID_KEY = 'propr.hostedTunnelFlowId'; -/** Paired with HOSTED_TUNNEL_FLOW_ID_KEY; must match this browsing context's window.name token. */ -export const HOSTED_TUNNEL_CONTEXT_ID_KEY = 'propr.hostedTunnelContextId'; +export const INVALID_RUNTIME_CONFIGURATION_CODE = 'INVALID_RUNTIME_CONFIGURATION'; -const WINDOW_NAME_CONTEXT_PREFIX = 'propr-hosted-flow-context:'; -const WINDOW_NAME_CONTEXT_SEPARATOR = '|'; +const invalidRuntimeConfigurationIssue = (): HostedUiConnectionIssue => ({ + code: INVALID_RUNTIME_CONFIGURATION_CODE, + title: 'Invalid ProPR configuration', + message: 'ProPR cannot use the configured connection. Re-enter or rediscover the instance, then try again.', +}); let activeHostedTunnelFlowId: string | null = null; - -/** - * Hostname of the managed hosted UI (e.g. `app.propr.dev`), derived from the - * shared origin constant so there is a single source of truth. - */ -const HOSTED_UI_HOSTNAME = new URL(DEFAULT_PROPR_UI_ORIGIN).hostname; +let desktopApiBaseUrl: string | null = null; /** * Whether the page is being served from the managed hosted UI origin @@ -77,9 +105,6 @@ const HOSTED_UI_HOSTNAME = new URL(DEFAULT_PROPR_UI_ORIGIN).hostname; * ships the UI and API together and is NOT a hosted-UI origin, so it is exempt * from both — only the actual hosted UI is gated. Exported for unit testing. */ -export const isHostedUiOrigin = (hostname: string): boolean => - hostname === HOSTED_UI_HOSTNAME; - export const isHostedOAuthCompletionRoute = ( hostname: string, pathname: string, @@ -96,6 +121,7 @@ export const isHostedOAuthCompletionRoute = ( * unit testing. */ export const isValidHttpUrl = (value: string): boolean => { + if (value.length > MAX_PROPR_API_BASE_URL_LENGTH) return false; try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; @@ -104,167 +130,36 @@ export const isValidHttpUrl = (value: string): boolean => { } }; -/** - * Resolve the Connect deep-link API base from `?tunnel=`. Connect opens the - * hosted UI as `https://app.propr.dev?tunnel=t-.propr.dev` after a - * tunnel passes health checks. Accept only hosted ProPR proxy targets and only - * on the managed hosted UI origin so arbitrary self-hosted pages cannot smuggle - * a cross-origin API base through the query string. - */ -export const hostedTunnelQueryApiBaseUrl = ( - hostname: string, - search: string -): string | null => { - if (!isHostedUiOrigin(hostname)) return null; - - const raw = new URLSearchParams(search).get('tunnel')?.trim(); - if (!raw) return null; - - if (isProprProxyUrl(raw)) return raw.replace(/\/+$/, ''); - - const instanceUrl = proprInstanceProxyUrl(raw); - if (instanceUrl) return instanceUrl; - - try { - const url = new URL(`https://${raw}`); - if (/[^/]/.test(url.pathname) || url.search || url.hash) return null; - const normalized = `https://${url.hostname}`; - return isProprProxyUrl(normalized) ? normalized : null; - } catch { - return null; - } -}; - -type HostedTunnelStorage = Pick; - -const storageForWindow = (): HostedTunnelStorage | undefined => { - if (typeof window === 'undefined') return undefined; - try { - return window.sessionStorage; - } catch { - return undefined; - } -}; - -/** Generate a random per-tab flow token. */ -const generateFlowId = (): string => { - try { - return crypto.randomUUID(); - } catch { - return Math.random().toString(36).slice(2) + Date.now().toString(36); - } -}; - -const generateHostedTunnelContextId = (): string => generateFlowId(); - -const contextIdFromWindowName = (name: string): string | null => { - if (!name.startsWith(WINDOW_NAME_CONTEXT_PREFIX)) return null; - const rest = name.slice(WINDOW_NAME_CONTEXT_PREFIX.length); - const separatorIndex = rest.indexOf(WINDOW_NAME_CONTEXT_SEPARATOR); - const contextId = (separatorIndex === -1 ? rest : rest.slice(0, separatorIndex)).trim(); - return contextId || null; -}; - -const currentHostedTunnelContextId = (): string | null => { - if (typeof window === 'undefined') return null; - try { - return contextIdFromWindowName(window.name); - } catch { - return null; - } -}; - -const setHostedTunnelContextId = (contextId: string): string | null => { - if (typeof window === 'undefined') return contextId; - try { - const existing = window.name || ''; - const separatorIndex = existing.indexOf(WINDOW_NAME_CONTEXT_SEPARATOR); - const preservedName = existing.startsWith(WINDOW_NAME_CONTEXT_PREFIX) - ? (separatorIndex === -1 ? '' : existing.slice(separatorIndex + 1)) - : existing; - window.name = `${WINDOW_NAME_CONTEXT_PREFIX}${contextId}${WINDOW_NAME_CONTEXT_SEPARATOR}${preservedName}`; - return contextId; - } catch { - return null; - } -}; - -const ensureHostedTunnelContextId = (): string | null => { - const existing = currentHostedTunnelContextId(); - if (existing) return existing; - return setHostedTunnelContextId(generateHostedTunnelContextId()); -}; - -/** Extract the `?flow=` token from a URL search string. */ -const flowIdFromSearch = (search: string): string | null => - new URLSearchParams(search).get('flow') || null; - -const effectiveHostedTunnelContextId = ( - _flowId: string, - _storedContextId: string, - contextId: string | null | undefined -): string | null => { - const currentContextId = contextId === undefined ? currentHostedTunnelContextId() : contextId; - if (currentContextId) return currentContextId; - return null; -}; +/** Whether a raw URL places a managed-looking tunnel label under propr.dev. */ +const claimsManagedTunnelNamespace = (value: string): boolean => { + // Inspect the literal authority before URL applies IDNA conversion. This is + // deliberately the same raw-authority classification used by the API: the + // first label starts with t- and the terminal labels are exactly propr.dev. + const rawAuthority = value + .slice(value.indexOf('://') + 3) + .split(/[/?#]/, 1)[0] + ?.split('@') + .pop() + ?.toLowerCase() ?? ''; + const rawHostname = rawAuthority.replace(/:\d+$/, '').replace(/\.$/, ''); + const rawLabels = rawHostname.split('.'); + if ( + rawLabels[0]?.startsWith(PROPR_UI_PROXY_LABEL_PREFIX) === true + && rawLabels.at(-2) === 'propr' + && rawLabels.at(-1) === 'dev' + ) return true; -/** - * Store the selected hosted tunnel URL in sessionStorage together with a - * per-tab flow token. Returns the generated flow token (to be embedded in the - * page URL by the caller), or null if nothing was stored. - */ -export const rememberHostedTunnelApiBaseUrl = ( - hostname: string, - apiBaseUrl: string, - storage: HostedTunnelStorage | undefined = storageForWindow(), - contextId: string | null = ensureHostedTunnelContextId() -): string | null => { - if (!isHostedUiOrigin(hostname) || !storage || !contextId || !isProprProxyUrl(apiBaseUrl)) return null; try { - const flowId = generateFlowId(); - storage.setItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY, apiBaseUrl.replace(/\/+$/, '')); - storage.setItem(HOSTED_TUNNEL_FLOW_ID_KEY, flowId); - storage.setItem(HOSTED_TUNNEL_CONTEXT_ID_KEY, contextId); - return flowId; + const hostname = new URL(value.trim()).hostname.toLowerCase().replace(/\.$/, ''); + const suffix = `.${PROPR_UI_PROXY_SUFFIX}`; + if (!hostname.endsWith(suffix)) return false; + return hostname + .slice(0, -suffix.length) + .split('.') + .some(label => label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)); } catch { - // sessionStorage can be disabled or full. - return null; - } -}; - -/** - * Read the previously stored hosted tunnel URL from sessionStorage, but only - * when the supplied `flowId` matches the stored per-tab token. A new browsing - * context whose sessionStorage was copied from another tab (window.open(), - * duplicate-tab) but whose URL carries no valid flow token is rejected here, - * preventing silent cross-tab tunnel inheritance. - */ -export const readStoredHostedTunnelApiBaseUrl = ( - hostname: string, - flowId: string | null, - storage: HostedTunnelStorage | undefined = storageForWindow(), - contextId?: string | null -): string | null => { - if (!isHostedUiOrigin(hostname) || !storage) return null; - try { - const storedFlowId = storage.getItem(HOSTED_TUNNEL_FLOW_ID_KEY)?.trim() || null; - const storedContextId = storage.getItem(HOSTED_TUNNEL_CONTEXT_ID_KEY)?.trim() || null; - // Reject if storage has no flow token (never legitimately set by this tab) - // or context token, or if the URL/current tab tokens do not match storage. - if (!storedFlowId || storedFlowId !== flowId || !storedContextId) { - return null; - } - if (effectiveHostedTunnelContextId(storedFlowId, storedContextId, contextId) !== storedContextId) { - return null; - } - const stored = storage.getItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY)?.trim(); - if (stored && isProprProxyUrl(stored)) return stored.replace(/\/+$/, ''); - if (stored) storage.removeItem(HOSTED_TUNNEL_API_BASE_STORAGE_KEY); - } catch { - return null; + return false; } - return null; }; /** @@ -286,20 +181,21 @@ export const runtimeConfigWarning = ( ): string | null => { if (!isHostedUiOrigin(hostname)) return null; if (hostedTunnelQueryApiBaseUrl(hostname, search)) return null; + if (hasHostedTunnelQueryParameter(search)) return `[propr] ${INVALID_RUNTIME_CONFIGURATION_CODE}`; if (readStoredHostedTunnelApiBaseUrl(hostname, flowIdFromSearch(search), storage, contextId)) return null; if (!config) { - return ( - '[propr] window.__PROPR_CONFIG__ is not set — config.js did not load. ' + - 'The hosted UI needs a selected tunnel before it can reach a per-instance proxy.' - ); + return '[propr] HOSTED_STACK_REQUIRED'; + } + const configured = config.apiBaseUrl; + if (configured !== undefined && typeof configured !== 'string') { + return `[propr] ${INVALID_RUNTIME_CONFIGURATION_CODE}`; } - const apiBaseUrl = config.apiBaseUrl?.trim(); + if ((configured?.length ?? 0) > MAX_PROPR_API_BASE_URL_LENGTH) { + return `[propr] ${INVALID_RUNTIME_CONFIGURATION_CODE}`; + } + const apiBaseUrl = configured; if (!apiBaseUrl) { - return ( - '[propr] window.__PROPR_CONFIG__.apiBaseUrl is empty — config.js loaded but ' + - 'PROPR_UI_PUBLIC_API_URL was not set at container start. ' + - 'The hosted UI needs a selected tunnel before it can reach a per-instance proxy.' - ); + return '[propr] HOSTED_STACK_REQUIRED'; } // The launcher validates PROPR_UI_PUBLIC_API_URL before injecting it, but a // hand-served config.js or vendor-hosted injection can still provide a @@ -307,25 +203,17 @@ export const runtimeConfigWarning = ( // that is not an absolute http(s) URL (a path, a host with no scheme, junk) // produces broken requests — warn so hosted misconfiguration is diagnosable. if (!isValidHttpUrl(apiBaseUrl)) { - return ( - `[propr] window.__PROPR_CONFIG__.apiBaseUrl is not a valid http(s) URL: "${apiBaseUrl}". ` + - 'Expected an absolute per-instance proxy URL like https://t-abc123.propr.dev. ' + - 'API calls built from this base will fail.' - ); + return `[propr] ${INVALID_RUNTIME_CONFIGURATION_CODE}`; } // Hosted UI tunnel mode is explicitly limited to per-instance proxy hosts: // propr-routing only forwards /api/* and /socket.io/* on // https://t-.propr.dev. A well-formed http(s) URL pointing anywhere // else (e.g. https://custom.example.com) parses fine but requests will not be // routed to the local stack, so warn rather than letting it fail silently at - // request time. This is a warning, not a hard block — a future hosting setup - // could legitimately front a different proxy domain. + // request time. The same condition is also returned as a blocked connection + // issue before the hosted API client is constructed. if (!isProprProxyUrl(apiBaseUrl)) { - return ( - `[propr] window.__PROPR_CONFIG__.apiBaseUrl is not a hosted ProPR proxy URL: "${apiBaseUrl}". ` + - 'Hosted UI tunnel mode only routes https://t-.propr.dev, so API calls built ' + - 'from this base may not reach the local stack.' - ); + return `[propr] ${INVALID_RUNTIME_CONFIGURATION_CODE}`; } return null; }; @@ -339,31 +227,26 @@ export const hostedUiConnectionIssue = ( ): HostedUiConnectionIssue | null => { if (!isHostedUiOrigin(hostname)) return null; if (hostedTunnelQueryApiBaseUrl(hostname, search)) return null; + if (hasHostedTunnelQueryParameter(search)) return invalidRuntimeConfigurationIssue(); if (readStoredHostedTunnelApiBaseUrl(hostname, flowIdFromSearch(search), storage, contextId)) return null; - const apiBaseUrl = config?.apiBaseUrl?.trim(); + const configured = config?.apiBaseUrl; + if (configured !== undefined && typeof configured !== 'string') return invalidRuntimeConfigurationIssue(); + if ((configured?.length ?? 0) > MAX_PROPR_API_BASE_URL_LENGTH) return invalidRuntimeConfigurationIssue(); + const apiBaseUrl = configured; if (!apiBaseUrl) { return { + code: 'HOSTED_STACK_REQUIRED', title: 'Connect a ProPR stack', message: 'This hosted UI needs a selected local stack before it can make API calls. Open ProPR Connect and choose a tunnel, or use the hosted UI link shown after tunnel setup.', }; } if (!isValidHttpUrl(apiBaseUrl)) { - return { - title: 'Invalid hosted UI configuration', - message: - `The configured API URL is not a valid http(s) URL: "${apiBaseUrl}". ` + - 'Restart the stack after setting a hosted proxy URL such as https://t-abc123.propr.dev.', - }; + return invalidRuntimeConfigurationIssue(); } if (!isProprProxyUrl(apiBaseUrl)) { - return { - title: 'Invalid hosted UI tunnel', - message: - `The configured API URL is not a hosted ProPR proxy URL: "${apiBaseUrl}". ` + - 'Hosted UI tunnel mode requires a bare https://t-.propr.dev URL.', - }; + return invalidRuntimeConfigurationIssue(); } return null; }; @@ -421,13 +304,17 @@ export const resolveApiBaseUrl = ( const storedApiBaseUrl = readStoredHostedTunnelApiBaseUrl(hostname, flowId, storage, contextId); if (!queryApiBaseUrl && storedApiBaseUrl) activeHostedTunnelFlowId = flowId; - return ( + const selectedApiBaseUrl = ( queryApiBaseUrl || storedApiBaseUrl || - config?.apiBaseUrl?.trim() || - buildTimeApiBaseUrl?.trim() || + config?.apiBaseUrl || + buildTimeApiBaseUrl || '' - ).replace(/\/+$/, ''); + ); + if (isHostedUiOrigin(hostname) && claimsManagedTunnelNamespace(selectedApiBaseUrl)) { + return canonicalProprProxyUrl(selectedApiBaseUrl) ?? ''; + } + return normalizeApiBaseUrl(selectedApiBaseUrl); }; /* eslint-enable max-params */ @@ -483,13 +370,17 @@ if (typeof window !== 'undefined') { * connection so they always target the same origin. Returns an empty string * for same-origin requests. * - * Trailing slashes are stripped here, once, so the many callers that build - * paths as `${API_BASE_URL}/api/...` never produce a double slash (e.g. - * `https://t-abc.propr.dev//api/compatibility`). The orchestrator already - * normalizes the values it injects, but a hand-served `public/config.js`, - * `VITE_API_BASE_URL`, or manually set apiBaseUrl can still carry one. + * Generic/self-managed URL spellings are normalized here so callers that build + * paths as `${API_BASE_URL}/api/...` never produce a double slash. Hosted + * managed tunnel origins are checked before that normalization and must already + * use their exact lowercase, slash-free canonical spelling. */ export const getApiBaseUrl = (): string => { + return getRuntimeApiBaseUrlState().apiBaseUrl; +}; + +/** Resolve configuration without allowing malformed injected values to throw at import time. */ +export const getRuntimeApiBaseUrlState = (): RuntimeApiBaseUrlState => { if ( typeof window !== 'undefined' && isHostedOAuthCompletionRoute( @@ -498,14 +389,42 @@ export const getApiBaseUrl = (): string => { window.location.search ) ) { - return ''; + return { apiBaseUrl: '', issue: null }; } - return resolveApiBaseUrl( - typeof window !== 'undefined' ? window.location.hostname : '', - typeof window !== 'undefined' ? window.location.search : '', - runtimeConfig, - import.meta.env.VITE_API_BASE_URL, - storageForWindow() - ); + if (desktopApiBaseUrl !== null) return { apiBaseUrl: desktopApiBaseUrl, issue: null }; + + const hostname = typeof window !== 'undefined' ? window.location.hostname : ''; + const search = typeof window !== 'undefined' ? window.location.search : ''; + const storage = storageForWindow(); + const hostedIssue = hostedUiConnectionIssue(hostname, runtimeConfig, search, storage); + if (hostedIssue) return { apiBaseUrl: '', issue: hostedIssue }; + + try { + return { + apiBaseUrl: resolveApiBaseUrl( + hostname, + search, + runtimeConfig, + import.meta.env.VITE_API_BASE_URL, + storage + ), + issue: null, + }; + } catch { + return { apiBaseUrl: '', issue: invalidRuntimeConfigurationIssue() }; + } +}; + +/** Set by the desktop presentation boundary after a profile has passed its probe. */ +export const setDesktopApiBaseUrl = (value: string | null): void => { + if (value === null) { + desktopApiBaseUrl = null; + return; + } + try { + desktopApiBaseUrl = normalizeApiBaseUrl(value); + } catch { + throw new Error('The ProPR connection configuration is invalid.'); + } }; diff --git a/propr-ui/src/config/runtimeMode.ts b/propr-ui/src/config/runtimeMode.ts new file mode 100644 index 000000000..3614855ca --- /dev/null +++ b/propr-ui/src/config/runtimeMode.ts @@ -0,0 +1,23 @@ +export const isDesktopRuntime = (): boolean => + typeof __PROPR_DESKTOP__ !== 'undefined' && __PROPR_DESKTOP__; + +const desktopLocation = (): URL => { + const hashPath = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : window.location.hash; + return new URL(hashPath || '/', 'https://desktop.propr.invalid'); +}; + +export const currentUiPathname = (): string => + isDesktopRuntime() ? desktopLocation().pathname : window.location.pathname; + +export const navigateToUiPath = (path: string): void => { + if (isDesktopRuntime()) { + window.location.hash = path; + return; + } + window.location.href = path; +}; + +export const publicAssetUrl = (path: `/${string}`): string => + isDesktopRuntime() ? new URL(`.${path}`, window.location.href).href : path; diff --git a/propr-ui/src/contexts/SocketContext.ts b/propr-ui/src/contexts/SocketContext.ts index f37a0d6a7..3a09c3405 100644 --- a/propr-ui/src/contexts/SocketContext.ts +++ b/propr-ui/src/contexts/SocketContext.ts @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import { Socket } from 'socket.io-client'; +import type { Socket } from '@propr/client'; import { TaskUpdatePayload, DraftUpdatePayload, IndexingUpdatePayload, QueueStatsUpdatePayload, TaskLiveUpdatePayload } from '@propr/shared'; export interface SocketContextValue { diff --git a/propr-ui/src/contexts/SocketProvider.test.tsx b/propr-ui/src/contexts/SocketProvider.test.tsx index c8c8a60db..ee96e4ab6 100644 --- a/propr-ui/src/contexts/SocketProvider.test.tsx +++ b/propr-ui/src/contexts/SocketProvider.test.tsx @@ -1,68 +1,228 @@ -import { cleanup, render } from '@testing-library/react'; +import { act, cleanup, render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SocketProvider } from './SocketProvider'; +import { useSocket } from './useSocket'; -const runtimeConfigMock = vi.hoisted(() => ({ - getApiBaseUrl: vi.fn(() => ''), +type Handler = (value?: unknown) => void; +const sockets = vi.hoisted(() => [] as Array<{ + handlers: Map; + connect: ReturnType; + disconnect: ReturnType; + emit: ReturnType; + on: ReturnType; + off: ReturnType; +}>); +const connectSocketMock = vi.hoisted(() => vi.fn(() => { + const handlers = new Map(); + const socket = { + handlers, + connect: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + on: vi.fn((event: string, handler: Handler) => { handlers.set(event, handler); }), + off: vi.fn((event: string, handler?: Handler) => { + if (!handler || handlers.get(event) === handler) handlers.delete(event); + }), + }; + sockets.push(socket); + return socket; })); - -const socketMock = vi.hoisted(() => ({ - disconnect: vi.fn(), - emit: vi.fn(), - on: vi.fn(), +const scopeListeners = vi.hoisted(() => new Set<() => void>()); +const handleDesktopAccessCode = vi.hoisted(() => vi.fn(async () => 'retryable')); +const runtime = vi.hoisted(() => ({ desktop: true })); +const state = vi.hoisted(() => ({ + origin: 'https://a.example.test', + scope: null as null | { bridge: never; profileId: string; transportScope: string }, })); -const ioMock = vi.hoisted(() => vi.fn(() => socketMock)); - -vi.mock('../config/runtimeConfig', () => runtimeConfigMock); - -vi.mock('socket.io-client', () => ({ - io: ioMock, +vi.mock('../api/apiClient', () => ({ + getProprClient: () => ({ connectSocket: connectSocketMock }), + getDesktopConnectionScope: () => state.scope, + getDesktopSocketConfigurationKey: () => + `${runtime.desktop ? 'desktop' : 'browser'}\u0000${state.origin}\u0000${state.scope?.profileId ?? ''}\u0000${state.scope?.transportScope ?? ''}`, + subscribeDesktopConnectionScope: (listener: () => void) => { + scopeListeners.add(listener); + return () => scopeListeners.delete(listener); + }, + handleDesktopAccessCode, })); +vi.mock('../config/runtimeMode', () => ({ isDesktopRuntime: () => runtime.desktop })); + +const scope = (profileId: string, transportScope: string) => ({ + bridge: {} as never, + profileId, + transportScope, +}); +const publish = (next: typeof state.scope, origin = state.origin) => { + act(() => { + state.scope = next; + state.origin = origin; + scopeListeners.forEach(listener => listener()); + }); +}; describe('SocketProvider', () => { afterEach(() => { cleanup(); - ioMock.mockClear(); - socketMock.disconnect.mockClear(); - socketMock.emit.mockClear(); - socketMock.on.mockClear(); - runtimeConfigMock.getApiBaseUrl.mockReturnValue(''); + sockets.splice(0); + connectSocketMock.mockClear(); + scopeListeners.clear(); + handleDesktopAccessCode.mockReset(); + handleDesktopAccessCode.mockResolvedValue('retryable'); + runtime.desktop = true; + state.origin = 'https://a.example.test'; + state.scope = null; }); - it('does not connect when disabled for demo mode', () => { - render( - -
demo
-
- ); + it('does not connect when disabled or when desktop has no activation scope', () => { + const { rerender } = render(
demo
); + rerender(
desktop
); - expect(ioMock).not.toHaveBeenCalled(); + expect(connectSocketMock).not.toHaveBeenCalled(); }); - it('connects when real-time updates are enabled', () => { - const { unmount } = render( - -
app
-
- ); + it('creates one force-new scoped Manager on null-to-A activation', () => { + render(
app
); + publish(scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA')); - expect(ioMock).toHaveBeenCalledOnce(); - unmount(); - expect(socketMock.disconnect).toHaveBeenCalledOnce(); + expect(connectSocketMock).toHaveBeenCalledOnce(); + expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ + forceNew: true, + auth: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' }, + query: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' }, + })); }); - it('connects Socket.IO to the same resolved hosted tunnel origin used by REST calls', () => { - runtimeConfigMock.getApiBaseUrl.mockReturnValue('https://t-active.propr.dev'); - const { unmount } = render( - -
app
-
- ); + it.each([ + ['scope rotation', scope('profile-a', 'BBBBBBBBBBBBBBBBBBBBBB')], + ['same-origin A-to-B', scope('profile-b', 'BBBBBBBBBBBBBBBBBBBBBB')], + ])('fully detaches A before creating a distinct Manager for %s', (_name, nextScope) => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + render(
app
); + const socketA = sockets[0]; + + publish(nextScope); + + expect(sockets).toHaveLength(2); + expect(socketA.disconnect).toHaveBeenCalledOnce(); + expect(socketA.off).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(socketA.off).toHaveBeenCalledWith('authentication:error', expect.any(Function)); + expect(socketA.disconnect.mock.invocationCallOrder[0]) + .toBeLessThan(connectSocketMock.mock.invocationCallOrder[1]); + expect(sockets[1]).not.toBe(socketA); + }); + + it('reports a replacement Manager as disconnected until its own connect event', () => { + const connectedStates: boolean[] = []; + const ConnectionState = () => { + connectedStates.push(useSocket().isConnected); + return null; + }; + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + render(); + + act(() => { sockets[0].handlers.get('connect')?.(); }); + expect(connectedStates.at(-1)).toBe(true); + + publish(scope('profile-b', 'BBBBBBBBBBBBBBBBBBBBBB')); + expect(connectedStates.at(-1)).toBe(false); + act(() => { sockets[1].handlers.get('connect_error')?.(new Error('not connected')); }); + expect(connectedStates.at(-1)).toBe(false); + act(() => { sockets[1].handlers.get('connect')?.(); }); + expect(connectedStates.at(-1)).toBe(true); + }); + + it('rotates the Manager when the effective API origin changes', () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + render(
app
); + const socketA = sockets[0]; + + publish(state.scope, 'https://b.example.test'); + + expect(sockets).toHaveLength(2); + expect(socketA.disconnect).toHaveBeenCalledOnce(); + }); + + it('disconnects on deactivate and creates no replacement', () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + render(
app
); + const socketA = sockets[0]; + + publish(null); + + expect(socketA.disconnect).toHaveBeenCalledOnce(); + expect(connectSocketMock).toHaveBeenCalledOnce(); + }); + + it('keeps the hosted browser cookie socket without a desktop marker', () => { + runtime.desktop = false; + render(
app
); + + expect(connectSocketMock).toHaveBeenCalledOnce(); + expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ forceNew: true })); + expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ auth: expect.anything() })); + expect(connectSocketMock).toHaveBeenCalledWith(expect.not.objectContaining({ query: expect.anything() })); + }); + + it('classifies authentication errors against the immutable activation scope', async () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + handleDesktopAccessCode.mockResolvedValueOnce('invalidated'); + render(
app
); + + sockets[0].handlers.get('authentication:error')?.({ code: 'INVALID_INSTANCE_TOKEN' }); + await vi.waitFor(() => expect(handleDesktopAccessCode).toHaveBeenCalledWith( + 'INVALID_INSTANCE_TOKEN', state.scope, + )); + expect(sockets[0].connect).not.toHaveBeenCalled(); + }); + + it('reconnects the current Manager when authorization changes without invalidating its token', async () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + handleDesktopAccessCode.mockResolvedValueOnce('authorization-changed'); + render(
app
); + const socketA = sockets[0]; + + socketA.handlers.get('authentication:error')?.({ code: 'AUTHORIZATION_CHANGED' }); + + await vi.waitFor(() => expect(socketA.connect).toHaveBeenCalledOnce()); + expect(handleDesktopAccessCode).toHaveBeenCalledWith('AUTHORIZATION_CHANGED', state.scope); + expect(socketA.disconnect).toHaveBeenCalledOnce(); + }); + + it('never reconnects a stale same-origin Manager after deferred authorization work resolves', async () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + let resolveClassification!: (value: 'authorization-changed') => void; + handleDesktopAccessCode.mockReturnValueOnce(new Promise(resolve => { resolveClassification = resolve; })); + render(
app
); + const socketA = sockets[0]; + const staleAuthenticationHandler = socketA.handlers.get('authentication:error'); + + staleAuthenticationHandler?.({ code: 'AUTHORIZATION_CHANGED' }); + await vi.waitFor(() => expect(handleDesktopAccessCode).toHaveBeenCalledWith( + 'AUTHORIZATION_CHANGED', state.scope, + )); + publish(scope('profile-b', 'BBBBBBBBBBBBBBBBBBBBBB')); + const socketB = sockets[1]; + resolveClassification('authorization-changed'); + await Promise.resolve(); + + expect(socketA.connect).not.toHaveBeenCalled(); + expect(socketA.disconnect).toHaveBeenCalledOnce(); + expect(socketA.off).toHaveBeenCalledWith('authentication:error', staleAuthenticationHandler); + expect(socketA.handlers.size).toBe(0); + expect(socketB.disconnect).not.toHaveBeenCalled(); + expect(socketB.connect).not.toHaveBeenCalled(); + }); + + it('fully detaches listeners and disconnects on unmount', () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + const { unmount } = render(
app
); + const socketA = sockets[0]; - expect(ioMock).toHaveBeenCalledWith('https://t-active.propr.dev', expect.objectContaining({ - withCredentials: true, - })); unmount(); + + expect(socketA.disconnect).toHaveBeenCalledOnce(); + expect(socketA.handlers.size).toBe(0); + expect(scopeListeners.size).toBe(0); }); }); diff --git a/propr-ui/src/contexts/SocketProvider.tsx b/propr-ui/src/contexts/SocketProvider.tsx index a1076a1cf..0c6ef8459 100644 --- a/propr-ui/src/contexts/SocketProvider.tsx +++ b/propr-ui/src/contexts/SocketProvider.tsx @@ -1,8 +1,15 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { io, Socket } from 'socket.io-client'; -import { TASK_UPDATE, DRAFT_UPDATE, INDEXING_UPDATE, QUEUE_STATS_UPDATE, TASK_LIVE_UPDATE, TaskUpdatePayload, DraftUpdatePayload, IndexingUpdatePayload, QueueStatsUpdatePayload, TaskLiveUpdatePayload } from '@propr/shared'; +import React, { useEffect, useState, useCallback, useRef, useSyncExternalStore } from 'react'; +import type { Socket } from '@propr/client'; +import { DESKTOP_TRANSPORT_SCOPE_QUERY, TASK_UPDATE, DRAFT_UPDATE, INDEXING_UPDATE, QUEUE_STATS_UPDATE, TASK_LIVE_UPDATE, TaskUpdatePayload, DraftUpdatePayload, IndexingUpdatePayload, QueueStatsUpdatePayload, TaskLiveUpdatePayload } from '@propr/shared'; import { SocketContext, SocketContextValue } from './SocketContext'; -import { getApiBaseUrl } from '../config/runtimeConfig'; +import { + getDesktopConnectionScope, + getDesktopSocketConfigurationKey, + getProprClient, + handleDesktopAccessCode, + subscribeDesktopConnectionScope, +} from '../api/apiClient'; +import { isDesktopRuntime } from '../config/runtimeMode'; interface SocketProviderProps { children: React.ReactNode; @@ -17,6 +24,11 @@ export const SocketProvider: React.FC = ({ children, disabl const indexingUpdateCallbacksRef = useRef void>>(new Set()); const queueStatsUpdateCallbacksRef = useRef void>>(new Set()); const taskLiveUpdateCallbacksRef = useRef void>>(new Set()); + const socketConfigurationKey = useSyncExternalStore( + subscribeDesktopConnectionScope, + getDesktopSocketConfigurationKey, + getDesktopSocketConfigurationKey, + ); useEffect(() => { if (disabled) { @@ -25,32 +37,70 @@ export const SocketProvider: React.FC = ({ children, disabl return; } - // Connect to the backend WebSocket server using the same runtime-configured - // API base URL as REST calls, so REST and Socket.IO always share an origin. - // When empty, socket.io-client connects to the same origin. - const socketUrl = getApiBaseUrl() || undefined; - - const newSocket = io(socketUrl, { + const desktopScope = getDesktopConnectionScope(); + if (isDesktopRuntime() && !desktopScope) { + setSocket(null); + setIsConnected(false); + return; + } + setIsConnected(false); + const newSocket = getProprClient().connectSocket({ transports: ['websocket'], - withCredentials: true, autoConnect: true, - // Use path for socket.io which is the standard /socket.io/ path: '/socket.io/', + forceNew: true, + ...(desktopScope ? { + auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope }, + query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope }, + } : {}), }); + let disposed = false; + const isCurrentScope = (): boolean => { + if (disposed) return false; + const current = getDesktopConnectionScope(); + return current?.profileId === desktopScope?.profileId + && current?.transportScope === desktopScope?.transportScope; + }; + const handleAuthenticationCode = (code: string | undefined, reconnect = false): void => { + if (!isCurrentScope()) return; + void handleDesktopAccessCode(code, desktopScope).then(classification => { + if (!isCurrentScope()) return; + if (classification === 'authorization-changed' && reconnect) { + newSocket.disconnect(); + if (!isCurrentScope()) return; + newSocket.connect(); + } + }); + }; - newSocket.on('connect', () => { + const connected = () => { + if (!isCurrentScope()) return; console.log('[SocketContext] Connected to WebSocket server'); setIsConnected(true); - }); + }; - newSocket.on('disconnect', (reason) => { + const disconnected = (reason: string) => { + if (!isCurrentScope()) return; console.log('[SocketContext] Disconnected from WebSocket server:', reason); setIsConnected(false); - }); + }; - newSocket.on('connect_error', (error) => { + const connectionError = (error: Error) => { + if (!isCurrentScope()) return; + setIsConnected(false); console.error('[SocketContext] Connection error:', error.message); - }); + const code = (error as Error & { data?: { code?: string } }).data?.code; + handleAuthenticationCode(code); + }; + + const authenticationError = (value: { code?: string } | undefined) => { + handleAuthenticationCode(value?.code, true); + }; + + newSocket.on('connect', connected); + newSocket.on('disconnect', disconnected); + newSocket.on('connect_error', connectionError); + newSocket.on('authentication:error', authenticationError); // Set up global event listeners newSocket.on(TASK_UPDATE, (payload: TaskUpdatePayload) => { @@ -82,9 +132,20 @@ export const SocketProvider: React.FC = ({ children, disabl return () => { console.log('[SocketContext] Cleaning up socket connection'); + setIsConnected(false); + disposed = true; + newSocket.off('connect', connected); + newSocket.off('disconnect', disconnected); + newSocket.off('connect_error', connectionError); + newSocket.off('authentication:error', authenticationError); + newSocket.off(TASK_UPDATE); + newSocket.off(DRAFT_UPDATE); + newSocket.off(INDEXING_UPDATE); + newSocket.off(QUEUE_STATS_UPDATE); + newSocket.off(TASK_LIVE_UPDATE); newSocket.disconnect(); }; - }, [disabled]); + }, [disabled, socketConfigurationKey]); const subscribeToTask = useCallback((taskId: string) => { if (socket && isConnected) { diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts new file mode 100644 index 000000000..29ab7ec85 --- /dev/null +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DesktopDeepLinkInbox, DesktopDeepLinkNavigation } from './desktop-deep-link'; + +describe('desktop open deep-link navigation', () => { + it('preserves a startup-buffered link until the dashboard is ready', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + + expect(navigation.receive('propr://open?path=%2Ftasks', 'profile-a')).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + + navigation.setDashboardReady('profile-a'); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith('/tasks'); + }); + + it('preserves the order of multiple accepted links buffered during startup', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + + navigation.receive('propr://open?path=%2Fplans', 'profile-a'); + navigation.receive('propr://open?path=%2Ftasks', 'profile-a'); + navigation.setDashboardReady('profile-a'); + + expect(navigate.mock.calls).toEqual([['/plans'], ['/tasks']]); + }); + + it('delivers a valid link received after the dashboard has loaded', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + navigation.setDashboardReady('profile-a'); + + expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent', 'profile-a')).toBe(true); + expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); + }); + + it('rejects an expanded canonical link and accepts one at the length limit', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + navigation.setDashboardReady('profile-a'); + + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + expect(rawLink.length).toBeLessThan(2_048); + expect(expandedCanonicalLink.length).toBeGreaterThan(2_048); + expect(navigation.receive(expandedCanonicalLink, 'profile-a')).toBe(false); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + expect(boundaryCanonicalLink).toHaveLength(2_048); + expect(new URL(boundaryCanonicalLink).href).toBe(boundaryCanonicalLink); + expect(navigation.receive(boundaryCanonicalLink, 'profile-a')).toBe(true); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith(`/tasks/${suffix}`); + }); + + it('does not route malformed or unsafe links before or after dashboard load', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + const rejected = [ + 'not a URL', + 'propr://open?path=https%3A%2F%2Fevil.example', + 'propr://open?path=%2F%2Fevil.example', + 'propr://open?path=%2Ftasks%252F..%252Flogin', + 'propr://open?path=%2Ftasks%2523%2F%252e%252e%2Flogin', + 'propr://open?path=%2Ftasks%2523%2F%25252e%25252e%2Flogin', + 'propr://open?path=%2Ftasks%253F%2F%252e%252e%2Flogin', + 'propr://open?path=%2Ftasks%253F%2F%25252e%25252e%2Flogin', + 'propr://open?path=%2Ftasks%250Anext', + 'propr://open?path=%2Flogin%3Foauth_complete%3Dtrue', + 'propr://open?path=%2Ftasks%3Fflow%3Dattacker', + 'propr://open?path=%2Ftasks%3Ftunnel%3Dt-attacker.propr.dev', + ]; + + rejected.forEach(link => expect(navigation.receive(link, 'profile-a'), link).toBe(false)); + navigation.setDashboardReady('profile-a'); + rejected.forEach(link => expect(navigation.receive(link, 'profile-a'), link).toBe(false)); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('rejects a queued route when a different profile becomes active', () => { + const navigate = vi.fn(); + const reject = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate, reject); + + expect(navigation.receive('propr://open?path=%2Ftasks', 'profile-a')).toBe(true); + navigation.setDashboardReady('profile-b'); + + expect(navigate).not.toHaveBeenCalled(); + expect(reject).toHaveBeenCalledOnce(); + }); +}); + +describe('desktop deep-link inbox', () => { + it('delivers values received before a consumer subscribes exactly once', () => { + const inbox = new DesktopDeepLinkInbox(); + const first = vi.fn(); + const second = vi.fn(); + inbox.receive('propr://connect?api=https%3A%2F%2Ffirst.example'); + + const unsubscribe = inbox.subscribe(first); + expect(first).toHaveBeenCalledOnce(); + unsubscribe(); + const unsubscribeSecond = inbox.subscribe(second); + expect(second).not.toHaveBeenCalled(); + + inbox.receive('propr://connect?api=https%3A%2F%2Fsecond.example'); + expect(second).toHaveBeenCalledOnce(); + unsubscribeSecond(); + }); + + it('fails closed when a competing consumer subscribes', () => { + const inbox = new DesktopDeepLinkInbox(); + const first = vi.fn(); + const unsubscribe = inbox.subscribe(first); + + expect(() => inbox.subscribe(vi.fn())).toThrow('already has a consumer'); + inbox.receive('propr://connect?api=https%3A%2F%2Fonly.example'); + expect(first).toHaveBeenCalledOnce(); + unsubscribe(); + }); +}); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts new file mode 100644 index 000000000..a81070ed7 --- /dev/null +++ b/propr-ui/src/desktop-deep-link.ts @@ -0,0 +1,75 @@ +import { dashboardPathFromDeepLink } from '../../apps/desktop/src/security'; + +const validProfileId = (value: string): boolean => value.length > 0 && value.length <= 128 && !/[\u0000-\u001F\u007F]/.test(value); + +interface PendingNavigation { + path: string; + profileId: string; +} + +/** Holds accepted routes while binding each one to the profile active when it arrived. */ +export class DesktopDeepLinkNavigation { + private activeProfileId: string | null = null; + private readonly pending: PendingNavigation[] = []; + + constructor( + private readonly navigate: (path: string) => void, + private readonly reject: () => void = () => undefined, + ) {} + + receive(value: string, profileId: string): boolean { + const path = dashboardPathFromDeepLink(value); + if (!path || !validProfileId(profileId)) { + this.reject(); + return false; + } + if (this.activeProfileId === profileId) this.navigate(path); + else if (this.activeProfileId === null) this.pending.push({ path, profileId }); + else { + this.reject(); + return false; + } + return true; + } + + setDashboardReady(profileId: string): void { + if (!validProfileId(profileId)) { + this.rejectPending(); + return; + } + this.activeProfileId = profileId; + this.pending.splice(0).forEach(item => { + if (item.profileId === profileId) this.navigate(item.path); + else this.reject(); + }); + } + + setDashboardUnavailable(): void { + this.activeProfileId = null; + } + + rejectPending(): void { + const rejected = this.pending.splice(0).length; + if (rejected > 0) this.reject(); + } +} + +/** One-consumer handoff between the desktop bridge and presentation experience. */ +export class DesktopDeepLinkInbox { + private listener: ((value: string) => void) | null = null; + private readonly pending: string[] = []; + + receive(value: string): void { + if (this.listener) this.listener(value); + else this.pending.push(value); + } + + subscribe(listener: (value: string) => void): () => void { + if (this.listener) throw new Error('Desktop deep-link inbox already has a consumer'); + this.listener = listener; + this.pending.splice(0).forEach(value => listener(value)); + return () => { + if (this.listener === listener) this.listener = null; + }; + } +} diff --git a/propr-ui/src/desktop-profile.ts b/propr-ui/src/desktop-profile.ts new file mode 100644 index 000000000..e9acf4c3a --- /dev/null +++ b/propr-ui/src/desktop-profile.ts @@ -0,0 +1,10 @@ +import type { DesktopBridge, DesktopProfile } from '../../apps/desktop/src/shared/contract'; + +export const activateDesktopProfile = async ( + profiles: Pick, + profile: DesktopProfile, + reload: () => void = () => window.location.reload(), +) => { + await profiles.setActive(profile.id); + reload(); +}; diff --git a/propr-ui/src/desktop.css b/propr-ui/src/desktop.css new file mode 100644 index 000000000..6d7bad456 --- /dev/null +++ b/propr-ui/src/desktop.css @@ -0,0 +1,143 @@ +html, +body, +#root { + height: 100%; + margin: 0; + overflow: hidden; +} + +.desktop-shell { + display: flex; + height: 100%; + flex-direction: column; + background: #f8fafc; +} + +.desktop-titlebar { + display: flex; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid #e2e8f0; + background: rgba(255, 255, 255, 0.96); + padding: 0 0.75rem 0 4.75rem; + color: #64748b; + user-select: none; +} + +.desktop-titlebar-drag { + -webkit-app-region: drag; +} + +.desktop-titlebar-actions { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.7rem; + -webkit-app-region: no-drag; +} + +.desktop-titlebar-button { + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + color: #475569; +} + +.desktop-titlebar-button:hover { + background: #f1f5f9; + color: #0f172a; +} + +.desktop-connection-canvas { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: 2.5rem; + background: + radial-gradient(circle at 15% 10%, rgba(13, 148, 136, 0.09), transparent 30%), + radial-gradient(circle at 85% 85%, rgba(14, 116, 144, 0.08), transparent 32%), + #f8fafc; +} + +.desktop-connection-card { + width: 100%; + max-width: 35rem; + border: 1px solid #e2e8f0; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.98); + padding: 2rem; + box-shadow: 0 20px 50px -28px rgba(15, 23, 42, 0.34); +} + +.desktop-connection-status { + display: inline-flex; + flex: none; + align-items: center; + gap: 0.45rem; + border-radius: 9999px; + background: #f1f5f9; + padding: 0.4rem 0.65rem; + font-size: 0.7rem; + font-weight: 600; + color: #475569; +} + +.desktop-connection-status span { + height: 0.45rem; + width: 0.45rem; + border-radius: 9999px; + background: #94a3b8; +} + +.desktop-input { + margin-top: 0.5rem; + display: block; + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 0.6rem; + background: #fff; + padding: 0.7rem 0.8rem; + font-size: 0.875rem; + color: #0f172a; + outline: none; + transition: border-color 120ms, box-shadow 120ms; +} + +.desktop-input:focus { + border-color: #0d9488; + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.13); +} + +.desktop-primary-button { + width: 100%; + border-radius: 0.65rem; + background: #0f766e; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 600; + color: white; + transition: background 120ms; +} + +.desktop-primary-button:hover:not(:disabled) { + background: #115e59; +} + +.desktop-primary-button:disabled { + cursor: wait; + opacity: 0.65; +} + +@media (max-width: 720px) { + .desktop-titlebar { + padding-left: 0.75rem; + } + + .desktop-connection-canvas { + align-items: flex-start; + padding: 1.25rem; + } +} diff --git a/propr-ui/src/desktop.test.tsx b/propr-ui/src/desktop.test.tsx new file mode 100644 index 000000000..c2cdfc748 --- /dev/null +++ b/propr-ui/src/desktop.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DesktopProfile } from '../../apps/desktop/src/shared/contract'; +import { activateDesktopProfile } from './desktop-profile'; + +const profile = (id: string, apiBaseUrl: string): DesktopProfile => ({ + id, + label: id, + apiBaseUrl, + createdAt: '2026-08-29T00:00:00.000Z', + updatedAt: '2026-08-29T00:00:00.000Z', +}); + +describe('desktop profile activation', () => { + it('reloads module state after selecting each distinct API endpoint', async () => { + const profiles = [ + profile('first', 'https://first.propr.example'), + profile('second', 'https://second.propr.example'), + ]; + let activeProfile: DesktopProfile | undefined; + const loadedEndpoints: string[] = []; + const setActive = vi.fn(async (profileId: string | null) => { + activeProfile = profiles.find(item => item.id === profileId); + }); + const reload = vi.fn(() => { + if (activeProfile) loadedEndpoints.push(activeProfile.apiBaseUrl); + }); + + await activateDesktopProfile({ setActive }, profiles[0], reload); + await activateDesktopProfile({ setActive }, profiles[1], reload); + + expect(setActive).toHaveBeenNthCalledWith(1, 'first'); + expect(setActive).toHaveBeenNthCalledWith(2, 'second'); + expect(loadedEndpoints).toEqual([ + 'https://first.propr.example', + 'https://second.propr.example', + ]); + }); +}); diff --git a/propr-ui/src/desktop.tsx b/propr-ui/src/desktop.tsx new file mode 100644 index 000000000..2bf17953f --- /dev/null +++ b/propr-ui/src/desktop.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +const container = document.getElementById('root'); +if (!container) throw new Error('Root container missing in renderer.html'); + +if (location.hash === '#packaged-transport-smoke') { + void import('./desktop/packagedTransportSmoke').then(({ installPackagedTransportSmokeHarness }) => { + installPackagedTransportSmokeHarness(); + }); +} + +createRoot(container).render(); diff --git a/propr-ui/src/desktop/DesktopConnectedExperience.tsx b/propr-ui/src/desktop/DesktopConnectedExperience.tsx new file mode 100644 index 000000000..9e769cdaf --- /dev/null +++ b/propr-ui/src/desktop/DesktopConnectedExperience.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import type { RefObject } from 'react'; +import { Plus, X } from 'lucide-react'; +import { DesktopContext } from './DesktopContext'; +import { ProfileEditor, ProfileList } from './DesktopExperiencePanels'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +interface DesktopConnectedExperienceProps { + adapters: DesktopAdapters; + profile: DesktopProfile; + result: Extract; + profiles: DesktopProfile[]; + managerOpen: boolean; + managerRef: RefObject; + editing: DesktopProfile | 'new' | null; + operationError: string | null; + deepLinkError: string | null; + editorNotice: string | null; + hasPendingConnectCandidate: boolean; + children: React.ReactNode; + openManager(): void; + closeManager(): void; + closeEditor(): void; + openEditor(profile: DesktopProfile | 'new'): void; + connect(profile: DesktopProfile): Promise; + removeProfile(profile: DesktopProfile): Promise; + saveProfile(profile: DesktopProfile, shouldConnect?: boolean): Promise; + retry(): void; + setManagerOpen(open: boolean): void; +} + +export const DesktopConnectedExperience: React.FC = ({ + adapters, profile, result, profiles, managerOpen, managerRef, editing, + operationError, deepLinkError, editorNotice, hasPendingConnectCandidate, + children, openManager, closeManager, closeEditor, openEditor, connect, + removeProfile, saveProfile, retry, setManagerOpen, +}) => { + const [networkOffline, setNetworkOffline] = useState(!navigator.onLine); + + useEffect(() => { + const online = () => setNetworkOffline(false); + const offline = () => setNetworkOffline(true); + window.addEventListener('online', online); + window.addEventListener('offline', offline); + return () => { + window.removeEventListener('online', online); + window.removeEventListener('offline', offline); + }; + }, []); + + const displayedConnection: DesktopConnectionResult = networkOffline + ? { status: 'offline', message: 'This computer is offline.' } + : result; + const contextValue = { + isDesktop: true as const, + platform: adapters.platform, + profile, + connection: displayedConnection, + openProfileManager: openManager, + authenticate: () => adapters.authentication.authenticate(profile), + openConnectionHelp: () => adapters.externalBrowser.open('https://propr.dev'), + retry, + ...(adapters.acceptance ? { + reportConnectedRendererReady: () => adapters.acceptance!.reportJourneyStage('REACT_CONNECTED'), + } : {}), + }; + + return ( + + {deepLinkError &&
{deepLinkError}
} +
{children}
+ {managerOpen && ( +
{ if (event.target === event.currentTarget) closeManager(); }}> +
+
Desktop

Manage instances

+ {editing ? ( + void saveProfile(editedProfile, hasPendingConnectCandidate || editing === 'new' || profile.id === editedProfile.id)} /> + ) : ( + <> + {operationError &&
{operationError}
} + { setManagerOpen(false); void connect(nextProfile); }} onEdit={openEditor} onRemove={nextProfile => void removeProfile(nextProfile)} /> + + + )} +
+
+ )} +
+ ); +}; diff --git a/propr-ui/src/desktop/DesktopContext.tsx b/propr-ui/src/desktop/DesktopContext.tsx new file mode 100644 index 000000000..86c1db8b0 --- /dev/null +++ b/propr-ui/src/desktop/DesktopContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import type { DesktopConnectionResult, DesktopPlatform, DesktopProfile } from './types'; + +export interface DesktopContextValue { + isDesktop: true; + platform: DesktopPlatform; + profile: DesktopProfile; + connection: DesktopConnectionResult; + openProfileManager(): void; + /** Resolves when authenticated requests for the active profile are ready. */ + authenticate(): Promise; + openConnectionHelp(): Promise; + retry(): void; + /** @internal Packaged acceptance signal owned by the committed connected renderer. */ + reportConnectedRendererReady?(): Promise; +} + +export const DesktopContext = createContext(null); + +export const useDesktop = (): DesktopContextValue | null => useContext(DesktopContext); diff --git a/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx new file mode 100644 index 000000000..a42fb1fe7 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.authentication.test.tsx @@ -0,0 +1,87 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { DesktopTitleBar } from './DesktopTitleBar'; +import { adaptersFor, remoteProfile } from './DesktopExperience.testSupport'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +describe('DesktopExperience authentication', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('publishes the connected renderer only after its authenticated transport is ready', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe); + const stages: string[] = []; + adapters.acceptance = { + reportJourneyStage: vi.fn(async stage => { + if (stage === 'REACT_CONNECTED') { + expect(document.querySelector('.desktop-connection-pill.desktop-connection-ready')).toBeInstanceOf(HTMLButtonElement); + } + stages.push(stage); + }), + }; + const connectedApp = (transportReady: boolean) => ( + + +
Connected app
+
+ ); + const view = render(connectedApp(false)); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile); + expect(probe).toHaveBeenCalledTimes(2); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(stages).toEqual([ + 'AUTHENTICATION_REQUIRED', + 'CREDENTIAL_COMMITTED', + 'AUTHENTICATED_REPROBE_READY', + 'ACTIVATION_COMMITTED', + 'ACTIVATION_PUBLISHED', + ]); + + // Model the slower ARM64 ordering: React has committed the connected shell, + // but authenticated REST and the scoped Socket.IO handshake complete later. + view.rerender(connectedApp(true)); + await waitFor(() => expect(stages).toEqual([ + 'AUTHENTICATION_REQUIRED', + 'CREDENTIAL_COMMITTED', + 'AUTHENTICATED_REPROBE_READY', + 'ACTIVATION_COMMITTED', + 'ACTIVATION_PUBLISHED', + 'REACT_CONNECTED', + ])); + }); + + it('reports rejected authentication and connection-help operations in the blocked panel', async () => { + const adapters = adaptersFor( + [remoteProfile], + remoteProfile.id, + async () => ({ status: 'authentication-required', message: 'Please sign in.' }) + ); + vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.')); + vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.')); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument(); + expect(screen.queryByText(/browser launch failed/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Open connection help/i })); + expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument(); + expect(screen.queryByText(/no browser is configured/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.discovery.test.tsx b/propr-ui/src/desktop/DesktopExperience.discovery.test.tsx new file mode 100644 index 000000000..8f8468cd5 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.discovery.test.tsx @@ -0,0 +1,161 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; +import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; +import { DesktopConnectDiscoveryService } from '../../../apps/desktop/src/connect-discovery'; +import type { DesktopCredentialService } from '../../../apps/desktop/src/credential-service'; +import { registerIpcHandlers } from '../../../apps/desktop/src/ipc'; +import type { LocalLifecycleController } from '../../../apps/desktop/src/lifecycle'; +import type { DesktopLogger } from '../../../apps/desktop/src/logger'; +import { createDesktopBridge, type PreloadIpc } from '../../../apps/desktop/src/preload-bridge'; +import type { ProfileStore } from '../../../apps/desktop/src/profile-store'; +import { IPC_CHANNELS } from '../../../apps/desktop/src/shared/contract'; +import { DesktopExperience } from './DesktopExperience'; +import { createElectronDesktopAdapters } from './electronAdapters'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +vi.mock('../api/apiClient', () => ({ + getDesktopConnectionScope: () => null, + setApiBaseUrl: vi.fn(), + setDesktopConnectionScope: vi.fn(), +})); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: vi.fn() })); + +const rendererUrl = 'propr-app://renderer/renderer.html'; + +const readyStatus: ConnectStatusDocument = { + schemaVersion: 1, + status: 'ready', + canonicalEndpoint: 'https://t-discovered123.propr.dev', + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + configured: true, + enabled: true, + sidecarRunning: true, + apiReady: true, + restartRequired: false, + compatibility: '2026-08-01', + version: '0.8.15', + reasonCodes: [], +}; + +const savedProfile: DesktopProfile = { + id: 'saved', name: 'Saved instance', baseUrl: 'https://saved.example.test', kind: 'remote', +}; + +const 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 adaptersWithDiscovery = (discover: DesktopAdapters['discovery']['discover']): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => [savedProfile]), save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), getActiveId: vi.fn(async () => null), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: true, discover }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: false, setup: vi.fn(async () => savedProfile) }, + connection: { probe: vi.fn(async (): Promise => ({ status: 'ready' })) }, +}); + +describe('DesktopExperience production Connect discovery pipeline', () => { + it('flows fixed-root main discovery through IPC, preload, and Electron adapters without persistence', async () => { + type InvokeHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; + const handlers = new Map(); + const invocations: Array<{ channel: string; args: unknown[] }> = []; + const credentials = { + listProfiles: vi.fn(async () => ({ profiles: [], activeProfileId: null })), + saveProfile: vi.fn(), + } as unknown as DesktopCredentialService; + const connectDiscovery = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => readyStatus, + }); + const registered = registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain: { + handle: (channel: string, handler: InvokeHandler) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: rendererUrl, + openExternal: async () => undefined, + }); + const event = { senderFrame: { url: rendererUrl } } as unknown as IpcMainInvokeEvent; + const ipc: PreloadIpc = { + invoke: (channel, ...args) => { + invocations.push({ channel, args }); + return Promise.resolve(handlers.get(channel)!(event, ...args)); + }, + on: () => undefined, + removeListener: () => undefined, + }; + const adapters = createElectronDesktopAdapters(createDesktopBridge(ipc, true)); + + render(
Connected app
); + fireEvent.click(await screen.findByRole('button', { name: /Search for instances on this network/i })); + + expect(await screen.findByRole('heading', { name: 'Edit instance' })).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Verified ProPR Connect endpoint'); + expect(screen.getByLabelText('Instance URL')).toHaveValue('https://t-discovered123.propr.dev'); + expect(credentials.saveProfile).not.toHaveBeenCalled(); + await waitFor(() => expect(invocations).toContainEqual({ + channel: IPC_CHANNELS.connectDiscover, + args: [], + })); + expect(invocations.find(item => item.channel === IPC_CHANNELS.connectDiscover)?.args).toEqual([]); + registered.dispose(); + }); + + it('discards a late discovery success after an editor action', async () => { + const pending = deferred(); + const adapters = adaptersWithDiscovery(() => pending.promise); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Search for instances on this network/i })); + fireEvent.click(screen.getByRole('button', { name: 'Edit Saved instance' })); + expect(await screen.findByRole('heading', { name: 'Edit instance' })).toBeInTheDocument(); + expect(screen.getByLabelText('Instance URL')).toHaveValue(savedProfile.baseUrl); + + await act(() => { + pending.resolve([{ + id: 'late', name: 'Late discovery', + baseUrl: 'https://t-late123.propr.dev', kind: 'remote', + }]); + return pending.promise; + }); + expect(screen.getByLabelText('Instance URL')).toHaveValue(savedProfile.baseUrl); + expect(screen.queryByText('Late discovery')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('button', { name: /Search for instances on this network/i })).toBeEnabled(); + }); + + it('discards a late discovery error after a competing connection action', async () => { + const pending = deferred(); + const adapters = adaptersWithDiscovery(() => pending.promise); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Search for instances on this network/i })); + fireEvent.click(screen.getByRole('button', { name: /^Saved instance/ })); + await act(async () => { pending.reject(new Error('native path SENTINEL')); await Promise.resolve(); }); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(screen.queryByText(/Network discovery is unavailable/)).not.toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.management.test.tsx b/propr-ui/src/desktop/DesktopExperience.management.test.tsx new file mode 100644 index 000000000..fdc0fa2f8 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.management.test.tsx @@ -0,0 +1,263 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { DesktopTitleBar } from './DesktopTitleBar'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +const localProfile: DesktopProfile = { + id: 'local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', + kind: 'local', +}; + +const remoteProfile: DesktopProfile = { + id: 'remote', + name: 'Team server', + baseUrl: 'https://propr.example.com', + kind: 'remote', +}; + +const connectedApp = <>
Connected app
; +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +}; + +const adaptersFor = ( + profiles: DesktopProfile[] = [], + activeId: string | null = null, + probe: (profile: DesktopProfile) => Promise = + async () => ({ status: 'ready', version: '0.8.15' }), +): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => profiles), + save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + getActiveId: vi.fn(async () => activeId), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: true, discover: vi.fn(async () => []) }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: true, setup: vi.fn(async () => localProfile) }, + connection: { probe: vi.fn(probe) }, +}); + +describe('DesktopExperience profile management', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reconnects an edited active instance but saves an inactive edit without connecting', async () => { + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id); + render({connectedApp}); + + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + vi.clearAllMocks(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://active.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + expect(adapters.connection.probe).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'https://active.example.com' })); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'https://active.example.com' })); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).toHaveBeenLastCalledWith('https://active.example.com'); + + vi.clearAllMocks(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Renamed team server' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Renamed team server')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ id: 'remote', name: 'Renamed team server' })); + expect(adapters.connection.probe).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('does not persist an active profile edit until the updated connection is ready', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) + .mockResolvedValueOnce({ status: 'offline', message: 'The updated server is unavailable.' }); + const adapters = adaptersFor([localProfile], localProfile.id, probe); + render({connectedApp}); + + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + vi.clearAllMocks(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://unavailable.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText(/could not reach this instance.*try again/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('The updated server is unavailable.'); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('keeps a failed save in the manager editor so it can be retried', async () => { + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id); + vi.mocked(adapters.profiles.save) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Profile storage is locked.')) + .mockResolvedValueOnce(undefined); + render(
Connected app
); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Retryable edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not save this instance.*try again/i); + expect(document.body).not.toHaveTextContent('Profile storage is locked.'); + expect(screen.getByLabelText('Display name')).toHaveValue('Retryable edit'); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(await screen.findByText('Retryable edit')).toBeInTheDocument(); + }); + + it('keeps a profile visible and reports a rejected removal', async () => { + const adapters = adaptersFor([remoteProfile]); + vi.mocked(adapters.profiles.remove).mockRejectedValueOnce(new Error('Profile storage is locked.')); + render(
Connected app
); + + expect(await screen.findByText('Team server')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Team server' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not remove this instance.*try again/i); + expect(document.body).not.toHaveTextContent('Profile storage is locked.'); + expect(screen.getByText('Team server')).toBeInTheDocument(); + expect(adapters.profiles.remove).toHaveBeenCalledWith(remoteProfile.id); + }); + + it('reconnects after authentication completes and advances to the connected app', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'authentication-required', message: 'Please sign in.' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor([remoteProfile], remoteProfile.id, probe); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.authentication.authenticate).toHaveBeenCalledWith(remoteProfile); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('reports rejected authentication and connection-help operations in the blocked panel', async () => { + const adapters = adaptersFor( + [remoteProfile], + remoteProfile.id, + async () => ({ status: 'authentication-required', message: 'Please sign in.' }), + ); + vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce(new Error('Browser launch failed.')); + vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce(new Error('No browser is configured.')); + render(
Connected app
); + + fireEvent.click(await screen.findByRole('button', { name: /Sign in in browser/i })); + expect(await screen.findByText(/could not open sign in.*try again/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('Browser launch failed.'); + expect(screen.getByRole('button', { name: /Sign in in browser/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Open connection help/i })); + expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('No browser is configured.'); + expect(screen.getByRole('button', { name: /Open connection help/i })).toBeInTheDocument(); + }); + + it.each(['macos', 'windows'] as const)('offers remote connection guidance instead of local setup on %s', async platform => { + const adapters = adaptersFor(); + adapters.platform = platform; + adapters.localSetup.supported = false; + render(
Connected app
); + + expect(await screen.findByRole('heading', { name: 'Connect to ProPR' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Set up this computer/i })).not.toBeInTheDocument(); + expect(screen.getByText(/local setup is currently available on Linux/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect to an existing instance/i })).toBeInTheDocument(); + }); + + it('hides unsupported local setup when the adapter reports Linux', async () => { + const adapters = adaptersFor(); + adapters.localSetup.supported = false; + render(
Connected app
); + + expect(await screen.findByRole('heading', { name: 'Connect to ProPR' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Set up this computer/i })).not.toBeInTheDocument(); + expect(adapters.localSetup.setup).not.toHaveBeenCalled(); + }); + + it('keeps management ready after out-of-order profile loading and a concurrent status refresh', async () => { + const listed = deferred(); + const selected = deferred(); + const probed = deferred(); + const adapters = adaptersFor(); + vi.mocked(adapters.profiles.list).mockImplementation(() => listed.promise); + vi.mocked(adapters.profiles.getActiveId).mockImplementation(() => selected.promise); + vi.mocked(adapters.connection.probe).mockImplementation(() => probed.promise); + render({connectedApp}); + + await act(async () => { selected.resolve(localProfile.id); }); + expect(screen.getByText('Opening ProPR…')).toBeInTheDocument(); + await act(async () => { listed.resolve([localProfile, remoteProfile]); }); + expect(await screen.findByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument(); + await act(async () => { probed.resolve({ status: 'ready', version: '0.8.15' }); }); + + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + fireEvent(window, new Event('offline')); + expect(await screen.findByRole('button', { name: 'Offline: This computer' })).toBeInTheDocument(); + fireEvent(window, new Event('online')); + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + expect(screen.getByLabelText('Display name')).toHaveValue('This computer'); + expect(screen.queryByText('Opening ProPR…')).not.toBeInTheDocument(); + }); + + it('ignores late profile and status resolutions after unmount without stale publication', async () => { + const listed = deferred(); + const selected = deferred(); + const adapters = adaptersFor(); + vi.mocked(adapters.profiles.list).mockImplementation(() => listed.promise); + vi.mocked(adapters.profiles.getActiveId).mockImplementation(() => selected.promise); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const first = render({connectedApp}); + first.unmount(); + await act(async () => { + listed.resolve([localProfile]); + selected.resolve(localProfile.id); + await Promise.resolve(); + }); + expect(adapters.connection.probe).not.toHaveBeenCalled(); + + const probe = deferred(); + const probingAdapters = adaptersFor([localProfile], localProfile.id, () => probe.promise); + const second = render({connectedApp}); + expect(await screen.findByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument(); + second.unmount(); + await act(async () => { probe.resolve({ status: 'ready', version: '0.8.15' }); }); + expect(probingAdapters.profiles.save).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.recovery.test.tsx b/propr-ui/src/desktop/DesktopExperience.recovery.test.tsx new file mode 100644 index 000000000..15ff7d425 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.recovery.test.tsx @@ -0,0 +1,225 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +const savedProfile: DesktopProfile = { + id: 'opaque-profile-id', + name: 'Managed workspace', + baseUrl: 'https://t-stale123.propr.dev', + kind: 'remote', +}; + +const replacement: DesktopProfile = { + ...savedProfile, + baseUrl: 'https://t-restarted456.propr.dev', +}; + +const adaptersFor = ( + probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'offline', message: 'offline' }), +): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => [savedProfile]), + save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + getActiveId: vi.fn(async () => savedProfile.id), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: false, discover: vi.fn(async () => []) }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: false, setup: vi.fn(async () => savedProfile) }, + connection: { probe: vi.fn(probe) }, +}); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(complete => { resolve = complete; }); + return { promise, resolve }; +}; + +const renderOfflineProfile = async (adapters: DesktopAdapters) => { + render(
Dashboard content
); + return await screen.findByRole('button', { name: 'Rediscover Connect endpoint' }); +}; + +describe('DesktopExperience managed Connect recovery', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows bounded recovery guidance without endpoint or raw failure details', async () => { + const adapters = adaptersFor(async () => ({ + status: 'offline', + message: 'Failed at https://t-stale123.propr.dev?token=secret-sentinel', + })); + await renderOfflineProfile(adapters); + + expect(screen.getByText(/endpoint may be stale or the local stack may have restarted/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Re-enter Connect address' })).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('t-stale123.propr.dev'); + expect(document.body).not.toHaveTextContent('secret-sentinel'); + }); + + it('does not give renderer network discovery authority when the trusted adapter is absent', async () => { + const adapters = adaptersFor(); + vi.mocked(adapters.discovery.discover).mockResolvedValue([ + { ...savedProfile, id: 'unrelated', baseUrl: 'https://t-unrelated.propr.dev' }, + replacement, + ]); + fireEvent.click(await renderOfflineProfile(adapters)); + + expect(await screen.findByText(/rediscovery is unavailable.*re-enter/i)).toBeInTheDocument(); + expect(adapters.discovery.discover).not.toHaveBeenCalled(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + }); + + it.each([ + ['null result', null], + ['mismatched profile', { ...replacement, id: 'other-profile' }], + ['trailing slash', { ...replacement, baseUrl: `${replacement.baseUrl}/` }], + ['mixed case', { ...replacement, baseUrl: 'https://T-restarted456.propr.dev' }], + ['nested reserved host', { ...replacement, baseUrl: 'https://x.t-restarted456.propr.dev' }], + ['missing endpoint', { ...replacement, baseUrl: undefined } as unknown as DesktopProfile], + ])('keeps the saved profile untouched for a %s candidate', async (_case, candidate) => { + const adapters = adaptersFor(); + adapters.managedTunnelRecovery = { rediscover: vi.fn(async () => candidate) }; + fireEvent.click(await renderOfflineProfile(adapters)); + + expect(await screen.findByText(/rediscovery is unavailable.*re-enter/i)).toBeInTheDocument(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.connection.probe).toHaveBeenCalledTimes(1); + }); + + it('keeps the saved profile untouched when trusted rediscovery rejects', async () => { + const adapters = adaptersFor(); + adapters.managedTunnelRecovery = { + rediscover: vi.fn(async () => { throw new Error('token-sentinel at /private/path'); }), + }; + fireEvent.click(await renderOfflineProfile(adapters)); + + expect(await screen.findByText(/rediscovery is unavailable.*re-enter/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(/token-sentinel|private\/path/i); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + }); + + it('identifies the bounded saved label, hides both endpoints, and requires confirmation', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'offline', message: 'offline' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor(probe); + adapters.managedTunnelRecovery = { rediscover: vi.fn(async () => replacement) }; + fireEvent.click(await renderOfflineProfile(adapters)); + + expect(await screen.findByRole('heading', { name: 'Use the rediscovered endpoint?' })).toBeInTheDocument(); + expect(screen.getByText(/replacement endpoint was discovered for the saved connection “Managed workspace”/i)).toBeInTheDocument(); + expect(adapters.managedTunnelRecovery.rediscover).toHaveBeenCalledWith(savedProfile.id); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent(savedProfile.baseUrl); + expect(document.body).not.toHaveTextContent(replacement.baseUrl); + + fireEvent.click(screen.getByRole('button', { name: 'Connect to rediscovered endpoint' })); + expect(await screen.findByText('Dashboard content')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ + id: savedProfile.id, + name: savedProfile.name, + baseUrl: replacement.baseUrl, + lastConnectedAt: expect.any(String), + })); + }); + + it('cancels confirmation without saving and preserves Retry and Re-enter recovery', async () => { + const adapters = adaptersFor(); + adapters.managedTunnelRecovery = { rediscover: vi.fn(async () => replacement) }; + fireEvent.click(await renderOfflineProfile(adapters)); + fireEvent.click(await screen.findByRole('button', { name: 'Keep saved connection' })); + + expect(await screen.findByRole('button', { name: 'Retry' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Re-enter Connect address' })).toBeInTheDocument(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + }); + + it('falls back from an unsafe saved label without exposing label contents', async () => { + const unsafeLabel = 'https://old-host.example/private?token=label-secret'; + const adapters = adaptersFor(); + vi.mocked(adapters.profiles.list).mockResolvedValue([{ ...savedProfile, name: unsafeLabel }]); + adapters.managedTunnelRecovery = { rediscover: vi.fn(async () => replacement) }; + fireEvent.click(await renderOfflineProfile(adapters)); + + expect(await screen.findByText(/saved connection “Saved connection”/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(/old-host|private|label-secret/i); + }); + + it('fences a stale concurrent rediscovery result from the current confirmation', async () => { + const first = deferred(); + const second = deferred(); + const secondReplacement = { ...replacement, baseUrl: 'https://t-current789.propr.dev' }; + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'offline', message: 'offline' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor(probe); + adapters.managedTunnelRecovery = { + rediscover: vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise), + }; + const button = await renderOfflineProfile(adapters); + fireEvent.click(button); + fireEvent.click(button); + await act(async () => second.resolve(secondReplacement)); + expect(await screen.findByRole('heading', { name: 'Use the rediscovered endpoint?' })).toBeInTheDocument(); + await act(async () => first.resolve(replacement)); + + fireEvent.click(screen.getByRole('button', { name: 'Connect to rediscovered endpoint' })); + await waitFor(() => expect(probe).toHaveBeenLastCalledWith(expect.objectContaining({ + id: savedProfile.id, + baseUrl: secondReplacement.baseUrl, + }))); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: secondReplacement.baseUrl })); + }); + + it('re-enters a managed address without exposing or overwriting the stale value', async () => { + const adapters = adaptersFor(); + const reenter = await renderOfflineProfile(adapters); + fireEvent.click(screen.getByRole('button', { name: 'Re-enter Connect address' })); + + expect(reenter).not.toBeInTheDocument(); + expect(screen.getByLabelText('Instance URL')).toHaveValue(''); + expect(document.body).not.toHaveTextContent(savedProfile.baseUrl); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + }); + + it('turns a managed pairing failure into recovery without leaking the failure', async () => { + const adapters = adaptersFor(async () => ({ + status: 'authentication-required', + message: 'pair at private-path-sentinel', + })); + vi.mocked(adapters.authentication.authenticate).mockRejectedValueOnce( + new Error('password-sentinel at /Users/private/config'), + ); + render(
Dashboard content
); + fireEvent.click(await screen.findByRole('button', { name: 'Sign in in browser' })); + + expect(await screen.findByText(/pairing could not be completed.*try again/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(/password-sentinel|Users\/private/i); + }); + + it('reports a managed connection-help failure as a bounded help error', async () => { + const adapters = adaptersFor(); + vi.mocked(adapters.externalBrowser.open).mockRejectedValueOnce( + new Error('browser-sentinel at /Users/private/config'), + ); + await renderOfflineProfile(adapters); + fireEvent.click(screen.getByRole('button', { name: 'Open connection help' })); + + expect(await screen.findByText(/could not open connection help.*try again/i)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(/pairing could not be completed|browser-sentinel|Users\/private/i); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx new file mode 100644 index 000000000..f5545dcb8 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -0,0 +1,384 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopDeepLinkInbox } from '../desktop-deep-link'; +import { DesktopExperience } from './DesktopExperience'; +import { adaptersFor, deferred, localProfile, remoteProfile, renderConnectedExperience } from './DesktopExperience.testSupport'; +import type { DesktopConnectionResult } from './types'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +describe('DesktopExperience', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('offers network search only when the adapter has a real discovery provider', async () => { + const capable = adaptersFor(); + const { unmount } = render( +
Capable app
+ ); + const search = await screen.findByRole('button', { name: /Search for instances on this network/i }); + fireEvent.click(search); + await waitFor(() => expect(capable.discovery.discover).toHaveBeenCalledOnce()); + unmount(); + + const incapable = adaptersFor(); + incapable.discovery.supported = false; + render(
Incapable app
); + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Search for instances on this network/i })).not.toBeInTheDocument(); + expect(incapable.discovery.discover).not.toHaveBeenCalled(); + }); + + it('runs first-time local setup through adapters before mounting the shared app', async () => { + const adapters = adaptersFor(); + render(
Shared route tree
); + + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + expect(screen.queryByText('Shared route tree')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Set up this computer/i })); + + expect(await screen.findByText('Shared route tree')).toBeInTheDocument(); + expect(adapters.localSetup.setup).toHaveBeenCalledOnce(); + expect(adapters.connection.probe).toHaveBeenCalledWith(localProfile); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ id: 'local' })); + expect(adapters.profiles.setActiveId).toHaveBeenCalledWith('local'); + expect(runtimeMock.setDesktopApiBaseUrl).toHaveBeenCalledWith(localProfile.baseUrl); + expect(apiMock.setApiBaseUrl).toHaveBeenCalledWith(localProfile.baseUrl); + }); + + it('stages a Connect deep link for confirmation with zero pre-confirmation effects', async () => { + const adapters = adaptersFor(); + adapters.connection.activate = vi.fn(async (_profile, result) => result); + adapters.connection.deactivate = vi.fn(); + const deepLinks = new DesktopDeepLinkInbox(); + render(
Shared route tree
); + + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + vi.clearAllMocks(); + act(() => deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev')); + + expect(await screen.findByRole('status')).toHaveTextContent(/untrusted instance address/i); + expect(screen.getByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + expect(adapters.discovery.discover).not.toHaveBeenCalled(); + expect(adapters.connection.probe).not.toHaveBeenCalled(); + expect(adapters.connection.activate).not.toHaveBeenCalled(); + expect(adapters.authentication.authenticate).not.toHaveBeenCalled(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(adapters.connection.deactivate).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + await waitFor(() => expect(adapters.connection.probe).toHaveBeenCalledOnce()); + expect(adapters.profiles.save).toHaveBeenCalledOnce(); + expect(adapters.connection.activate).toHaveBeenCalledOnce(); + }); + + it('returns from the prefilled profile editor to every packaged-layout chooser element', async () => { + const adapters = adaptersFor(); + const deepLinks = new DesktopDeepLinkInbox(); + render(
Shared route tree
); + + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + act(() => deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev')); + expect(await screen.findByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + await screen.findByRole('heading', { name: 'Let’s set up this computer' }); + + for (const selector of [ + '.desktop-entry', + '.desktop-welcome-card', + '.desktop-welcome-card .desktop-brand img', + '.desktop-welcome-card .desktop-welcome-copy h1', + '.desktop-welcome-card .desktop-choice-button', + '.desktop-welcome-card .desktop-choice-button small', + ]) { + expect(document.querySelector(selector), selector).toBeVisible(); + } + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + + it('keeps Open deep-link navigation separate and bound to the active profile', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + const deepLinks = new DesktopDeepLinkInbox(); + window.location.hash = ''; + render(
Connected app
); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + act(() => deepLinks.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen')); + + expect(window.location.hash).toBe('#/tasks?status=open'); + expect(screen.queryByLabelText('Instance URL')).not.toBeInTheDocument(); + }); + + it('rejects malformed desktop links with a fixed redacted message and no effects', async () => { + const adapters = adaptersFor(); + const deepLinks = new DesktopDeepLinkInbox(); + render(
Shared route tree
); + + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + vi.clearAllMocks(); + act(() => deepLinks.receive('propr://connect?api=SENTINEL_ATTACKER_VALUE&token=secret')); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('ProPR Desktop could not use that link. Choose an instance and try again.'); + expect(alert).not.toHaveTextContent('SENTINEL_ATTACKER_VALUE'); + expect(adapters.connection.probe).not.toHaveBeenCalled(); + expect(adapters.authentication.authenticate).not.toHaveBeenCalled(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + }); + + it('identifies only a verified ProPR Connect endpoint while adding a profile', async () => { + const adapters = adaptersFor(); + render(
Shared route tree
); + fireEvent.click(await screen.findByRole('button', { name: /Connect to an existing instance/i })); + + const input = screen.getByLabelText('Instance URL'); + fireEvent.change(input, { target: { value: 'https://t-instance123.propr.dev' } }); + expect(screen.getByRole('status')).toHaveTextContent('Verified ProPR Connect endpoint'); + + fireEvent.change(input, { target: { value: 'https://t-instance123.propr.dev:8443' } }); + expect(screen.queryByText('Verified ProPR Connect endpoint')).not.toBeInTheDocument(); + fireEvent.change(input, { target: { value: 'https://t-instance123.foo.propr.dev' } }); + expect(screen.queryByText('Verified ProPR Connect endpoint')).not.toBeInTheDocument(); + }); + + it('supports editing a recent profile and connecting to the updated URL', async () => { + const adapters = adaptersFor([localProfile]); + render(
Connected app
); + + expect(await screen.findByText('Recent instances')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Office ProPR' } }); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://office.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ + id: 'local', + name: 'Office ProPR', + baseUrl: 'https://office.example.com', + kind: 'remote', + })); + }); + + it('derives a remote-to-loopback edit kind from the normalized submitted URL', async () => { + const adapters = adaptersFor([remoteProfile]); + render(
Connected app
); + + expect(await screen.findByText('Recent instances')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Edit Team server' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'HTTP://LOCALHOST:3000/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ + id: remoteProfile.id, + baseUrl: 'http://localhost:3000', + kind: 'local', + })); + }); + + it('opens instance management with the desktop shortcut and exposes connection status', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + renderConnectedExperience(adapters); + + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + expect(await screen.findByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('traps modal focus, makes the app inert, and restores focus to the opener', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + renderConnectedExperience(adapters); + + const opener = await screen.findByRole('button', { name: 'Connected: This computer' }); + opener.focus(); + fireEvent.click(opener); + + const dialog = await screen.findByRole('dialog', { name: 'Manage instances' }); + const app = opener.closest('.desktop-app'); + const close = screen.getByRole('button', { name: 'Close instance manager' }); + const last = screen.getByRole('button', { name: /Add instance/i }); + expect(app).toHaveAttribute('inert'); + expect(app).toHaveAttribute('aria-hidden', 'true'); + expect(dialog).toContainElement(close); + expect(close).toHaveFocus(); + + close.focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(last).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Tab' }); + expect(close).toHaveFocus(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(app).not.toHaveAttribute('inert'); + expect(opener).toHaveFocus(); + }); + + it('connects a new instance added from the manager', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + renderConnectedExperience(adapters, 'Connected app'); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + vi.clearAllMocks(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://new.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.connection.probe).toHaveBeenCalledWith(expect.objectContaining({ + name: 'New server', + baseUrl: 'https://new.example.com', + })); + expect(adapters.profiles.setActiveId).toHaveBeenCalledWith(expect.any(String)); + expect(runtimeMock.setDesktopApiBaseUrl).toHaveBeenLastCalledWith('https://new.example.com'); + expect(apiMock.setApiBaseUrl).toHaveBeenLastCalledWith('https://new.example.com'); + }); + + it.each(['new', 'active'] as const)('closes the instance manager after a %s profile starts connecting', async profileKind => { + const pendingProbe = deferred(); + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) + .mockImplementationOnce(() => pendingProbe.promise); + const adapters = adaptersFor([localProfile], localProfile.id, probe); + renderConnectedExperience(adapters, 'Connected app'); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + if (profileKind === 'new') { + fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://new.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + } else { + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://active.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + } + + expect(await screen.findByRole('heading', { name: new RegExp(`Connecting to ${profileKind === 'new' ? 'New server' : 'This computer'}`) })).toBeInTheDocument(); + await act(async () => { pendingProbe.resolve({ status: 'ready', version: '0.8.15' }); }); + + const app = await screen.findByText('Connected app'); + expect(screen.queryByRole('dialog', { name: 'Manage instances' })).not.toBeInTheDocument(); + expect(app.closest('.desktop-app')).not.toHaveAttribute('inert'); + expect(app.closest('.desktop-app')).not.toHaveAttribute('aria-hidden'); + }); + + it('reconnects an edited active instance but saves an inactive edit without connecting', async () => { + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id); + renderConnectedExperience(adapters, 'Connected app'); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + vi.clearAllMocks(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://active.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.connection.probe).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'https://active.example.com' })); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'https://active.example.com' })); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).toHaveBeenLastCalledWith('https://active.example.com'); + + vi.clearAllMocks(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Renamed team server' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Renamed team server')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ id: 'remote', name: 'Renamed team server' })); + expect(adapters.connection.probe).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('does not persist an active profile edit until the updated connection is ready', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) + .mockResolvedValueOnce({ status: 'offline', message: 'The updated server is unavailable.' }); + const adapters = adaptersFor([localProfile], localProfile.id, probe); + renderConnectedExperience(adapters, 'Connected app'); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + vi.clearAllMocks(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); + fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://unavailable.example.com/' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText(/could not reach this instance/i)).toBeInTheDocument(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('keeps a failed save in the manager editor so it can be retried', async () => { + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id); + vi.mocked(adapters.profiles.save) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Profile storage is locked.')) + .mockResolvedValueOnce(undefined); + renderConnectedExperience(adapters, 'Connected app'); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Retryable edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not save this instance.*try again/i); + expect(screen.getByRole('alert')).not.toHaveTextContent(/storage is locked/i); + expect(screen.getByLabelText('Display name')).toHaveValue('Retryable edit'); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(await screen.findByText('Retryable edit')).toBeInTheDocument(); + }); + + it('keeps a profile visible and reports a rejected removal', async () => { + const adapters = adaptersFor([remoteProfile]); + vi.mocked(adapters.profiles.remove).mockRejectedValueOnce(new Error('Profile storage is locked.')); + render(
Connected app
); + + expect(await screen.findByText('Team server')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Team server' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not remove this instance.*try again/i); + expect(screen.getByRole('alert')).not.toHaveTextContent(/storage is locked/i); + expect(screen.getByText('Team server')).toBeInTheDocument(); + expect(adapters.profiles.remove).toHaveBeenCalledWith(remoteProfile.id); + }); + + it.each(['macos', 'windows'] as const)('offers remote connection guidance instead of local setup on %s', async platform => { + const adapters = adaptersFor(); + adapters.platform = platform; + render(
Connected app
); + + expect(await screen.findByRole('heading', { name: 'Connect to ProPR' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Set up this computer/i })).not.toBeInTheDocument(); + expect(screen.getByText(/local setup is currently available on Linux/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Connect to an existing instance/i })).toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/desktop/DesktopExperience.testSupport.tsx b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx new file mode 100644 index 000000000..193019a20 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.testSupport.tsx @@ -0,0 +1,53 @@ +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { DesktopTitleBar } from './DesktopTitleBar'; +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +export const localProfile: DesktopProfile = { + id: 'local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', + kind: 'local', +}; + +export const remoteProfile: DesktopProfile = { + id: 'remote', + name: 'Team server', + baseUrl: 'https://propr.example.com', + kind: 'remote', +}; + +export const adaptersFor = ( + profiles: DesktopProfile[] = [], + activeId: string | null = null, + probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' }) +): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => profiles), + save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + getActiveId: vi.fn(async () => activeId), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: true, discover: vi.fn(async () => []) }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: true, setup: vi.fn(async () => localProfile) }, + connection: { probe: vi.fn(probe) }, +}); + +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(complete => { resolve = complete; }); + return { promise, resolve }; +} + +export const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render( + + + {content &&
{content}
} +
+); diff --git a/propr-ui/src/desktop/DesktopExperience.transport.test.tsx b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx new file mode 100644 index 000000000..b183b1030 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx @@ -0,0 +1,276 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopExperience } from './DesktopExperience'; +import { DESKTOP_ACCESS_INVALID_EVENT, type DesktopAdapters, type DesktopConnectionResult, type DesktopProfile } from './types'; + +const apiMock = vi.hoisted(() => ({ setApiBaseUrl: vi.fn() })); +const runtimeMock = vi.hoisted(() => ({ setDesktopApiBaseUrl: vi.fn() })); + +vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); +vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); + +const localProfile: DesktopProfile = { + id: 'local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', + kind: 'local', +}; + +const remoteProfile: DesktopProfile = { + id: 'remote', + name: 'Team server', + baseUrl: 'https://propr.example.com', + kind: 'remote', +}; + +const adaptersFor = ( + profiles: DesktopProfile[] = [], + activeId: string | null = null, + probe: (profile: DesktopProfile) => Promise = async () => ({ status: 'ready', version: '0.8.15' }) +): DesktopAdapters => ({ + platform: 'linux', + app: { onDeepLink: () => () => undefined }, + profiles: { + list: vi.fn(async () => profiles), + save: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + getActiveId: vi.fn(async () => activeId), + setActiveId: vi.fn(async () => undefined), + }, + discovery: { supported: true, discover: vi.fn(async () => []) }, + authentication: { authenticate: vi.fn(async () => undefined) }, + externalBrowser: { open: vi.fn(async () => undefined) }, + localSetup: { supported: true, setup: vi.fn(async () => localProfile) }, + connection: { probe: vi.fn(probe) }, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(complete => { resolve = complete; }); + return { promise, resolve }; +} + +describe('DesktopExperience transport and fencing', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows a retryable offline state and recovers without reloading', async () => { + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'offline', message: 'The instance is offline.' }) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor([localProfile], localProfile.id, probe); + render(
Dashboard content
); + + expect(await screen.findByRole('heading', { name: 'This computer' })).toBeInTheDocument(); + expect(screen.getByText(/could not reach this instance/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Try again/i })); + + expect(await screen.findByText('Dashboard content')).toBeInTheDocument(); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('shows a retryable failure when the connection adapter rejects', async () => { + const probe = vi.fn() + .mockRejectedValueOnce(new Error('The desktop host did not respond.')) + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }); + const adapters = adaptersFor([localProfile], localProfile.id, probe); + render(
Dashboard content
); + + expect(await screen.findByText(/could not check this instance/i)).toBeInTheDocument(); + expect(screen.queryByText(/desktop host did not respond/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Try again/i })); + + expect(await screen.findByText('Dashboard content')).toBeInTheDocument(); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('reports persistence failures distinctly and allows retrying', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + vi.mocked(adapters.profiles.save) + .mockRejectedValueOnce(new Error('Profile storage is unavailable.')) + .mockResolvedValueOnce(undefined); + render(
Dashboard content
); + + expect(await screen.findByText(/could not save this connection/i)).toBeInTheDocument(); + expect(screen.queryByText(/profile storage is unavailable/i)).not.toBeInTheDocument(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /Try again/i })); + + expect(await screen.findByText('Dashboard content')).toBeInTheDocument(); + expect(adapters.profiles.save).toHaveBeenCalledTimes(2); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + }); + + it('uses one activation commit instead of renderer setActive and never publishes a failed B selection', async () => { + const probe = vi.fn(async (profile: DesktopProfile): Promise => ({ + status: 'ready', + version: '0.8.15', + activationTicket: `ticket-${profile.id}`, + })); + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id, probe); + adapters.connection.activate = vi.fn() + .mockResolvedValueOnce({ status: 'ready', transportScope: 'scope-a' }) + .mockRejectedValueOnce(new Error('Profile selection could not be written.')); + adapters.connection.publishActivation = vi.fn(); + render(
Connected app
); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(adapters.connection.publishActivation).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click((await screen.findByText('Team server')).closest('button')!); + + expect(await screen.findByText(/could not save this connection/i)).toBeInTheDocument(); + expect(screen.queryByText(/selection could not be written/i)).not.toBeInTheDocument(); + expect(adapters.profiles.setActiveId).not.toHaveBeenCalled(); + expect(adapters.connection.publishActivation).toHaveBeenCalledTimes(1); + }); + + it('does not publish ready state when main activation reports a changed profile binding', async () => { + const adapters = adaptersFor([localProfile], localProfile.id); + adapters.connection.activate = vi.fn(async () => ({ + status: 'authentication-required' as const, + message: 'This connection changed while it was being activated.', + })); + adapters.connection.publishActivation = vi.fn(); + + render(
Wrong profile app
); + + expect(await screen.findByText(/connection changed while it was being activated/i)).toBeInTheDocument(); + expect(screen.queryByText('Wrong profile app')).not.toBeInTheDocument(); + expect(adapters.connection.publishActivation).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('ignores a stale connection result after the adapters change', async () => { + let resolveFirstProbe: ((result: DesktopConnectionResult) => void) | undefined; + const firstProbe = vi.fn(() => new Promise(resolve => { + resolveFirstProbe = resolve; + })); + const firstAdapters = adaptersFor([localProfile], localProfile.id, firstProbe); + const replacementProfile = { ...localProfile, id: 'replacement', name: 'Replacement instance' }; + const replacementAdapters = adaptersFor( + [replacementProfile], + replacementProfile.id, + async () => ({ status: 'offline', message: 'The replacement instance is unavailable.' }) + ); + const { rerender } = render( +
Stale dashboard
+ ); + + await waitFor(() => expect(firstProbe).toHaveBeenCalledOnce()); + rerender(
Replacement dashboard
); + expect(await screen.findByText(/could not reach this instance/i)).toBeInTheDocument(); + + await act(async () => { + resolveFirstProbe?.({ status: 'ready', version: '0.8.15' }); + }); + + expect(screen.getByText(/could not reach this instance/i)).toBeInTheDocument(); + expect(screen.queryByText('Stale dashboard')).not.toBeInTheDocument(); + expect(firstAdapters.profiles.save).not.toHaveBeenCalled(); + }); + + it('serializes deferred persistence so the latest connection owns the stored profile and active ID', async () => { + const firstSave = deferred(); + let storedProfile: DesktopProfile | null = null; + let storedActiveId: string | null = null; + const adapters = adaptersFor([localProfile, remoteProfile]); + vi.mocked(adapters.profiles.save).mockImplementation(async profile => { + if (vi.mocked(adapters.profiles.save).mock.calls.length === 1) { + await firstSave.promise; + } + storedProfile = profile; + }); + vi.mocked(adapters.profiles.setActiveId).mockImplementation(async id => { storedActiveId = id; }); + render(
Latest dashboard
); + + expect(await screen.findByText('Recent instances')).toBeInTheDocument(); + fireEvent.click(screen.getByText('This computer').closest('button')!); + await waitFor(() => expect(adapters.profiles.save).toHaveBeenCalledOnce()); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click((await screen.findByText('Team server')).closest('button')!); + await waitFor(() => expect(adapters.connection.probe).toHaveBeenCalledWith(remoteProfile)); + expect(adapters.profiles.save).toHaveBeenCalledOnce(); + + await act(async () => { firstSave.resolve(); }); + + expect(await screen.findByText('Latest dashboard')).toBeInTheDocument(); + expect(storedProfile).toMatchObject({ id: remoteProfile.id, baseUrl: remoteProfile.baseUrl }); + expect(storedActiveId).toBe(remoteProfile.id); + expect(adapters.profiles.setActiveId).toHaveBeenCalledTimes(1); + }); + + it('offers Back while probing and prevents a cancelled probe from committing', async () => { + const pendingProbe = deferred(); + const adapters = adaptersFor([localProfile], null, () => pendingProbe.promise); + render(
Cancelled dashboard
); + + fireEvent.click((await screen.findByText('This computer')).closest('button')!); + expect(await screen.findByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByText('Recent instances')).toBeInTheDocument(); + + await act(async () => { pendingProbe.resolve({ status: 'ready', version: '0.8.15' }); }); + + expect(screen.queryByText('Cancelled dashboard')).not.toBeInTheDocument(); + expect(adapters.profiles.save).not.toHaveBeenCalled(); + expect(adapters.profiles.setActiveId).toHaveBeenCalledWith(null); + }); + + it('settles a rejected fire-and-forget authentication cancellation during shutdown', async () => { + const adapters = adaptersFor([localProfile], null, async () => ({ + status: 'authentication-required', message: 'Sign in required.', + })); + adapters.authentication.cancel = vi.fn(async () => { throw new Error('private IPC cancellation failure'); }); + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + const { unmount } = render( +
Cancelled app
+ ); + + fireEvent.click((await screen.findByText('This computer')).closest('button')!); + expect(await screen.findByText('Sign in to continue to this instance.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Choose another instance' })); + unmount(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(adapters.authentication.cancel).toHaveBeenCalledWith(localProfile.id); + expect(unhandled).not.toHaveBeenCalled(); + window.removeEventListener('unhandledrejection', unhandled); + }); + + it('ignores a delayed access-invalid event from A after B has connected', async () => { + const probe = vi.fn(async (profile: DesktopProfile): Promise => ({ + status: 'ready', + version: '0.8.15', + transportScope: profile.id === localProfile.id ? 'scope-11' : 'scope-12', + })); + const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id, probe); + adapters.connection.deactivate = vi.fn(); + render(
Connected app
); + + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click((await screen.findByText('Team server')).closest('button')!); + await waitFor(() => expect(probe).toHaveBeenCalledWith(remoteProfile)); + expect(await screen.findByText('Connected app')).toBeInTheDocument(); + + window.dispatchEvent(new CustomEvent(DESKTOP_ACCESS_INVALID_EVENT, { + detail: { profileId: localProfile.id, transportScope: 'scope-11', code: 'INVALID_INSTANCE_TOKEN' }, + })); + + expect(screen.getByText('Connected app')).toBeInTheDocument(); + expect(adapters.connection.deactivate).not.toHaveBeenCalled(); + }); + +}); diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx new file mode 100644 index 000000000..30e9c099e --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperience.tsx @@ -0,0 +1,395 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { parseProprConnectEndpoint } from '@propr/shared'; +import { LoaderCircle } from 'lucide-react'; +import { setApiBaseUrl } from '../api/apiClient'; +import * as runtimeConfig from '../config/runtimeConfig'; +import type { DesktopDeepLinkInbox } from '../desktop-deep-link'; +import { DesktopConnectedExperience } from './DesktopConnectedExperience'; +import { useAttemptFence, useDesktopModal, useSerializedMutationQueue } from './desktopExperienceHooks'; +import { ConnectionPanel, DesktopBrand, InstanceChooser, ManagedRecoveryReview, ProfileEditor } from './DesktopExperiencePanels'; +import { managedRecoveryMessage, managedRediscoveryUnavailableMessage, safeConnectionMessage } from './desktopExperienceMessages'; +import { mergeProfiles, recoverableError, settleAuthenticationCancellation, type ExperienceState } from './desktopExperienceState'; +import { DESKTOP_ACCESS_INVALID_EVENT, type DesktopAccessInvalidEventDetail, type DesktopAdapters, type DesktopConnectionResult, type DesktopProfile } from './types'; +import { useDesktopDeepLinks } from './useDesktopDeepLinks'; +import './desktop.css'; + +interface DesktopExperienceProps { + adapters: DesktopAdapters; + deepLinks?: DesktopDeepLinkInbox; + children: React.ReactNode; +} + +export const DesktopExperience: React.FC = ({ adapters, deepLinks, children }) => { + const [profiles, setProfiles] = useState([]); + const [state, setState] = useState({ phase: 'loading' }); + const [editing, setEditing] = useState(null); + const [managerOpen, setManagerOpen] = useState(false); + const [operationError, setOperationError] = useState(null); + const [busy, setBusy] = useState(false); + const connectionAttempt = useRef(0); + const activeProfileId = useRef(null); + const stateRef = useRef(state); + stateRef.current = state; + const { begin: beginDiscoveryAttempt, invalidate: invalidateDiscovery } = useAttemptFence(); + const cancelDiscovery = useCallback(() => { + invalidateDiscovery(); + setBusy(false); + }, [invalidateDiscovery]); + const stageConnectCandidate = useCallback((candidate: DesktopProfile, phase: ExperienceState['phase']) => { + cancelDiscovery(); + setOperationError(null); + setEditing(candidate); + if (phase === 'connected') setManagerOpen(true); + else if (phase !== 'loading') { + connectionAttempt.current += 1; + setState({ phase: 'choose' }); + } + }, [cancelDiscovery]); + const { + deepLinkError, + editorNotice, + clearConnectCandidate, + hasPendingConnectCandidate, + } = useDesktopDeepLinks({ + deepLinks, + phase: state.phase, + profileId: state.phase === 'connecting' || state.phase === 'connected' ? state.profile.id : null, + activeProfileId, + onStageConnectCandidate: stageConnectCandidate, + }); + const enqueueProfileMutation = useSerializedMutationQueue(); + const closeManager = useCallback(() => { + cancelDiscovery(); + clearConnectCandidate(); + setManagerOpen(false); + setEditing(null); + }, [cancelDiscovery, clearConnectCandidate]); + const { dialogRef: managerRef, openModal: openManager } = useDesktopModal(managerOpen, setManagerOpen, closeManager); + const reportAcceptanceStage = useCallback(async ( + stage: Parameters['reportJourneyStage']>[0], + ): Promise => { + try { + await adapters.acceptance?.reportJourneyStage(stage); + } catch { + // Acceptance diagnostics must never alter the renderer lifecycle they observe. + } + }, [adapters]); + + const connect = useCallback(async (profile: DesktopProfile) => { + cancelDiscovery(); + const attempt = ++connectionAttempt.current; + const isCurrentAttempt = () => connectionAttempt.current === attempt; + setOperationError(null); + setState({ phase: 'connecting', profile }); + let operation: 'probe' | 'persist' = 'probe'; + try { + const probeResult = await adapters.connection.probe(profile); + if (!isCurrentAttempt()) return; + if (probeResult.status !== 'ready') { + if (probeResult.status === 'authentication-required') { + await reportAcceptanceStage('AUTHENTICATION_REQUIRED'); + } + setState({ + phase: 'blocked', + profile, + result: { ...probeResult, message: safeConnectionMessage(probeResult, Boolean(parseProprConnectEndpoint(profile.baseUrl))) }, + }); + return; + } + await reportAcceptanceStage('AUTHENTICATED_REPROBE_READY'); + + operation = 'persist'; + const connectedProfile = { ...profile, lastConnectedAt: new Date().toISOString() }; + let result: DesktopConnectionResult = probeResult; + await enqueueProfileMutation(async () => { + if (!isCurrentAttempt()) return; + await adapters.profiles.save(connectedProfile); + if (!isCurrentAttempt()) return; + if (adapters.connection.activate) { + result = await adapters.connection.activate(connectedProfile, probeResult, isCurrentAttempt); + } + else if (activeProfileId.current !== profile.id) await adapters.profiles.setActiveId(profile.id); + if (result.status === 'ready') { + activeProfileId.current = profile.id; + await reportAcceptanceStage('ACTIVATION_COMMITTED'); + } + }); + if (!isCurrentAttempt()) return; + setProfiles(current => mergeProfiles(current, [connectedProfile])); + if (result.status !== 'ready') { + setState({ phase: 'blocked', profile: connectedProfile, result }); + return; + } + runtimeConfig.setDesktopApiBaseUrl(connectedProfile.baseUrl); + if (adapters.connection.publishActivation) adapters.connection.publishActivation(connectedProfile, result); + else setApiBaseUrl(connectedProfile.baseUrl); + await reportAcceptanceStage('ACTIVATION_PUBLISHED'); + setState({ phase: 'connected', profile: connectedProfile, result }); + } catch { + if (!isCurrentAttempt()) return; + const message = operation === 'persist' + ? 'The instance is reachable, but ProPR Desktop could not save this connection. Try again.' + : 'ProPR Desktop could not check this instance. Try again.'; + setState({ phase: 'blocked', profile, result: { status: 'offline', message } }); + } + }, [adapters, cancelDiscovery, enqueueProfileMutation, reportAcceptanceStage]); + + useEffect(() => { + let cancelled = false; + activeProfileId.current = null; + void Promise.all([adapters.profiles.list(), adapters.profiles.getActiveId()]).then(([stored, activeId]) => { + if (cancelled) return; + activeProfileId.current = activeId; + setProfiles(stored); + if (hasPendingConnectCandidate()) { + setState({ phase: 'choose' }); + return; + } + const active = stored.find(profile => profile.id === activeId); + if (active) void connect(active); + else setState({ phase: 'choose' }); + }).catch(() => { + if (!cancelled) { + setOperationError('Profiles could not be loaded. Try again.'); + setState({ phase: 'choose' }); + } + }); + return () => { + cancelled = true; + connectionAttempt.current += 1; + invalidateDiscovery(); + }; + }, [adapters, connect, hasPendingConnectCandidate, invalidateDiscovery]); + + useEffect(() => { + const accessInvalid = (event: Event) => { + const detail = (event as CustomEvent).detail; + setState(current => { + if (current.phase !== 'connected') return current; + if (!detail || detail.profileId !== current.profile.id || detail.transportScope !== current.result.transportScope) return current; + adapters.connection.deactivate?.(); + return { + phase: 'blocked', + profile: current.profile, + result: { status: 'authentication-required', + message: 'Access to this instance was revoked or expired. Pair again to continue.', + version: current.result.version, authentication: current.result.authentication }, + }; + }); + }; + window.addEventListener(DESKTOP_ACCESS_INVALID_EVENT, accessInvalid); + return () => window.removeEventListener(DESKTOP_ACCESS_INVALID_EVENT, accessInvalid); + }, [adapters]); + + useEffect(() => { + const handleKeyboard = (event: KeyboardEvent) => { + const current = stateRef.current; + if (current.phase !== 'connected') return; + if ((event.metaKey || event.ctrlKey) && event.key === ',') { + event.preventDefault(); + openManager(); + } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'r') { + event.preventDefault(); + void connect(current.profile); + } + }; + document.addEventListener('keydown', handleKeyboard); + return () => document.removeEventListener('keydown', handleKeyboard); + }, [connect, openManager]); + + const removeProfile = async (profile: DesktopProfile) => { + cancelDiscovery(); + if (!window.confirm(`Remove “${profile.name}” from this computer?`)) return; + setOperationError(null); + try { + await enqueueProfileMutation(() => adapters.profiles.remove(profile.id)); + setProfiles(current => current.filter(item => item.id !== profile.id)); + if (activeProfileId.current === profile.id) activeProfileId.current = null; + if (state.phase === 'connected' && state.profile.id === profile.id) { + adapters.connection.deactivate?.(); + setState({ phase: 'choose' }); + } + } catch { + setOperationError(recoverableError('ProPR Desktop could not remove this instance.')); + } + }; + + const saveProfile = async (profile: DesktopProfile, shouldConnect = true) => { + cancelDiscovery(); + clearConnectCandidate(); + setOperationError(null); + if (shouldConnect) { + closeManager(); + await connect(profile); + return; + } + + try { + await enqueueProfileMutation(() => adapters.profiles.save(profile)); + setProfiles(current => mergeProfiles(current, [profile])); + setEditing(null); + } catch { + setOperationError(recoverableError('ProPR Desktop could not save this instance.')); + } + }; + + const setupLocal = async () => { + cancelDiscovery(); + setBusy(true); + setOperationError(null); + try { + const profile = await adapters.localSetup.setup(); + await saveProfile(profile); + } catch { + setOperationError('Local setup could not be started. Try again.'); + } finally { + setBusy(false); + } + }; + + const discover = async () => { + const isCurrentAttempt = beginDiscoveryAttempt(); + setBusy(true); + setOperationError(null); + try { + const discovered = await adapters.discovery.discover(); + if (!isCurrentAttempt()) return; + const candidate = discovered[0]; + if (candidate) { + // Discovery is evidence for a proposed endpoint, never permission to + // persist, pair, or activate it. The editor owns explicit confirmation. + setEditing(candidate); + } else { + setOperationError('No new ProPR instances were found on this network.'); + } + } catch { + if (isCurrentAttempt()) setOperationError('Network discovery is unavailable. Try again.'); + } finally { + if (isCurrentAttempt()) setBusy(false); + } + }; + + const choose = () => { + cancelDiscovery(); + if ('profile' in state) settleAuthenticationCancellation(adapters, state.profile.id); + adapters.connection.deactivate?.(); + const attempt = ++connectionAttempt.current; + void enqueueProfileMutation(async () => { + if (connectionAttempt.current !== attempt) return; + await adapters.profiles.setActiveId(null); + activeProfileId.current = null; + }).catch(() => { + if (connectionAttempt.current === attempt) setOperationError(recoverableError('ProPR Desktop could not clear the active instance.')); + }); + setManagerOpen(false); + setEditing(null); + setState({ phase: 'choose' }); + }; + + const retry = () => { if ('profile' in state) void connect(state.profile); }; + + const runBlockedAction = async ( + profile: DesktopProfile, + action: () => Promise, + failureMessage: string, + connectFailureMessage?: string, + onSuccess?: () => Promise, + ) => { + cancelDiscovery(); + const attempt = connectionAttempt.current; + try { + await action(); + if (connectionAttempt.current === attempt) await onSuccess?.(); + } catch { + const message = recoverableError(failureMessage); + setState(current => current.phase === 'blocked' && current.profile.id === profile.id + ? { + ...current, + result: parseProprConnectEndpoint(profile.baseUrl) && connectFailureMessage + ? { status: 'offline', message: recoverableError(connectFailureMessage) } + : { ...current.result, message }, + } + : current); + } + }; + + const openEditor = (profile: DesktopProfile | 'new') => { + cancelDiscovery(); + clearConnectCandidate(); + setOperationError(null); + setEditing(profile); + }; + + const closeEditor = () => { + cancelDiscovery(); + clearConnectCandidate(); + setEditing(null); + }; + + const reenterManagedEndpoint = (profile: DesktopProfile) => { + cancelDiscovery(); + connectionAttempt.current += 1; + setOperationError(null); + setState({ phase: 'choose' }); + setEditing({ ...profile, baseUrl: '' }); + }; + + const rediscoverManagedEndpoint = async (profile: DesktopProfile) => { + const isCurrentDiscovery = beginDiscoveryAttempt(); + const attempt = ++connectionAttempt.current; + const showUnavailable = () => { + if (connectionAttempt.current !== attempt || !isCurrentDiscovery()) return; + setState(current => current.phase === 'blocked' && current.profile.id === profile.id + ? { + phase: 'blocked', + profile, + result: { status: 'offline', message: managedRediscoveryUnavailableMessage }, + } + : current); + }; + if (!adapters.managedTunnelRecovery) { + showUnavailable(); + return; + } + try { + const discovered = await adapters.managedTunnelRecovery.rediscover(profile.id); + if (connectionAttempt.current !== attempt || !isCurrentDiscovery()) return; + if (!discovered || discovered.id !== profile.id) return showUnavailable(); + const endpoint = parseProprConnectEndpoint(discovered.baseUrl); + if (!endpoint) return showUnavailable(); + setState({ + phase: 'recovery-review', + profile, + candidate: { ...profile, baseUrl: endpoint.origin, kind: 'remote' }, + }); + } catch { + showUnavailable(); + } + }; + + const content = () => { + if (state.phase === 'loading') return
Opening ProPR…
; + if (state.phase === 'connecting') return undefined} onHelp={() => undefined} onReenter={() => undefined} onRediscover={() => undefined} />; + if (state.phase === 'recovery-review') return { cancelDiscovery(); setState({ phase: 'blocked', profile: state.profile, result: { status: 'offline', message: managedRecoveryMessage } }); }} onConfirm={() => void connect(state.candidate)} />; + if (state.phase === 'blocked') return void runBlockedAction(state.profile, async () => { + await adapters.authentication.authenticate(state.profile); + await reportAcceptanceStage('CREDENTIAL_COMMITTED'); + }, 'ProPR Desktop could not open sign in.', 'ProPR Connect pairing could not be completed.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} onReenter={() => reenterManagedEndpoint(state.profile)} onRediscover={() => void rediscoverManagedEndpoint(state.profile)} />; + if (editing) return
void saveProfile(profile)} />
; + return void setupLocal()} onConnectNew={() => openEditor('new')} onDiscover={() => void discover()} onConnect={profile => void connect(profile)} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} />; + }; + + if (state.phase !== 'connected') return
{deepLinkError &&
{deepLinkError}
}{content()}
; + + return ( + {children} + ); +}; diff --git a/propr-ui/src/desktop/DesktopExperiencePanels.tsx b/propr-ui/src/desktop/DesktopExperiencePanels.tsx new file mode 100644 index 000000000..8288beba8 --- /dev/null +++ b/propr-ui/src/desktop/DesktopExperiencePanels.tsx @@ -0,0 +1,236 @@ +import React, { useState } from 'react'; +import { isProprLoopbackHostname, parseProprConnectEndpoint } from '@propr/shared'; +import { + AlertTriangle, + ArrowLeft, + ChevronRight, + Cloud, + Computer, + LoaderCircle, + Pencil, + RefreshCw, + Search, + Server, + Trash2, +} from 'lucide-react'; +import { normalizeBaseUrl } from './browserAdapters'; +import type { DesktopConnectionResult, DesktopProfile } from './types'; + +const createProfileId = (): string => { + try { return crypto.randomUUID(); } catch { return `profile-${Date.now()}`; } +}; + +const safeVersion = (version: string | undefined): string | null => + version && /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(version) ? version : null; + +const safeProfileDisplayLabel = (name: string): string => { + const normalized = name.replace(/[\p{Cc}\p{Cf}]/gu, ' ').trim(); + const bounded = Array.from(normalized).slice(0, 80).join(''); + if (!bounded || /https?:|\.propr\.dev\b|[/?#@\\]|token|secret|password/i.test(bounded)) { + return 'Saved connection'; + } + return bounded; +}; + +const connectionLabel = (result: DesktopConnectionResult): string => { + if (result.status === 'incompatible') return 'Update required'; + if (result.status === 'authentication-required') return 'Sign in required'; + if (result.status === 'offline') return 'Instance unavailable'; + return 'Connected'; +}; + +export const DesktopBrand: React.FC = () => ( +
+ + ProPR +
+); + +interface ProfileEditorProps { + initial?: DesktopProfile; + candidate?: boolean; + notice?: string | null; + operationError?: string | null; + onCancel(): void; + onSave(profile: DesktopProfile): void; +} + +export const ProfileEditor: React.FC = ({ initial, candidate = false, notice, operationError, onCancel, onSave }) => { + const [name, setName] = useState(initial?.name || 'My ProPR'); + const [baseUrl, setBaseUrl] = useState(initial ? initial.baseUrl : 'http://127.0.0.1:3000'); + const [validationError, setValidationError] = useState(null); + const connectEndpoint = parseProprConnectEndpoint(baseUrl); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + try { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const hostname = new URL(normalizedBaseUrl).hostname; + onSave({ + id: initial?.id || createProfileId(), + name: name.trim() || 'My ProPR', + baseUrl: normalizedBaseUrl, + kind: isProprLoopbackHostname(hostname) ? 'local' : 'remote', + lastConnectedAt: initial?.lastConnectedAt, + }); + } catch (caught) { + setValidationError(caught instanceof Error ? caught.message : 'Enter a valid instance URL.'); + } + }; + + const error = validationError || operationError; + return ( +
+ +

{candidate || !initial ? 'Connect to an instance' : 'Edit instance'}

+

Enter the address shown by your ProPR server.

+ {notice &&
{notice}
} + + + {connectEndpoint &&
} + {error && } + +
+ ); +}; + +interface ProfileListProps { + profiles: DesktopProfile[]; + onConnect(profile: DesktopProfile): void; + onEdit(profile: DesktopProfile): void; + onRemove(profile: DesktopProfile): void; +} + +export const ProfileList: React.FC = ({ profiles, onConnect, onEdit, onRemove }) => ( +
+

Recent instances

+
+ {profiles.map(profile => ( +
+ + + +
+ ))} +
+
+); + +interface ChooserProps extends ProfileListProps { + busy: boolean; + error: string | null; + localSetupSupported: boolean; + networkDiscoverySupported: boolean; + onLocalSetup(): void; + onConnectNew(): void; + onDiscover(): void; +} + +export const InstanceChooser: React.FC = ({ + profiles, busy, error, localSetupSupported, networkDiscoverySupported, + onLocalSetup, onConnectNew, onDiscover, ...listProps +}) => ( +
+ +
+ ProPR Desktop +

{profiles.length ? 'Choose an instance' : localSetupSupported ? 'Let’s set up this computer' : 'Connect to ProPR'}

+

{localSetupSupported + ? 'Keep your repositories and coding agents close, or connect securely to a ProPR instance you already use.' + : 'Local setup is currently available on Linux. Connect securely to a ProPR instance hosted elsewhere.'}

+
+
+ {localSetupSupported && ( + + )} + +
+ {error &&
{error}
} + {profiles.length > 0 && } + {networkDiscoverySupported && ( + + )} +
+); + +interface ConnectionPanelProps { + profile: DesktopProfile; + result?: Exclude; + onBack(): void; + onRetry(): void; + onAuthenticate(): void; + onHelp(): void; + onReenter(): void; + onRediscover(): void; +} + +export const ConnectionPanel = ({ profile, result, onBack, onRetry, onAuthenticate, onHelp, onReenter, onRediscover }: ConnectionPanelProps) => { + const managed = Boolean(result && parseProprConnectEndpoint(profile.baseUrl)); + return ( +
+ + {!result ? ( + <>

Connecting to {profile.name}

Checking the instance and desktop compatibility…

+ ) : ( + <> +
+ {connectionLabel(result)}

{profile.name}

+

{result.message}

+ {result.status === 'incompatible' && safeVersion(result.version) &&
Instance version {safeVersion(result.version)} · Desktop {__APP_VERSION__}
} + {'authentication' in result && result.authentication &&
{result.authentication}
} +
+ {result.status === 'authentication-required' && } + + {managed && } + {managed && } + + +
+ + )} +
+ ); +}; + +export const ManagedRecoveryReview = ({ profile, onCancel, onConfirm }: { + profile: DesktopProfile; + onCancel(): void; + onConfirm(): void; +}) => ( +
+ +
+ ProPR Connect rediscovered +

Use the rediscovered endpoint?

+

A replacement endpoint was discovered for the saved connection “{safeProfileDisplayLabel(profile.name)}”. Confirm before updating that connection.

+
+ + +
+
+); diff --git a/propr-ui/src/desktop/DesktopPresentationBoundary.test.tsx b/propr-ui/src/desktop/DesktopPresentationBoundary.test.tsx new file mode 100644 index 000000000..5cad17759 --- /dev/null +++ b/propr-ui/src/desktop/DesktopPresentationBoundary.test.tsx @@ -0,0 +1,60 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DesktopPresentationBoundary } from './DesktopPresentationBoundary'; +import type { ProprDesktopBridge } from './types'; + +const bridgeWithDeepLinks = () => { + const listeners = new Set<(value: string) => void>(); + const onDeepLink = vi.fn((listener: (value: string) => void) => { + listeners.add(listener); + return vi.fn(() => listeners.delete(listener)); + }); + const bridge: ProprDesktopBridge = { + isDesktop: true, + platform: 'linux', + app: { onDeepLink }, + profiles: { + list: async () => [], + save: async () => undefined, + remove: async () => undefined, + getActiveId: async () => null, + setActiveId: async () => undefined, + }, + discovery: { supported: false, discover: async () => [] }, + authentication: { authenticate: async () => undefined }, + externalBrowser: { open: async () => undefined }, + localSetup: { supported: false, setup: async () => { throw new Error('not used'); } }, + connection: { probe: async () => ({ status: 'ready' }) }, + }; + return { bridge, listeners, onDeepLink }; +}; + +describe('DesktopPresentationBoundary deep-link subscription', () => { + afterEach(() => { + delete window.__PROPR_DESKTOP__; + vi.restoreAllMocks(); + }); + + it('subscribes once, tears down, and does not replay a consumed candidate after remount', async () => { + const { bridge, listeners, onDeepLink } = bridgeWithDeepLinks(); + window.__PROPR_DESKTOP__ = bridge; + const first = render(Desktop app
} fallback={
Web app
} />); + + expect(await screen.findByRole('heading', { name: 'Connect to ProPR' })).toBeInTheDocument(); + expect(onDeepLink).toHaveBeenCalledOnce(); + first.rerender(Desktop app
} fallback={
Web app
} />); + expect(onDeepLink).toHaveBeenCalledOnce(); + act(() => listeners.forEach(listener => listener('propr://connect?api=https%3A%2F%2Ffirst.example'))); + expect(await screen.findByDisplayValue('https://first.example')).toBeInTheDocument(); + + const unsubscribe = onDeepLink.mock.results[0]?.value; + first.unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(listeners.size).toBe(0); + + render(Desktop app
} fallback={
Web app
} />); + expect(await screen.findByRole('heading', { name: 'Connect to ProPR' })).toBeInTheDocument(); + expect(screen.queryByDisplayValue('https://first.example')).not.toBeInTheDocument(); + expect(onDeepLink).toHaveBeenCalledTimes(2); + }); +}); diff --git a/propr-ui/src/desktop/DesktopPresentationBoundary.tsx b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx new file mode 100644 index 000000000..1c0cc0383 --- /dev/null +++ b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx @@ -0,0 +1,22 @@ +import React, { useEffect, useState } from 'react'; +import { DesktopDeepLinkInbox } from '../desktop-deep-link'; +import { resolveDesktopAdapters } from './browserAdapters'; +import { DesktopExperience } from './DesktopExperience'; + +interface DesktopPresentationBoundaryProps { + desktop: React.ReactNode; + fallback: React.ReactNode; +} + +/** Keeps desktop detection at the application edge and leaves the route tree shared. */ +export const DesktopPresentationBoundary: React.FC = ({ desktop, fallback }) => { + const adapters = useState(resolveDesktopAdapters)[0]; + const inbox = useState(() => new DesktopDeepLinkInbox())[0]; + + useEffect(() => { + if (!adapters) return; + return adapters.app.onDeepLink(value => inbox.receive(value)); + }, [adapters, inbox]); + + return adapters ? {desktop} : fallback; +}; diff --git a/propr-ui/src/desktop/DesktopTitleBar.tsx b/propr-ui/src/desktop/DesktopTitleBar.tsx new file mode 100644 index 000000000..c8c766917 --- /dev/null +++ b/propr-ui/src/desktop/DesktopTitleBar.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; +import { ChevronDown, CircleAlert, CloudOff, RefreshCw, Wifi } from 'lucide-react'; +import { useDesktop } from './DesktopContext'; + +interface DesktopTitleBarProps { + /** Authenticated REST and Socket.IO are ready for the published desktop scope. */ + transportReady?: boolean; +} + +export const DesktopTitleBar: React.FC = ({ transportReady = false }) => { + const desktop = useDesktop(); + const connected = desktop?.connection.status === 'ready'; + + useEffect(() => { + if (!connected || !transportReady) return; + void desktop?.reportConnectedRendererReady?.().catch(() => { + // Acceptance diagnostics must never alter the renderer lifecycle they observe. + }); + }, [connected, desktop, transportReady]); + + if (!desktop) return null; + const incompatible = desktop.connection.status === 'incompatible'; + const label = connected ? 'Connected' : incompatible ? 'Update required' : 'Offline'; + + return ( +
+ + ); +}; diff --git a/propr-ui/src/desktop/browserAdapters.test.ts b/propr-ui/src/desktop/browserAdapters.test.ts new file mode 100644 index 000000000..5e7c291e1 --- /dev/null +++ b/propr-ui/src/desktop/browserAdapters.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import { normalizeBaseUrl, resolveDesktopAdapters } from './browserAdapters'; +import { DESKTOP_AUTHENTICATION_COMPLETE_EVENT } from './types'; + +describe('desktop browser fixtures', () => { + afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState(null, '', '/'); + delete window.__PROPR_DESKTOP__; + vi.restoreAllMocks(); + }); + + it('does not enable desktop presentation for the normal hosted web app', () => { + expect(resolveDesktopAdapters()).toBeNull(); + }); + + it('explicitly enables deterministic screenshot fixtures', async () => { + window.history.replaceState(null, '', '/?desktop-fixture=recents'); + const adapters = resolveDesktopAdapters(); + expect(adapters).not.toBeNull(); + await expect(adapters?.profiles.list()).resolves.toHaveLength(2); + expect(adapters?.discovery.supported).toBe(false); + }); + + it('does not enable query-driven fixtures in production mode', () => { + vi.stubEnv('DEV', false); + window.history.replaceState(null, '', '/?desktop-fixture=connected'); + + expect(resolveDesktopAdapters()).toBeNull(); + }); + + it('normalizes safe instance origins and rejects unsafe URL components', () => { + expect(normalizeBaseUrl(' https://propr.example.com/// ')).toBe('https://propr.example.com'); + for (const unsafe of [ + 'file:///tmp/propr', + 'https://user:secret@example.com', + 'https://propr.example.com/api', + 'https://propr.example.com?token=secret', + ]) { + expect(() => normalizeBaseUrl(unsafe)).toThrow('The configured ProPR API URL is invalid.'); + } + }); + + it('matches the shared canonical origin parity table', () => { + for (const [, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + if (expected === null) expect(() => normalizeBaseUrl(input)).toThrow(); + else expect(normalizeBaseUrl(input)).toBe(expected); + } + }); + + it('resolves fixture authentication only after the matching desktop completion signal', async () => { + window.history.replaceState(null, '', '/?desktop-fixture=connected'); + const open = vi.spyOn(window, 'open').mockReturnValue({} as Window); + const adapters = resolveDesktopAdapters(); + const profile = (await adapters?.profiles.list())?.[0]; + expect(adapters).not.toBeNull(); + expect(profile).toBeDefined(); + + let completed = false; + const authentication = adapters!.authentication.authenticate(profile!); + void authentication.then(() => { completed = true; }); + await Promise.resolve(); + + expect(completed).toBe(false); + window.dispatchEvent(new CustomEvent(DESKTOP_AUTHENTICATION_COMPLETE_EVENT, { + detail: { profileId: 'another-profile' }, + })); + await Promise.resolve(); + expect(completed).toBe(false); + + window.dispatchEvent(new CustomEvent(DESKTOP_AUTHENTICATION_COMPLETE_EVENT, { + detail: { profileId: profile!.id }, + })); + await expect(authentication).resolves.toBeUndefined(); + expect(completed).toBe(true); + expect(decodeURIComponent(open.mock.calls[0]?.[0] as string)).toContain( + `propr://authentication-complete?profile_id=${profile!.id}` + ); + }); +}); diff --git a/propr-ui/src/desktop/browserAdapters.ts b/propr-ui/src/desktop/browserAdapters.ts new file mode 100644 index 000000000..a85a57ff1 --- /dev/null +++ b/propr-ui/src/desktop/browserAdapters.ts @@ -0,0 +1,201 @@ +import { normalizeApiBaseUrl, ProprClientError } from '@propr/client'; +import { evaluateProprApiCompatibility } from '@propr/shared'; +import { createElectronDesktopAdapters } from './electronAdapters'; +import type { + DesktopAdapters, + DesktopAuthenticationCompleteEventDetail, + DesktopConnectionResult, + DesktopPlatform, + DesktopProfile, + ProprDesktopBridge, +} from './types'; +import { DESKTOP_AUTHENTICATION_COMPLETE_EVENT } from './types'; + +const PROFILES_KEY = 'propr.desktop.profiles'; +const ACTIVE_PROFILE_KEY = 'propr.desktop.activeProfile'; +const FIXTURE_QUERY_KEY = 'desktop-fixture'; +const AUTHENTICATION_TIMEOUT_MS = 5 * 60_000; + +type DesktopFixture = 'first-run' | 'recents' | 'offline' | 'incompatible' | 'connected'; + +const fixtureProfile: DesktopProfile = { + id: 'fixture-local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', + kind: 'local', + lastConnectedAt: '2026-08-29T12:00:00.000Z', +}; + +const normalizeBaseUrl = (value: string): string => { + try { + const normalized = normalizeApiBaseUrl(value); + if (!normalized) throw new Error('Enter an instance URL.'); + return normalized; + } catch (error) { + if (error instanceof ProprClientError) throw new Error(error.message); + throw error; + } +}; + +const readProfiles = (): DesktopProfile[] => { + try { + const value = JSON.parse(window.localStorage.getItem(PROFILES_KEY) || '[]') as unknown; + return Array.isArray(value) ? value.filter(isDesktopProfile) : []; + } catch { + return []; + } +}; + +const isDesktopProfile = (value: unknown): value is DesktopProfile => { + if (!value || typeof value !== 'object') return false; + const profile = value as Partial; + if (typeof profile.baseUrl !== 'string') return false; + let normalizedBaseUrl: string; + try { normalizedBaseUrl = normalizeBaseUrl(profile.baseUrl); } catch { return false; } + return typeof profile.id === 'string' + && profile.id.length > 0 + && profile.id.length <= 64 + && typeof profile.name === 'string' + && profile.name.length > 0 + && profile.name.length <= 80 + && typeof profile.baseUrl === 'string' + && normalizedBaseUrl === profile.baseUrl + && (profile.kind === 'local' || profile.kind === 'remote'); +}; + +const saveProfiles = (profiles: DesktopProfile[]): void => { + window.localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles)); +}; + +const detectPlatform = (): DesktopPlatform => { + const platform = navigator.platform.toLowerCase(); + if (platform.includes('mac')) return 'macos'; + if (platform.includes('win')) return 'windows'; + return 'linux'; +}; + +const fixtureFromLocation = (): DesktopFixture | null => { + const fixture = new URLSearchParams(window.location.search).get(FIXTURE_QUERY_KEY); + return fixture === 'first-run' || fixture === 'recents' || fixture === 'offline' + || fixture === 'incompatible' || fixture === 'connected' + ? fixture + : null; +}; + +const probeProfile = async (profile: DesktopProfile): Promise => { + try { + const response = await fetch(`${normalizeBaseUrl(profile.baseUrl)}/api/compatibility`, { + credentials: 'include', + cache: 'no-store', + signal: AbortSignal.timeout(8_000), + }); + if (response.status === 401 || response.status === 403) { + return { status: 'authentication-required', message: 'Sign in to continue to this instance.' }; + } + if (response.status === 404) return { status: 'ready' }; + if (!response.ok) return { status: 'offline', message: `The instance returned HTTP ${response.status}.` }; + const metadata = await response.json() as { apiCompatibility?: string; version?: string }; + const compatibility = evaluateProprApiCompatibility(metadata); + if (compatibility.compatible || compatibility.reason === 'missing') { + return { status: 'ready', version: compatibility.apiVersion ?? undefined }; + } + return { + status: 'incompatible', + message: compatibility.message, + version: compatibility.apiVersion ?? undefined, + }; + } catch { + return { status: 'offline', message: 'ProPR could not reach this instance. Check that it is running and try again.' }; + } +}; + +const authenticateBrowserFixture = (profile: DesktopProfile): Promise => new Promise((resolve, reject) => { + const complete = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.profileId !== profile.id) return; + cleanup(); + resolve(); + }; + const timeoutId = window.setTimeout(() => { + cleanup(); + reject(new Error('GitHub sign-in timed out.')); + }, AUTHENTICATION_TIMEOUT_MS); + const cleanup = () => { + window.clearTimeout(timeoutId); + window.removeEventListener(DESKTOP_AUTHENTICATION_COMPLETE_EVENT, complete); + }; + + window.addEventListener(DESKTOP_AUTHENTICATION_COMPLETE_EVENT, complete); + const redirect = new URL('propr://authentication-complete'); + redirect.searchParams.set('profile_id', profile.id); + try { + window.open( + `${normalizeBaseUrl(profile.baseUrl)}/api/auth/github?redirect_to=${encodeURIComponent(redirect.toString())}`, + '_blank', + 'noopener,noreferrer' + ); + } catch (error) { + cleanup(); + reject(error); + } +}); + +const createBrowserAdapters = (fixture: DesktopFixture | null): DesktopAdapters => ({ + platform: detectPlatform(), + app: { onDeepLink: () => () => undefined }, + profiles: { + async list() { + if (fixture === 'first-run') return []; + if (fixture) return [fixtureProfile, { ...fixtureProfile, id: 'fixture-team', name: 'Team server', baseUrl: 'https://propr.example.test', kind: 'remote' }]; + return readProfiles(); + }, + async save(profile) { + const normalized = { ...profile, baseUrl: normalizeBaseUrl(profile.baseUrl) }; + saveProfiles([...readProfiles().filter(item => item.id !== profile.id), normalized]); + }, + async remove(profileId) { + saveProfiles(readProfiles().filter(profile => profile.id !== profileId)); + if (window.localStorage.getItem(ACTIVE_PROFILE_KEY) === profileId) { + window.localStorage.removeItem(ACTIVE_PROFILE_KEY); + } + }, + async getActiveId() { + if (fixture === 'connected') return fixtureProfile.id; + return fixture ? null : window.localStorage.getItem(ACTIVE_PROFILE_KEY); + }, + async setActiveId(profileId) { + if (profileId) window.localStorage.setItem(ACTIVE_PROFILE_KEY, profileId); + else window.localStorage.removeItem(ACTIVE_PROFILE_KEY); + }, + }, + discovery: { supported: false, async discover() { return fixture ? [fixtureProfile] : []; } }, + externalBrowser: { async open(url) { window.open(url, '_blank', 'noopener,noreferrer'); } }, + authentication: { + authenticate: authenticateBrowserFixture, + }, + localSetup: { + supported: true, + async setup() { + if (fixture) return fixtureProfile; + throw new Error('Local setup will be available when the desktop host adapter is connected.'); + }, + }, + connection: { + async probe(profile) { + if (fixture === 'offline') return { status: 'offline', message: 'The instance is offline. Start it and try again.' }; + if (fixture === 'incompatible') return { status: 'incompatible', message: 'This instance requires a newer version of ProPR Desktop.', version: '0.7.0' }; + if (fixture) return { status: 'ready', version: '0.8.15' }; + return probeProfile(profile); + }, + }, +}); + +export const resolveDesktopAdapters = (): DesktopAdapters | null => { + const bridge: ProprDesktopBridge | undefined = window.__PROPR_DESKTOP__; + if (bridge?.isDesktop) return bridge; + if (window.proprDesktop) return createElectronDesktopAdapters(window.proprDesktop); + const fixture = import.meta.env.DEV ? fixtureFromLocation() : null; + return fixture ? createBrowserAdapters(fixture) : null; +}; + +export { normalizeBaseUrl }; diff --git a/propr-ui/src/desktop/desktop.css b/propr-ui/src/desktop/desktop.css new file mode 100644 index 000000000..6f039d878 --- /dev/null +++ b/propr-ui/src/desktop/desktop.css @@ -0,0 +1,260 @@ +:root { + --desktop-titlebar-height: 2.75rem; + --desktop-focus: #0f766e; +} + +.desktop-entry { + min-height: 100vh; + display: grid; + place-items: center; + overflow: auto; + padding: max(2.5rem, env(safe-area-inset-top)) 1.5rem 2.5rem; + color: #17212b; + background: + radial-gradient(circle at 10% 0%, rgba(36, 163, 163, 0.16), transparent 35rem), + radial-gradient(circle at 100% 100%, rgba(15, 118, 110, 0.10), transparent 32rem), + #f4f7f7; +} + +.desktop-app { + height: 100vh; + overflow: hidden; + background: #f8fafc; +} + +.desktop-app > .desktop-shell { + height: 100%; +} + +.desktop-brand { + display: flex; + align-items: center; + gap: .65rem; + font-size: 1.15rem; + font-weight: 750; + letter-spacing: -.02em; +} + +.desktop-brand img { + width: 2rem; + height: 2rem; + border-radius: .55rem; +} + +.desktop-welcome-card, +.desktop-connection-card { + width: min(100%, 38rem); + border: 1px solid #dce5e5; + border-radius: 1.25rem; + background: rgba(255, 255, 255, .96); + box-shadow: 0 24px 70px rgba(25, 48, 48, .12), 0 2px 8px rgba(25, 48, 48, .05); + padding: 2rem; +} + +.desktop-welcome-copy { + padding: 2.4rem 0 1.75rem; +} + +.desktop-eyebrow { + display: block; + color: #0f766e; + font-size: .7rem; + font-weight: 750; + letter-spacing: .12em; + text-transform: uppercase; +} + +.desktop-welcome-copy h1, +.desktop-connection-card h1, +.desktop-profile-form h2, +.desktop-profile-manager h2 { + margin: .35rem 0 .5rem; + color: #132525; + font-weight: 720; + letter-spacing: -.035em; +} + +.desktop-welcome-copy h1, +.desktop-connection-card h1 { font-size: 1.85rem; line-height: 1.15; } +.desktop-profile-form h2, +.desktop-profile-manager h2 { font-size: 1.35rem; } + +.desktop-welcome-copy p, +.desktop-connection-card p, +.desktop-profile-form > p { + color: #5e6d6d; + line-height: 1.55; + font-size: .925rem; +} + +.desktop-setup-actions { display: grid; gap: .7rem; } + +.desktop-choice-button { + display: grid; + grid-template-columns: 2.7rem 1fr auto; + align-items: center; + gap: .85rem; + width: 100%; + padding: .85rem; + border: 1px solid #dbe4e4; + border-radius: .8rem; + color: #243737; + text-align: left; + background: white; + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; +} + +.desktop-choice-button:hover:not(:disabled) { border-color: #83baba; box-shadow: 0 6px 20px rgba(28, 91, 91, .08); transform: translateY(-1px); } +.desktop-choice-button > span:first-child { display: grid; place-items: center; width: 2.7rem; height: 2.7rem; border-radius: .65rem; background: #eef5f4; color: #167575; } +.desktop-choice-button svg { width: 1.2rem; height: 1.2rem; } +.desktop-choice-button strong, +.desktop-choice-button small { display: block; } +.desktop-choice-button strong { font-size: .9rem; } +.desktop-choice-button small { margin-top: .18rem; color: #728080; font-size: .75rem; } +.desktop-choice-primary { border-color: #a8d2cf; background: #f7fbfa; } + +.desktop-recents { margin-top: 1.65rem; } +.desktop-recents h2 { margin-bottom: .55rem; color: #657474; font-size: .72rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +.desktop-profile-list { display: grid; gap: .4rem; } +.desktop-profile-row { display: flex; align-items: stretch; min-width: 0; border: 1px solid #e1e8e8; border-radius: .7rem; background: #fff; overflow: hidden; } +.desktop-profile-row:hover { border-color: #bad1d0; } +.desktop-profile-connect { display: grid; grid-template-columns: 2rem minmax(0, 1fr) auto; align-items: center; gap: .7rem; min-width: 0; flex: 1; padding: .65rem .7rem; text-align: left; } +.desktop-profile-connect strong, +.desktop-profile-connect small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.desktop-profile-connect strong { color: #273939; font-size: .84rem; } +.desktop-profile-connect small { margin-top: .12rem; color: #778686; font-size: .7rem; } +.desktop-profile-icon { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: .5rem; background: #f0f5f5; color: #377b78; } +.desktop-profile-icon svg, +.desktop-profile-chevron { width: 1rem; height: 1rem; } +.desktop-profile-chevron { color: #92a0a0; } + +.desktop-icon-button { display: grid; place-items: center; width: 2.4rem; min-width: 2.4rem; color: #6b7b7b; } +.desktop-icon-button:hover { color: #0f766e; background: #f2f7f7; } +.desktop-icon-button svg { width: 1rem; height: 1rem; } +.desktop-danger-button:hover { color: #b42318; background: #fff4f2; } + +.desktop-discover-button, +.desktop-back-button, +.desktop-link-button { + display: inline-flex; + align-items: center; + gap: .4rem; + color: #47706f; + font-size: .78rem; + font-weight: 600; +} + +.desktop-discover-button { margin: 1rem auto 0; width: 100%; justify-content: center; padding: .4rem; } +.desktop-discover-button:hover, +.desktop-back-button:hover, +.desktop-link-button:hover { color: #0f766e; text-decoration: underline; } +.desktop-discover-button svg, +.desktop-back-button svg { width: .9rem; height: .9rem; } + +.desktop-profile-form { padding-top: 2rem; } +.desktop-profile-form > p { margin-bottom: 1.25rem; } +.desktop-profile-form label { display: grid; gap: .4rem; margin-top: .8rem; color: #435555; font-size: .76rem; font-weight: 650; } +.desktop-profile-form input { width: 100%; border: 1px solid #cdd9d9; border-radius: .55rem; padding: .68rem .75rem; color: #192c2c; font-size: .86rem; font-weight: 450; outline: none; } +.desktop-profile-form input:focus { border-color: #16827c; box-shadow: 0 0 0 3px rgba(22, 130, 124, .15); } +.desktop-connect-verified { display: flex; align-items: center; gap: .4rem; margin-top: .65rem; color: #0f766e; font-size: .74rem; font-weight: 700; } +.desktop-connect-verified svg { width: .9rem; height: .9rem; } + +.desktop-primary-button, +.desktop-secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .45rem; + border-radius: .55rem; + padding: .65rem 1rem; + font-size: .82rem; + font-weight: 700; +} +.desktop-profile-form .desktop-primary-button { width: 100%; margin-top: 1.25rem; } +.desktop-primary-button { color: white; background: #147b76; } +.desktop-primary-button:hover { background: #0f6864; } +.desktop-secondary-button { border: 1px solid #ccdada; color: #345554; background: white; } +.desktop-secondary-button:hover { border-color: #86b3b0; background: #f7fbfb; } +.desktop-primary-button svg, +.desktop-secondary-button svg { width: .95rem; height: .95rem; } +.desktop-inline-error { margin-top: .8rem; border: 1px solid #fed0ca; border-radius: .55rem; padding: .65rem .75rem; color: #9f2d20; background: #fff6f4; font-size: .76rem; line-height: 1.45; } + +.desktop-connection-card { text-align: center; } +.desktop-connection-card .desktop-brand { justify-content: center; } +.desktop-connection-visual { display: grid; place-items: center; width: 4.25rem; height: 4.25rem; margin: 2.7rem auto 1.25rem; border-radius: 1.2rem; color: #a14336; background: #fff0ed; } +.desktop-connection-visual svg { width: 1.8rem; height: 1.8rem; } +.desktop-connecting { color: #147b76; background: #edf8f7; } +.desktop-connecting svg { animation: desktop-spin 1s linear infinite; } +.desktop-version-note { margin: 1.2rem auto; border-radius: .5rem; padding: .55rem; color: #695f46; background: #faf6e8; font-size: .75rem; } +.desktop-connection-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: .6rem; margin-top: 1.5rem; } +.desktop-connection-actions .desktop-link-button { flex-basis: 100%; justify-content: center; margin-top: .35rem; } + +.desktop-loading { display: flex; align-items: center; gap: .65rem; color: #536969; font-size: .85rem; } +.desktop-loading svg { width: 1.2rem; height: 1.2rem; } +.desktop-spin { animation: desktop-spin 1s linear infinite; } +@keyframes desktop-spin { to { transform: rotate(360deg); } } + +.desktop-titlebar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: var(--desktop-titlebar-height); + min-height: var(--desktop-titlebar-height); + border-bottom: 1px solid #dbe4e4; + color: #526565; + background: rgba(247, 250, 250, .94); + user-select: none; + z-index: 60; +} +.desktop-titlebar-drag { position: absolute; inset: 0; -webkit-app-region: drag; } +.desktop-window-title { position: relative; font-size: .72rem; font-weight: 700; pointer-events: none; } +.desktop-titlebar-actions { position: absolute; right: .7rem; display: flex; align-items: center; -webkit-app-region: no-drag; } +.desktop-platform-macos .desktop-titlebar-actions { right: .75rem; } +.desktop-platform-macos .desktop-window-title { padding-left: 4.5rem; } +.desktop-connection-pill { position: relative; display: flex; align-items: center; gap: .4rem; max-width: 15rem; border: 1px solid #d4dfdf; border-radius: 999px; padding: .27rem .55rem; color: #536666; background: rgba(255,255,255,.85); font-size: .68rem; font-weight: 650; } +.desktop-connection-pill:hover { border-color: #a5c5c3; background: #fff; } +.desktop-connection-pill > svg { width: .78rem; height: .78rem; } +.desktop-connection-pill > span:not(.desktop-connection-dot) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.desktop-connection-dot { width: .42rem; height: .42rem; border-radius: 50%; background: #24a36f; box-shadow: 0 0 0 2px rgba(36,163,111,.12); } +.desktop-connection-offline .desktop-connection-dot { background: #d47b36; } +.desktop-connection-incompatible .desktop-connection-dot { background: #c4483b; } +.desktop-pill-retry { margin-left: .1rem; } + +.desktop-modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 1.5rem; background: rgba(18, 34, 34, .35); backdrop-filter: blur(2px); z-index: 100; } +.desktop-profile-manager { width: min(100%, 32rem); max-height: min(42rem, calc(100vh - 3rem)); overflow-y: auto; border: 1px solid #d8e2e2; border-radius: 1rem; padding: 1.35rem; background: white; box-shadow: 0 30px 80px rgba(17, 34, 34, .25); } +.desktop-profile-manager > header { display: flex; align-items: flex-start; justify-content: space-between; border-bottom: 1px solid #e7eeee; padding-bottom: .85rem; margin-bottom: 1rem; } +.desktop-profile-manager .desktop-recents { margin-top: 0; } +.desktop-add-instance { width: 100%; margin-top: .8rem; } + +.desktop-app .desktop-shell-content > aside { box-shadow: none; background: #fbfdfd; } +.desktop-app .desktop-shell-content > aside nav a { border-right-width: 0; border-left: 2px solid transparent; } +.desktop-app .desktop-shell-content > aside nav a.bg-red-50 { border-left-color: #1d8a8a; background: #edf7f6; } +.desktop-app .desktop-shell-content header { box-shadow: none; } + +.desktop-entry button:focus-visible, +.desktop-entry a:focus-visible, +.desktop-entry input:focus-visible, +.desktop-app button:focus-visible, +.desktop-app a:focus-visible, +.desktop-app input:focus-visible, +.desktop-modal-backdrop button:focus-visible, +.desktop-modal-backdrop a:focus-visible, +.desktop-modal-backdrop input:focus-visible { + outline: 2px solid var(--desktop-focus); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .desktop-choice-button { transition: none; } + .desktop-choice-button:hover:not(:disabled) { transform: none; } + .desktop-spin, + .desktop-connecting svg { animation-duration: 2s; } +} + +@media (max-width: 640px) { + .desktop-entry { align-items: start; padding: 1rem; } + .desktop-welcome-card, + .desktop-connection-card { border-radius: .9rem; padding: 1.25rem; } + .desktop-welcome-copy { padding: 1.8rem 0 1.25rem; } +} diff --git a/propr-ui/src/desktop/desktopExperienceHooks.ts b/propr-ui/src/desktop/desktopExperienceHooks.ts new file mode 100644 index 000000000..721398cbc --- /dev/null +++ b/propr-ui/src/desktop/desktopExperienceHooks.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +const focusableSelector = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export const useSerializedMutationQueue = () => { + const queue = useRef>(Promise.resolve()); + return useCallback((mutation: () => Promise): Promise => { + const queued = queue.current.then(mutation, mutation); + queue.current = queued.catch(() => undefined); + return queued; + }, []); +}; + +export const useAttemptFence = (): { + begin(): () => boolean; + invalidate(): void; +} => { + const generation = useRef(0); + const invalidate = useCallback(() => { generation.current += 1; }, []); + const begin = useCallback(() => { + const attempt = ++generation.current; + return () => generation.current === attempt; + }, []); + useEffect(() => invalidate, [invalidate]); + return { begin, invalidate }; +}; + +export const useDesktopModal = ( + open: boolean, + setOpen: Dispatch>, + onClose: () => void +): { dialogRef: RefObject; openModal: () => void } => { + const dialogRef = useRef(null); + const openerRef = useRef(null); + const openModal = useCallback(() => { + openerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setOpen(true); + }, [setOpen]); + + useEffect(() => { + if (!open) return; + const dialog = dialogRef.current; + const opener = openerRef.current; + const focusableElements = () => dialog + ? [...dialog.querySelectorAll(focusableSelector)] + : []; + const handleKeyboard = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab') return; + const elements = focusableElements(); + if (!elements.length) { + event.preventDefault(); + dialog?.focus(); + return; + } + const first = elements[0]; + const last = elements[elements.length - 1]; + if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) { + event.preventDefault(); + first.focus(); + } + }; + + (focusableElements()[0] || dialog)?.focus(); + document.addEventListener('keydown', handleKeyboard); + return () => { + document.removeEventListener('keydown', handleKeyboard); + if (opener?.isConnected) opener.focus(); + }; + }, [onClose, open]); + + return { dialogRef, openModal }; +}; diff --git a/propr-ui/src/desktop/desktopExperienceMessages.ts b/propr-ui/src/desktop/desktopExperienceMessages.ts new file mode 100644 index 000000000..c8253b693 --- /dev/null +++ b/propr-ui/src/desktop/desktopExperienceMessages.ts @@ -0,0 +1,17 @@ +import type { DesktopConnectionResult } from './types'; + +export const managedRecoveryMessage = + 'This ProPR Connect endpoint may be stale or the local stack may have restarted. Restart Connect if needed, then retry, re-enter, or rediscover the connection.'; + +export const managedRediscoveryUnavailableMessage = + 'Connect rediscovery is unavailable. Retry the saved connection or re-enter its Connect address.'; + +export const safeConnectionMessage = ( + result: Exclude, + managed: boolean, +): string => { + if (managed && result.status === 'offline') return managedRecoveryMessage; + if (result.status === 'authentication-required') return 'Sign in to continue to this instance.'; + if (result.status === 'incompatible') return 'This instance is not compatible with this version of ProPR Desktop.'; + return 'ProPR Desktop could not reach this instance. Check that it is running and try again.'; +}; diff --git a/propr-ui/src/desktop/desktopExperienceState.ts b/propr-ui/src/desktop/desktopExperienceState.ts new file mode 100644 index 000000000..cc13dd001 --- /dev/null +++ b/propr-ui/src/desktop/desktopExperienceState.ts @@ -0,0 +1,27 @@ +import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; + +export type ExperienceState = + | { phase: 'loading' } + | { phase: 'choose' } + | { phase: 'connecting'; profile: DesktopProfile } + | { phase: 'blocked'; profile: DesktopProfile; result: Exclude } + | { phase: 'recovery-review'; profile: DesktopProfile; candidate: DesktopProfile } + | { phase: 'connected'; profile: DesktopProfile; result: Extract }; + +export const mergeProfiles = (current: DesktopProfile[], incoming: DesktopProfile[]): DesktopProfile[] => { + const profiles = new Map(current.map(profile => [profile.id, profile])); + incoming.forEach(profile => profiles.set(profile.id, profile)); + return [...profiles.values()].sort((a, b) => + (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '')); +}; + +export const recoverableError = (message: string): string => `${message} Try again.`; + +export const settleAuthenticationCancellation = (adapters: DesktopAdapters, profileId: string): void => { + // Back/navigation must remain synchronous. Cancellation is best effort and + // its rejection is deliberately consumed so shutdown cannot create an + // unhandled promise containing host-specific IPC details. + void Promise.resolve() + .then(() => adapters.authentication.cancel?.(profileId)) + .catch(() => undefined); +}; diff --git a/propr-ui/src/desktop/electronAdapters.test.ts b/propr-ui/src/desktop/electronAdapters.test.ts new file mode 100644 index 000000000..711cab66f --- /dev/null +++ b/propr-ui/src/desktop/electronAdapters.test.ts @@ -0,0 +1,420 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; +import type { DesktopBridge, DesktopProfile as StoredProfile } from '../../../apps/desktop/src/shared/contract'; +import { createElectronDesktopAdapters } from './electronAdapters'; + +const desktopConnectionState = vi.hoisted(() => ({ + scope: null as null | { bridge: DesktopBridge; profileId: string; transportScope: string }, +})); +const setDesktopConnectionScope = vi.hoisted(() => vi.fn((scope: typeof desktopConnectionState.scope) => { + desktopConnectionState.scope = scope; +})); +vi.mock('../api/apiClient', () => ({ + getDesktopConnectionScope: () => desktopConnectionState.scope, + setDesktopConnectionScope, +})); + +const storedProfile: StoredProfile = { + id: 'profile-1', + label: 'Team server', + apiBaseUrl: 'https://propr.example.test', + createdAt: '2026-08-29T00:00:00.000Z', + updatedAt: '2026-08-29T00:00:00.000Z', +}; + +const bridgeFixture = () => { + let profiles = [storedProfile]; + let activeProfileId: string | null = null; + const pair = vi.fn(async () => ({ paired: true as const })); + const onDeepLink = vi.fn(() => () => undefined); + const probe = vi.fn(async () => ({ + status: 'ready' as const, + version: '0.8.15', + activationTicket: 'ticket-7', + })); + const activate = vi.fn(async () => ({ + status: 'ready' as const, + profileId: storedProfile.id, + transportScope: 'scope-7', + identityEpoch: 'AAAAAAAAAAAAAAAAAAAAAA', + })); + const discard = vi.fn(async () => ({ discarded: true })); + const discover = vi.fn(async () => [{ + id: 'connect-candidate', + label: 'ProPR Connect', + apiBaseUrl: 'https://t-discovered123.propr.dev', + }]); + const rediscover = vi.fn(async (profileId: string) => ({ + id: profileId, + label: 'Team server', + apiBaseUrl: 'https://t-recovered456.propr.dev', + })); + const bridge: DesktopBridge = { + app: { + getMetadata: async () => ({ + name: 'ProPR Desktop', version: '0.8.15', platform: 'linux', arch: 'x64', packaged: true, + }), + onDeepLink, + }, + auth: { logout: async () => undefined }, + external: { open: async () => undefined }, + storage: { security: async () => ({ available: true, backend: 'keychain' }) }, + profiles: { + list: async () => ({ profiles, activeProfileId }), + save: async input => { + const saved = { ...storedProfile, id: input.id ?? 'new', label: input.label, apiBaseUrl: input.apiBaseUrl }; + profiles = [...profiles.filter(profile => profile.id !== saved.id), saved]; + return saved; + }, + remove: async profileId => { profiles = profiles.filter(profile => profile.id !== profileId); }, + setActive: async profileId => { activeProfileId = profileId; }, + }, + authentication: { pair, cancel: vi.fn(async () => undefined) }, + connection: { probe, activate, discard, invalidate: vi.fn(async () => ({ invalidated: false })) }, + discovery: { supported: true, discover, rediscover }, + lifecycle: { + status: async () => ({ state: 'disconnected' }), + start: async () => ({ ok: false, code: 'not-implemented', status: { state: 'disconnected' } }), + stop: async () => ({ ok: false, code: 'not-implemented', status: { state: 'disconnected' } }), + restart: async () => ({ ok: false, code: 'not-implemented', status: { state: 'disconnected' } }), + }, + }; + return { bridge, onDeepLink, pair, probe, activate, discard, discover, rediscover, profiles: () => profiles }; +}; + +describe('Electron remote instance adapters', () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + desktopConnectionState.scope = null; + setDesktopConnectionScope.mockClear(); + }); + it('reports local setup as unavailable in the production Electron adapter', () => { + const adapters = createElectronDesktopAdapters(bridgeFixture().bridge); + + expect(adapters.localSetup.supported).toBe(false); + expect(adapters.discovery.supported).toBe(true); + }); + + it('forwards the renderer deep-link subscription through the Electron adapter once', () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const listener = vi.fn(); + + const unsubscribe = adapters.app.onDeepLink(listener); + + expect(fixture.onDeepLink).toHaveBeenCalledOnce(); + expect(fixture.onDeepLink).toHaveBeenCalledWith(listener); + unsubscribe(); + }); + + it('projects typed main discovery and managed recovery without renderer authority inputs', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + + await expect(adapters.discovery.discover()).resolves.toEqual([{ + id: 'connect-candidate', + name: 'ProPR Connect', + baseUrl: 'https://t-discovered123.propr.dev', + kind: 'remote', + }]); + await expect(adapters.managedTunnelRecovery?.rediscover('profile-1')).resolves.toEqual({ + id: 'profile-1', + name: 'Team server', + baseUrl: 'https://t-recovered456.propr.dev', + kind: 'remote', + }); + expect(fixture.discover).toHaveBeenCalledWith(); + expect(fixture.rediscover).toHaveBeenCalledWith('profile-1'); + }); + + it('returns authentication cancellation rejection to the explicit UI settlement path', async () => { + const fixture = bridgeFixture(); + vi.mocked(fixture.bridge.authentication.cancel).mockRejectedValueOnce(new Error('private IPC detail')); + const adapters = createElectronDesktopAdapters(fixture.bridge); + + await expect(adapters.authentication.cancel?.('profile-1')).rejects.toThrow('private IPC detail'); + expect(fixture.bridge.authentication.cancel).toHaveBeenCalledWith('profile-1'); + }); + it('matches the shared canonical origin parity table before profile IPC', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + let index = 0; + for (const [name, input, expected] of PROPR_API_ORIGIN_PARITY_CASES) { + const save = adapters.profiles.save({ + id: `parity-${index++}`, + name, + baseUrl: input, + kind: expected?.startsWith('http:') ? 'local' : 'remote', + }); + if (expected === null) await expect(save, name).rejects.toThrow(); + else { + await expect(save, name).resolves.toBeUndefined(); + expect(fixture.profiles().at(-1)?.apiBaseUrl).toBe(expected); + } + } + }); + it('uses status-only main-process pairing and probe APIs', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + window.localStorage.setItem('profile-state', 'A'); + window.sessionStorage.setItem('profile-session', 'A'); + + await adapters.authentication.authenticate(profile); + const result = await adapters.connection.probe(profile); + expect(fixture.pair).toHaveBeenCalledWith({ + id: profile.id, + label: profile.name, + apiBaseUrl: profile.baseUrl, + }); + expect(result).toEqual({ status: 'ready', version: '0.8.15', activationTicket: 'ticket-7' }); + expect('credentials' in fixture.bridge).toBe(false); + + if (result.status !== 'ready') return; + const activated = await adapters.connection.activate?.(profile, result); + expect(fixture.activate).toHaveBeenCalledWith('ticket-7'); + expect(activated).toEqual({ + status: 'ready', + version: '0.8.15', + authentication: undefined, + profileId: profile.id, + transportScope: 'scope-7', + identityEpoch: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + if (activated?.status === 'ready') adapters.connection.publishActivation?.(profile, activated); + expect(setDesktopConnectionScope).toHaveBeenCalledWith({ + bridge: fixture.bridge, + profileId: storedProfile.id, + transportScope: 'scope-7', + }, profile.baseUrl); + }); + + it('rejects a main-bound profile mismatch without publishing the returned scope', async () => { + const fixture = bridgeFixture(); + fixture.activate.mockResolvedValueOnce({ + status: 'ready', + profileId: 'profile-2', + transportScope: 'wrong-profile-scope', + identityEpoch: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + window.localStorage.setItem('profile-state', 'A'); + window.sessionStorage.setItem('profile-session', 'A'); + const probe = await adapters.connection.probe(profile); + if (probe.status !== 'ready') return; + + const activated = await adapters.connection.activate?.(profile, probe); + + expect(activated).toEqual(expect.objectContaining({ + status: 'authentication-required', + message: expect.stringMatching(/connection changed/i), + })); + expect(setDesktopConnectionScope).not.toHaveBeenCalled(); + expect(window.localStorage.getItem('profile-state')).toBe('A'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A'); + expect(fixture.discard).toHaveBeenCalledWith({ + profileId: 'profile-2', transportScope: 'wrong-profile-scope', + }); + }); + + it('clears renderer storage after a successful same-origin profile switch', async () => { + const fixture = bridgeFixture(); + await fixture.bridge.profiles.save({ + id: 'profile-a', label: 'Profile A', apiBaseUrl: storedProfile.apiBaseUrl, + }); + await fixture.bridge.profiles.setActive('profile-a'); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + window.localStorage.setItem('profile-state', 'profile-a-local-sentinel'); + window.sessionStorage.setItem('profile-session', 'profile-a-session-sentinel'); + const clear = vi.spyOn(Storage.prototype, 'clear'); + + const activated = await adapters.connection.activate?.(profile, { + status: 'ready', + version: '0.8.15', + activationTicket: 'ticket-7', + }); + + expect(activated?.status).toBe('ready'); + expect(window.localStorage.getItem('profile-state')).toBeNull(); + expect(window.sessionStorage.getItem('profile-session')).toBeNull(); + expect(clear).toHaveBeenCalledTimes(2); + if (activated?.status === 'ready') adapters.connection.publishActivation?.(profile, activated); + expect(clear.mock.invocationCallOrder.at(-1)).toBeLessThan(setDesktopConnectionScope.mock.invocationCallOrder[0]); + clear.mockRestore(); + }); + + it('retains state for the same credential and clears exactly once for a same-profile identity change', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + const activateAndPublish = async () => { + const activated = await adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-7', + }); + if (activated?.status === 'ready') adapters.connection.publishActivation?.(profile, activated); + return activated; + }; + + await activateAndPublish(); + await fixture.bridge.profiles.setActive(profile.id); + window.localStorage.setItem('profile-state', 'credential-a-local'); + window.sessionStorage.setItem('profile-session', 'credential-a-session'); + const clear = vi.spyOn(Storage.prototype, 'clear'); + + const reconnect = await activateAndPublish(); + expect(reconnect).toEqual(expect.objectContaining({ + status: 'ready', identityEpoch: 'AAAAAAAAAAAAAAAAAAAAAA', + })); + expect(window.localStorage.getItem('profile-state')).toBe('credential-a-local'); + expect(window.sessionStorage.getItem('profile-session')).toBe('credential-a-session'); + expect(clear).not.toHaveBeenCalled(); + + fixture.activate.mockResolvedValueOnce({ + status: 'ready', + profileId: profile.id, + transportScope: 'scope-b', + identityEpoch: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + const replacement = await activateAndPublish(); + expect(replacement).toEqual(expect.objectContaining({ + status: 'ready', identityEpoch: 'BBBBBBBBBBBBBBBBBBBBBB', + })); + expect(window.localStorage.getItem('profile-state')).toBeNull(); + expect(window.sessionStorage.getItem('profile-session')).toBeNull(); + expect(clear).toHaveBeenCalledTimes(2); + expect(clear.mock.invocationCallOrder.at(-1)).toBeLessThan(setDesktopConnectionScope.mock.invocationCallOrder.at(-1)!); + clear.mockRestore(); + }); + + it('cancels pairing and removes profiles entirely through main-process IPC', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + + await adapters.profiles.remove('profile-1'); + + expect(fixture.bridge.authentication.cancel).toHaveBeenCalledWith('profile-1'); + expect(fixture.profiles()).toEqual([]); + }); + + it('leaves renderer state untouched while probing an edited profile origin', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + window.localStorage.setItem('profile-state', 'A'); + window.sessionStorage.setItem('profile-session', 'A'); + + await adapters.connection.probe({ + ...fromProfile(storedProfile), + baseUrl: 'https://attacker.example.test', + }); + + expect(window.localStorage.getItem('profile-state')).toBe('A'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A'); + expect(setDesktopConnectionScope).not.toHaveBeenCalled(); + }); + + it('leaves renderer state untouched when an origin edit save or pairing fails', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const edited = { ...fromProfile(storedProfile), baseUrl: 'https://edited.example.test' }; + window.localStorage.setItem('profile-state', 'A-local'); + window.sessionStorage.setItem('profile-session', 'A-session'); + vi.spyOn(fixture.bridge.profiles, 'save').mockRejectedValueOnce(new Error('save failed')); + await expect(adapters.profiles.save(edited)).rejects.toThrow('save failed'); + fixture.pair.mockRejectedValueOnce(new Error('pairing cancelled')); + await expect(adapters.authentication.authenticate(edited)).rejects.toThrow('pairing cancelled'); + expect(window.localStorage.getItem('profile-state')).toBe('A-local'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A-session'); + }); + + it('does not clear on thrown or stale activation and discards only a stale main result', async () => { + const fixture = bridgeFixture(); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + window.localStorage.setItem('profile-state', 'A'); + window.sessionStorage.setItem('profile-session', 'A'); + fixture.activate.mockRejectedValueOnce(new Error('activation failed')); + + await expect(adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-throw', + })).rejects.toThrow('activation failed'); + expect(window.localStorage.getItem('profile-state')).toBe('A'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A'); + + fixture.activate.mockResolvedValueOnce({ + status: 'ready', profileId: profile.id, transportScope: 'stale-scope', + identityEpoch: 'AAAAAAAAAAAAAAAAAAAAAA', + }); + const stale = await adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-stale', + }, () => false); + expect(stale?.status).toBe('authentication-required'); + expect(window.localStorage.getItem('profile-state')).toBe('A'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A'); + expect(fixture.discard).toHaveBeenCalledWith({ profileId: profile.id, transportScope: 'stale-scope' }); + }); + + it('does not clear a newer scope while a stale activation discard is pending', async () => { + const fixture = bridgeFixture(); + let finishDiscard!: (value: { discarded: boolean }) => void; + fixture.discard.mockReturnValueOnce(new Promise(resolve => { finishDiscard = resolve; })); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + const staleActivation = adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-stale', + }, () => false); + await vi.waitFor(() => expect(fixture.discard).toHaveBeenCalledWith({ + profileId: profile.id, transportScope: 'scope-7', + })); + + adapters.connection.publishActivation?.(profile, { + status: 'ready', + profileId: profile.id, + transportScope: 'newer-scope', + identityEpoch: 'BBBBBBBBBBBBBBBBBBBBBB', + }); + finishDiscard({ discarded: true }); + await staleActivation; + + expect(desktopConnectionState.scope).toEqual(expect.objectContaining({ + profileId: profile.id, + transportScope: 'newer-scope', + })); + expect(setDesktopConnectionScope).not.toHaveBeenCalledWith(null); + }); + + it('publishes no B scope and restores sentinels when storage clearing fails', async () => { + const fixture = bridgeFixture(); + await fixture.bridge.profiles.setActive('profile-a'); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + window.localStorage.setItem('profile-state', 'A-local'); + window.sessionStorage.setItem('profile-session', 'A-session'); + const clear = vi.spyOn(Storage.prototype, 'clear').mockImplementationOnce(() => { + throw new Error('storage disabled'); + }); + + const activated = await adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-7', + }); + + expect(activated).toEqual({ + status: 'offline', + message: 'Desktop storage isolation failed. Restart ProPR Desktop before connecting again.', + }); + expect(window.localStorage.getItem('profile-state')).toBe('A-local'); + expect(window.sessionStorage.getItem('profile-session')).toBe('A-session'); + expect(fixture.discard).toHaveBeenCalledWith({ profileId: profile.id, transportScope: 'scope-7' }); + expect(setDesktopConnectionScope).not.toHaveBeenCalled(); + clear.mockRestore(); + }); +}); + +const fromProfile = (profile: StoredProfile) => ({ + id: profile.id, + name: profile.label, + baseUrl: profile.apiBaseUrl, + kind: 'remote' as const, +}); diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts new file mode 100644 index 000000000..f98cc53ea --- /dev/null +++ b/propr-ui/src/desktop/electronAdapters.ts @@ -0,0 +1,229 @@ +import { normalizeApiBaseUrl } from '@propr/client'; +import { isProprLoopbackHostname, parseProprConnectEndpoint } from '@propr/shared'; +import type { DesktopBridge, DesktopDiscoveryCandidate, DesktopProfile as StoredDesktopProfile } from '../../../apps/desktop/src/shared/contract'; +import { getDesktopConnectionScope, setDesktopConnectionScope } from '../api/apiClient'; +import type { DesktopAdapters, DesktopPlatform, DesktopProfile } from './types'; + +const platform = (value: string): DesktopPlatform => { + const normalized = value.toLowerCase(); + if (normalized.includes('mac')) return 'macos'; + if (normalized.includes('win')) return 'windows'; + return 'linux'; +}; + +const isLocal = (baseUrl: string): boolean => { + return isProprLoopbackHostname(new URL(baseUrl).hostname); +}; + +const fromStoredProfile = (profile: StoredDesktopProfile): DesktopProfile => ({ + id: profile.id, + name: profile.label, + baseUrl: profile.apiBaseUrl, + kind: isLocal(profile.apiBaseUrl) ? 'local' : 'remote', + lastConnectedAt: profile.updatedAt, +}); + +const toStoredProfile = (profile: DesktopProfile) => ({ + id: profile.id, + label: profile.name, + apiBaseUrl: normalizeApiBaseUrl(profile.baseUrl), +}); + +const fromDiscoveryCandidate = (candidate: DesktopDiscoveryCandidate): DesktopProfile | null => { + const endpoint = parseProprConnectEndpoint(candidate.apiBaseUrl); + if ( + !endpoint + || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(candidate.id) + || candidate.label.length === 0 + || candidate.label.length > 80 + ) return null; + return { + id: candidate.id, + name: candidate.label, + baseUrl: endpoint.origin, + kind: 'remote', + }; +}; + +const snapshotStorage = (storage: Storage): [string, string][] => { + const snapshot: [string, string][] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) snapshot.push([key, storage.getItem(key) ?? '']); + } + return snapshot; +}; + +const restoreStorage = (storage: Storage, snapshot: [string, string][]): void => { + const expected = new Set(snapshot.map(([key]) => key)); + for (let index = storage.length - 1; index >= 0; index -= 1) { + const key = storage.key(index); + if (key !== null && !expected.has(key)) storage.removeItem(key); + } + snapshot.forEach(([key, value]) => storage.setItem(key, value)); +}; + +const clearRendererProfileState = (): boolean => { + let localSnapshot: [string, string][] = []; + let sessionSnapshot: [string, string][] = []; + try { + localSnapshot = snapshotStorage(window.localStorage); + sessionSnapshot = snapshotStorage(window.sessionStorage); + window.localStorage.clear(); + if (window.localStorage.length !== 0) throw new Error('Local storage was not cleared'); + window.sessionStorage.clear(); + if (window.sessionStorage.length !== 0) throw new Error('Session storage was not cleared'); + return true; + } catch { + try { restoreStorage(window.localStorage, localSnapshot); } catch { /* fail closed below */ } + try { restoreStorage(window.sessionStorage, sessionSnapshot); } catch { /* fail closed below */ } + return false; + } +}; + +export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAdapters => { + let publishedProfile: { id: string; origin: string; identityEpoch: string } | null = null; + return { + platform: platform(navigator.platform || navigator.userAgent), + app: { onDeepLink: listener => bridge.app.onDeepLink(listener) }, + profiles: { + async list() { + return (await bridge.profiles.list()).profiles.map(fromStoredProfile); + }, + async save(profile) { + await bridge.profiles.save(toStoredProfile(profile)); + }, + async remove(profileId) { + await bridge.authentication.cancel(profileId); + await bridge.profiles.remove(profileId); + }, + async getActiveId() { + return (await bridge.profiles.list()).activeProfileId; + }, + async setActiveId(profileId) { + await bridge.profiles.setActive(profileId); + if (profileId === null) { + setDesktopConnectionScope(null); + } + }, + }, + discovery: { + supported: bridge.discovery.supported, + async discover() { + return (await bridge.discovery.discover()) + .map(fromDiscoveryCandidate) + .filter((profile): profile is DesktopProfile => profile !== null); + }, + }, + managedTunnelRecovery: { + async rediscover(profileId) { + const candidate = await bridge.discovery.rediscover(profileId); + if (!candidate || candidate.id !== profileId) return null; + return fromDiscoveryCandidate(candidate); + }, + }, + ...(bridge.acceptance ? { + acceptance: { + reportJourneyStage: stage => bridge.acceptance!.reportJourneyStage(stage), + }, + } : {}), + authentication: { + async authenticate(profile) { + const security = await bridge.storage.security(); + if (!security.available) throw new Error('OS-backed secure storage is required for desktop pairing.'); + await bridge.authentication.pair(toStoredProfile(profile)); + }, + cancel(profileId) { + return bridge.authentication.cancel(profileId); + }, + }, + externalBrowser: { open: url => bridge.external.open(url) }, + localSetup: { + supported: false, + async setup() { + throw new Error('Local setup is not available in this desktop build. Connect to a running local instance instead.'); + }, + }, + connection: { + async probe(profile) { + return bridge.connection.probe(toStoredProfile(profile)); + }, + async activate(profile, result, isCurrent = () => true) { + if (result.activationTicket === undefined) throw new Error('Desktop activation ticket is missing.'); + const previousProfileId = (await bridge.profiles.list()).activeProfileId; + const activated = await bridge.connection.activate(result.activationTicket); + const discard = async () => { + await bridge.connection.discard({ + profileId: activated.profileId, + transportScope: activated.transportScope, + }).catch(() => undefined); + const currentScope = getDesktopConnectionScope(); + if (currentScope?.profileId === activated.profileId + && currentScope.transportScope === activated.transportScope) { + setDesktopConnectionScope(null); + } + }; + if (activated.profileId !== profile.id || !isCurrent()) { + await discard(); + return { + status: 'authentication-required', + message: 'This connection changed while it was being activated. Check it again to continue.', + version: result.version, + authentication: result.authentication, + }; + } + const intendedOrigin = normalizeApiBaseUrl(profile.baseUrl); + if (!/^[A-Za-z0-9_-]{22}$/.test(activated.identityEpoch)) { + await discard(); + return { + status: 'authentication-required', + message: 'This connection changed while it was being activated. Check it again to continue.', + version: result.version, + authentication: result.authentication, + }; + } + const isReplacement = publishedProfile === null + || previousProfileId !== profile.id + || publishedProfile.id !== profile.id + || publishedProfile.origin !== intendedOrigin + || publishedProfile.identityEpoch !== activated.identityEpoch; + if (isReplacement && !clearRendererProfileState()) { + await discard(); + return { + status: 'offline', + message: 'Desktop storage isolation failed. Restart ProPR Desktop before connecting again.', + }; + } + return { + status: 'ready', + version: result.version, + authentication: result.authentication, + profileId: activated.profileId, + transportScope: activated.transportScope, + identityEpoch: activated.identityEpoch, + }; + }, + publishActivation(profile, result) { + if (result.transportScope === undefined) throw new Error('Desktop transport scope is missing.'); + if (result.identityEpoch === undefined) throw new Error('Desktop credential identity is missing.'); + if (result.profileId === undefined || result.profileId !== profile.id) { + setDesktopConnectionScope(null); + throw new Error('Desktop activation profile changed before publication.'); + } + setDesktopConnectionScope({ + bridge, + profileId: result.profileId, + transportScope: result.transportScope, + }, profile.baseUrl); + publishedProfile = { + id: profile.id, + origin: normalizeApiBaseUrl(profile.baseUrl), + identityEpoch: result.identityEpoch, + }; + }, + deactivate() { + setDesktopConnectionScope(null); + }, + }, + }; +}; diff --git a/propr-ui/src/desktop/packagedTransportSmoke.ts b/propr-ui/src/desktop/packagedTransportSmoke.ts new file mode 100644 index 000000000..dda4d28fd --- /dev/null +++ b/propr-ui/src/desktop/packagedTransportSmoke.ts @@ -0,0 +1,186 @@ +import type { Socket } from '@propr/client'; +import { DESKTOP_TRANSPORT_SCOPE_QUERY } from '@propr/shared'; +import type { DesktopBridge } from '../../../apps/desktop/src/shared/contract'; +import { + apiFetch, + getDesktopConnectionScope, + handleDesktopAccessCode, + proprClient, +} from '../api/apiClient'; +import { createElectronDesktopAdapters } from './electronAdapters'; +import type { DesktopProfile } from './types'; + +interface SocketRecord { + socket: Socket; + profileId: string; + transportScope: string; +} + +interface SocketConnectionError extends Error { + data?: { code?: unknown }; +} + +const INVALID_INSTANCE_TOKEN = 'INVALID_INSTANCE_TOKEN'; + +interface PackagedTransportSmokeHarness { + activate(profile: DesktopProfile): Promise<{ + profileId: string; + transportScope: string; + identityEpoch: string; + contractsContainSecret: boolean; + }>; + rest(): Promise; + connectSocket(): Promise; + reconnectSocket(id: number): Promise; + expectSocketRejected(id: number): Promise; + disconnectSocket(id: number): void; + handleStaleInvalidation(profileId: string, transportScope: string): Promise; + rendererEvidence(): unknown; +} + +declare global { + interface Window { + __proprPackagedTransportSmoke?: PackagedTransportSmokeHarness; + } +} + +const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): Promise => + new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + cleanup(); + reject(new Error(`Packaged Socket.IO ${expected} timed out`)); + }, 5_000); + const connected = () => { + cleanup(); + if (expected === 'connect') { + resolve(); + return; + } + reject(new Error('Stale Socket.IO scope unexpectedly connected')); + }; + const failed = (error: SocketConnectionError) => { + cleanup(); + if (expected !== 'connect_error') { + reject(new Error(`Packaged Socket.IO connection failed: ${error.message}`)); + return; + } + if (error.message !== INVALID_INSTANCE_TOKEN || error.data?.code !== INVALID_INSTANCE_TOKEN) { + reject(new Error('Packaged stale Socket.IO rejection was not INVALID_INSTANCE_TOKEN')); + return; + } + resolve(); + }; + const cleanup = () => { + window.clearTimeout(timer); + socket.off('connect', connected); + socket.off('connect_error', failed); + }; + socket.once('connect', connected); + socket.once('connect_error', failed); + }); + +/** + * Packaged-only E2E driver. It deliberately composes the same adapter, + * apiFetch, ProprClient Socket.IO transport, scope rotation, and invalidation + * handling as the desktop application; it never receives a credential. + */ +export const installPackagedTransportSmokeHarness = (): void => { + const bridge = window.proprDesktop as DesktopBridge | undefined; + if (!bridge) throw new Error('Packaged preload bridge is unavailable'); + const adapters = createElectronDesktopAdapters(bridge); + const sockets = new Map(); + let nextSocketId = 1; + + const harness: PackagedTransportSmokeHarness = { + async activate(profile) { + const probed = await adapters.connection.probe(profile); + if (probed.status !== 'ready' || !adapters.connection.activate || !adapters.connection.publishActivation) { + throw new Error('Packaged desktop profile was not ready'); + } + const activated = await adapters.connection.activate(profile, probed); + if (activated.status !== 'ready' || !activated.profileId || !activated.transportScope || !activated.identityEpoch) { + throw new Error('Packaged desktop activation failed'); + } + adapters.connection.publishActivation(profile, activated); + return { + profileId: activated.profileId, + transportScope: activated.transportScope, + identityEpoch: activated.identityEpoch, + contractsContainSecret: JSON.stringify([probed, activated]).includes('propr_it_'), + }; + }, + async rest() { + const response = await apiFetch('/api/smoke/rest', { credentials: 'include' }); + if (!response.ok || (await response.json() as { ok?: boolean }).ok !== true) { + throw new Error('Packaged REST fixture failed'); + } + }, + async connectSocket() { + const scope = getDesktopConnectionScope(); + if (!scope) throw new Error('Packaged Socket.IO scope is unavailable'); + const socket = proprClient.connectSocket({ + transports: ['websocket'], + forceNew: true, + reconnection: true, + auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope }, + query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope }, + }); + const id = nextSocketId++; + sockets.set(id, { socket, profileId: scope.profileId, transportScope: scope.transportScope }); + await waitForSocket(socket, 'connect'); + return id; + }, + async reconnectSocket(id) { + const record = sockets.get(id); + if (!record) throw new Error('Packaged Socket.IO connection is unavailable'); + record.socket.disconnect(); + const connected = waitForSocket(record.socket, 'connect'); + record.socket.connect(); + await connected; + }, + async expectSocketRejected(id) { + const record = sockets.get(id); + if (!record) throw new Error('Packaged Socket.IO connection is unavailable'); + const currentScope = getDesktopConnectionScope(); + if (!currentScope || currentScope.profileId !== record.profileId + || currentScope.transportScope === record.transportScope) { + throw new Error('Packaged stale Socket.IO activation was not rotated'); + } + record.socket.disconnect(); + record.socket.io.opts.query = { + [DESKTOP_TRANSPORT_SCOPE_QUERY]: currentScope.transportScope, + }; + const rejected = waitForSocket(record.socket, 'connect_error'); + try { + record.socket.connect(); + await rejected; + } finally { + record.socket.disconnect(); + } + }, + disconnectSocket(id) { + sockets.get(id)?.socket.disconnect(); + }, + handleStaleInvalidation(profileId, transportScope) { + return handleDesktopAccessCode('INVALID_INSTANCE_TOKEN', { bridge, profileId, transportScope }); + }, + rendererEvidence() { + return { + origin: location.origin, + href: location.href, + localStorage: Object.entries(localStorage), + sessionStorage: Object.entries(sessionStorage), + scope: getDesktopConnectionScope() && { + profileId: getDesktopConnectionScope()!.profileId, + transportScope: getDesktopConnectionScope()!.transportScope, + }, + }; + }, + }; + Object.defineProperty(window, '__proprPackagedTransportSmoke', { + configurable: false, + enumerable: false, + value: Object.freeze(harness), + writable: false, + }); +}; diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts new file mode 100644 index 000000000..c6b3f3cbe --- /dev/null +++ b/propr-ui/src/desktop/types.ts @@ -0,0 +1,121 @@ +export type DesktopPlatform = 'macos' | 'windows' | 'linux'; + +export interface DesktopProfile { + id: string; + name: string; + baseUrl: string; + kind: 'local' | 'remote'; + lastConnectedAt?: string; +} + +export type DesktopConnectionResult = + | { status: 'ready'; version?: string; authentication?: string; activationTicket?: string; transportScope?: string; profileId?: string; identityEpoch?: string } + | { status: 'authentication-required'; message?: string; version?: string; authentication?: string } + | { status: 'incompatible'; message: string; version?: string } + | { status: 'offline'; message: string }; + +export interface DesktopProfileAdapter { + list(): Promise; + save(profile: DesktopProfile): Promise; + remove(profileId: string): Promise; + getActiveId(): Promise; + setActiveId(profileId: string | null): Promise; +} + +export interface DesktopDiscoveryAdapter { + /** Whether this host has a real network-wide discovery provider. */ + supported: boolean; + discover(): Promise; +} + +export interface DesktopAuthenticationAdapter { + /** + * Resolves only after the desktop host has completed authentication and + * installed credentials that are ready for requests to this profile. + * Opening the system browser alone is not successful authentication. + */ + authenticate(profile: DesktopProfile): Promise; + cancel?(profileId: string): Promise; +} + +export const DESKTOP_AUTHENTICATION_COMPLETE_EVENT = 'propr:desktop-authentication-complete'; +export const DESKTOP_ACCESS_INVALID_EVENT = 'propr:desktop-access-invalid'; + +export interface DesktopAuthenticationCompleteEventDetail { + profileId: string; +} + +export interface DesktopAccessInvalidEventDetail { + profileId: string; + transportScope: string; + code: string; +} + +export interface DesktopExternalBrowserAdapter { + open(url: string): Promise; +} + +export interface DesktopLocalSetupAdapter { + supported: boolean; + setup(): Promise; +} + +export interface DesktopConnectionAdapter { + probe(profile: DesktopProfile): Promise; + activate?( + profile: DesktopProfile, + result: Extract, + isCurrent?: () => boolean, + ): Promise; + publishActivation?(profile: DesktopProfile, result: Extract): void; + deactivate?(): void; +} + +export type DesktopAcceptanceJourneyStage = + | 'AUTHENTICATION_REQUIRED' + | 'CREDENTIAL_COMMITTED' + | 'AUTHENTICATED_REPROBE_READY' + | 'ACTIVATION_COMMITTED' + | 'ACTIVATION_PUBLISHED' + | 'REACT_CONNECTED'; + +export interface DesktopManagedTunnelRecoveryAdapter { + /** + * Request a secret-free Connect endpoint refresh for an existing profile. + * The renderer supplies only the opaque profile id and must explicitly + * confirm a returned candidate before it can replace the saved endpoint. + */ + rediscover(profileId: string): Promise; +} + +export interface DesktopAdapters { + platform: DesktopPlatform; + app: { + onDeepLink(listener: (url: string) => void): () => void; + }; + profiles: DesktopProfileAdapter; + discovery: DesktopDiscoveryAdapter; + authentication: DesktopAuthenticationAdapter; + externalBrowser: DesktopExternalBrowserAdapter; + localSetup: DesktopLocalSetupAdapter; + connection: DesktopConnectionAdapter; + managedTunnelRecovery?: DesktopManagedTunnelRecoveryAdapter; + /** @internal Authorized packaged-journey evidence; absent in production use. */ + acceptance?: { + reportJourneyStage(stage: DesktopAcceptanceJourneyStage): Promise; + }; +} + +/** + * Small preload-facing contract. Electron can expose this object through + * contextBridge without exposing Node or command execution to React. + */ +export interface ProprDesktopBridge extends DesktopAdapters { + isDesktop: true; +} + +declare global { + interface Window { + __PROPR_DESKTOP__?: ProprDesktopBridge; + } +} diff --git a/propr-ui/src/desktop/useDesktopDeepLinks.ts b/propr-ui/src/desktop/useDesktopDeepLinks.ts new file mode 100644 index 000000000..0e8a7a745 --- /dev/null +++ b/propr-ui/src/desktop/useDesktopDeepLinks.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import { isProprLoopbackHostname } from '@propr/shared'; +import { connectApiBaseUrlFromDeepLink } from '../../../apps/desktop/src/security'; +import { DesktopDeepLinkNavigation, type DesktopDeepLinkInbox } from '../desktop-deep-link'; +import type { DesktopProfile } from './types'; + +const REJECTED_DEEP_LINK_MESSAGE = 'ProPR Desktop could not use that link. Choose an instance and try again.'; +const CONNECT_CANDIDATE_NOTICE = 'Review this untrusted instance address, then choose Connect to continue.'; + +type DesktopDeepLinkPhase = 'loading' | 'choose' | 'connecting' | 'blocked' | 'recovery-review' | 'connected'; + +interface UseDesktopDeepLinksOptions { + deepLinks?: DesktopDeepLinkInbox; + phase: DesktopDeepLinkPhase; + profileId: string | null; + activeProfileId: RefObject; + onStageConnectCandidate(candidate: DesktopProfile, phase: DesktopDeepLinkPhase): void; +} + +interface DesktopDeepLinkState { + deepLinkError: string | null; + editorNotice: string | null; + clearConnectCandidate(): void; + hasPendingConnectCandidate(): boolean; +} + +const createProfileId = (): string => { + try { return crypto.randomUUID(); } catch { return `profile-${Date.now()}`; } +}; + +/** Owns the one-consumer renderer handoff and stages Connect links without performing connection work. */ +export const useDesktopDeepLinks = ({ + deepLinks, + phase, + profileId, + activeProfileId, + onStageConnectCandidate, +}: UseDesktopDeepLinksOptions): DesktopDeepLinkState => { + const [deepLinkError, setDeepLinkError] = useState(null); + const [editorNotice, setEditorNotice] = useState(null); + const pendingConnectCandidate = useRef(false); + const startupOpenLinks = useRef([]); + const phaseRef = useRef(phase); + const profileIdRef = useRef(profileId); + const stageCandidateRef = useRef(onStageConnectCandidate); + phaseRef.current = phase; + profileIdRef.current = profileId; + stageCandidateRef.current = onStageConnectCandidate; + + const [navigation] = useState(() => new DesktopDeepLinkNavigation( + path => { + window.location.hash = path; + setDeepLinkError(null); + }, + () => setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE), + )); + const handler = useRef<(value: string) => void>(() => undefined); + + handler.current = value => { + let action: string | null = null; + try { + const url = new URL(value); + if (url.protocol === 'propr:') action = url.hostname; + } catch { + // The fixed rejection below deliberately omits attacker-controlled input. + } + + if (action === 'connect') { + const baseUrl = connectApiBaseUrlFromDeepLink(value); + if (!baseUrl) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return; + } + const candidate: DesktopProfile = { + id: createProfileId(), + name: 'Discovered ProPR instance', + baseUrl, + kind: isProprLoopbackHostname(new URL(baseUrl).hostname) ? 'local' : 'remote', + }; + pendingConnectCandidate.current = true; + setDeepLinkError(null); + setEditorNotice(CONNECT_CANDIDATE_NOTICE); + stageCandidateRef.current(candidate, phaseRef.current); + return; + } + + if (action === 'open') { + const currentPhase = phaseRef.current; + const currentProfileId = profileIdRef.current; + if (currentPhase === 'loading') { + startupOpenLinks.current.push(value); + return; + } + if ((currentPhase === 'connecting' || currentPhase === 'connected') && currentProfileId) { + if (activeProfileId.current !== currentProfileId + || !navigation.receive(value, currentProfileId)) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + } + return; + } + } + + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + }; + + useEffect(() => deepLinks?.subscribe(value => handler.current(value)), [deepLinks]); + + useEffect(() => { + if (phase === 'connecting' && profileId) { + navigation.setDashboardUnavailable(); + if (activeProfileId.current === profileId) { + startupOpenLinks.current.splice(0).forEach(value => navigation.receive(value, profileId)); + } else if (startupOpenLinks.current.splice(0).length > 0) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + } + return; + } + if (phase === 'connected' && profileId) { + if (activeProfileId.current === profileId) navigation.setDashboardReady(profileId); + else setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return; + } + navigation.setDashboardUnavailable(); + if (phase !== 'loading') { + if (startupOpenLinks.current.splice(0).length > 0) setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + navigation.rejectPending(); + } + }, [activeProfileId, navigation, phase, profileId]); + + const clearConnectCandidate = useCallback(() => { + pendingConnectCandidate.current = false; + setEditorNotice(null); + }, []); + const hasPendingConnectCandidate = useCallback(() => pendingConnectCandidate.current, []); + + return { deepLinkError, editorNotice, clearConnectCandidate, hasPendingConnectCandidate }; +}; diff --git a/propr-ui/src/pages/DesktopPairingPage.test.tsx b/propr-ui/src/pages/DesktopPairingPage.test.tsx new file mode 100644 index 000000000..32c050287 --- /dev/null +++ b/propr-ui/src/pages/DesktopPairingPage.test.tsx @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import DesktopPairingPage from './DesktopPairingPage'; +import { approveDesktopPairing, getDesktopPairingApproval } from '../api/desktopAuth'; + +vi.mock('../api/desktopAuth', () => ({ + approveDesktopPairing: vi.fn(), + getDesktopPairingApproval: vi.fn(), +})); + +const pairingId = `dpr_${'A'.repeat(22)}`; +const pending = { + pairingId, + clientName: 'Alice’s MacBook', + status: 'pending' as const, + createdAt: '2026-08-29T14:00:00.000Z', + expiresAt: '2026-08-29T14:10:00.000Z', +}; + +describe('DesktopPairingPage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows the server-provided client name and requires an explicit approval click', async () => { + vi.mocked(getDesktopPairingApproval).mockResolvedValue(pending); + vi.mocked(approveDesktopPairing).mockResolvedValue({ ...pending, status: 'approved' }); + render( + + + , + ); + + expect(await screen.findByText('Alice’s MacBook')).toBeInTheDocument(); + expect(approveDesktopPairing).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Approve desktop' })); + + await waitFor(() => expect(approveDesktopPairing).toHaveBeenCalledWith(pairingId)); + expect(await screen.findByText('Desktop paired')).toBeInTheDocument(); + }); + + it('rejects malformed URL identifiers without making an API request', () => { + render( + + + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent(/invalid/i); + expect(getDesktopPairingApproval).not.toHaveBeenCalled(); + }); +}); diff --git a/propr-ui/src/pages/DesktopPairingPage.tsx b/propr-ui/src/pages/DesktopPairingPage.tsx new file mode 100644 index 000000000..71304ca01 --- /dev/null +++ b/propr-ui/src/pages/DesktopPairingPage.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + approveDesktopPairing, + getDesktopPairingApproval, + type DesktopPairingApproval, +} from '../api/desktopAuth'; + +const PAIRING_ID_PATTERN = /^dpr_[A-Za-z0-9_-]{22}$/; + +const DesktopPairingPage = () => { + const [searchParams] = useSearchParams(); + const pairingId = useMemo(() => searchParams.get('pairing_id') ?? '', [searchParams]); + const [pairing, setPairing] = useState(null); + const [error, setError] = useState(''); + const [approving, setApproving] = useState(false); + + useEffect(() => { + if (!PAIRING_ID_PATTERN.test(pairingId)) { + setError('This desktop pairing link is invalid. Start pairing again from the desktop app.'); + return; + } + let cancelled = false; + getDesktopPairingApproval(pairingId) + .then(result => { if (!cancelled) setPairing(result); }) + .catch(() => { + if (!cancelled) setError('This pairing request was not found or has expired. Start pairing again from the desktop app.'); + }); + return () => { cancelled = true; }; + }, [pairingId]); + + const approve = async () => { + if (!pairing || pairing.status !== 'pending') return; + setApproving(true); + setError(''); + try { + setPairing(await approveDesktopPairing(pairing.pairingId)); + } catch { + setError('The pairing request could not be approved. It may have expired; start pairing again from the desktop app.'); + } finally { + setApproving(false); + } + }; + + const completed = pairing?.status === 'approved' || pairing?.status === 'consumed'; + + return ( +
+
+ ProPR +

+ {completed ? 'Desktop paired' : 'Approve desktop access'} +

+ {pairing && !completed && ( + <> +

+ Allow {pairing.clientName} to access this ProPR instance as you. + It receives your current instance role and permissions, but never your GitHub access token. +

+
+ + +
+ + )} + {completed && ( +

+ Return to the ProPR desktop app. You can revoke this device later from any authenticated client. +

+ )} + {!pairing && !error &&

Loading pairing request…

} + {error &&

{error}

} +
+
+ ); +}; + +export default DesktopPairingPage; diff --git a/propr-ui/src/pages/LoginPage.desktopAuthentication.test.tsx b/propr-ui/src/pages/LoginPage.desktopAuthentication.test.tsx new file mode 100644 index 000000000..37736e8a6 --- /dev/null +++ b/propr-ui/src/pages/LoginPage.desktopAuthentication.test.tsx @@ -0,0 +1,66 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { getCurrentUser } from '../api/proprApi'; +import { AuthProvider } from '../contexts/AuthContext'; +import { DesktopContext, type DesktopContextValue } from '../desktop/DesktopContext'; +import LoginPage from './LoginPage'; + +vi.mock('../hooks/useDocumentTitle', () => ({ useDocumentTitle: vi.fn() })); +vi.mock('../contexts/DemoModeContext', () => ({ + useDemoMode: () => ({ isDemoMode: false, isLoading: false }), +})); +vi.mock('../api/proprApi', () => ({ getCurrentUser: vi.fn() })); + +const LocationProbe = () => { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +}; + +describe('LoginPage desktop authentication', () => { + beforeEach(() => vi.clearAllMocks()); + + it('refreshes shared authentication state and resumes the return path after completion', async () => { + vi.mocked(getCurrentUser).mockRejectedValue(new Error('Authentication required')); + let completeAuthentication: (() => void) | undefined; + const authenticate = vi.fn(() => new Promise(resolve => { + completeAuthentication = resolve; + })); + const refreshCurrentUser = vi.fn(async () => undefined); + const desktop: DesktopContextValue = { + isDesktop: true, + platform: 'linux', + profile: { id: 'local', name: 'This computer', baseUrl: 'http://127.0.0.1:3000', kind: 'local' }, + connection: { status: 'ready' }, + openProfileManager: vi.fn(), + authenticate, + openConnectionHelp: vi.fn(async () => undefined), + retry: vi.fn(), + }; + + render( + + + + + + } /> + plans page
} /> + + + + + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Sign in with GitHub' })); + expect(screen.getByRole('button', { name: 'Waiting for GitHub...' })).toBeDisabled(); + expect(refreshCurrentUser).not.toHaveBeenCalled(); + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + + await act(async () => completeAuthentication?.()); + + await waitFor(() => expect(refreshCurrentUser).toHaveBeenCalledOnce()); + expect(await screen.findByText('plans page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/plans'); + }); +}); diff --git a/propr-ui/src/pages/LoginPage.tsx b/propr-ui/src/pages/LoginPage.tsx index e587704b2..c31e25ab7 100644 --- a/propr-ui/src/pages/LoginPage.tsx +++ b/propr-ui/src/pages/LoginPage.tsx @@ -9,11 +9,13 @@ import { pathWithActiveHostedTunnelFlow, } from '../config/runtimeConfig'; import { isProprProxyUrl } from '@propr/shared'; +import { publicAssetUrl } from '../config/runtimeMode'; +import { useDesktop } from '../desktop/DesktopContext'; +import { useRefreshCurrentUser } from '../contexts/AuthContext'; -const API_BASE_URL = getApiBaseUrl(); // For OAuth, use main API to avoid registering multiple callback URLs // Falls back to API_BASE_URL for main site -const OAUTH_API_URL = import.meta.env.VITE_OAUTH_API_URL || API_BASE_URL; +const getOAuthApiUrl = (): string => import.meta.env.VITE_OAUTH_API_URL || getApiBaseUrl(); const HOSTED_OAUTH_COMPLETION_PATH = '/login?oauth_complete=true'; const HOSTED_OAUTH_POLL_INTERVAL_MS = 1_000; const HOSTED_OAUTH_POPUP_CHECK_INTERVAL_MS = 500; @@ -84,7 +86,7 @@ const validateOAuthApiBaseUrl = ( throw new Error('OAuth API URL must be a bare http(s) origin.'); } if (options.hostedPopupCompletion && isHostedUiOrigin(hostname)) { - const activeApiBaseUrl = (options.activeApiBaseUrl ?? API_BASE_URL).trim(); + const activeApiBaseUrl = (options.activeApiBaseUrl ?? getApiBaseUrl()).trim(); let activeApiUrl: URL; try { activeApiUrl = validatedHttpUrl(activeApiBaseUrl); @@ -124,7 +126,7 @@ const resolveReturnPath = (state: unknown, redirectToParam: string | null): stri export const buildGithubOAuthUrl = ( returnPath: string, origin = window.location.origin, - oauthApiUrl = OAUTH_API_URL, + oauthApiUrl = getOAuthApiUrl(), hostname = window.location.hostname, options: BuildGithubOAuthUrlOptions = {} ): string => { @@ -163,6 +165,8 @@ const LoginPage: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode(); + const desktop = useDesktop(); + const refreshCurrentUser = useRefreshCurrentUser(); const loggedOut = searchParams.get('logged_out') === 'true'; const isOAuthCompletion = searchParams.get('oauth_complete') === 'true'; const hostedOAuthFlowRef = useRef(null); @@ -179,6 +183,7 @@ const LoginPage: React.FC = () => { // flash of the login button before the session check resolves. const [isRecovering, setIsRecovering] = useState(!loggedOut && !isOAuthCompletion); const [isHostedOAuthPolling, setIsHostedOAuthPolling] = useState(false); + const [isDesktopAuthenticating, setIsDesktopAuthenticating] = useState(false); const [hostedOAuthError, setHostedOAuthError] = useState(null); const stopHostedOAuthFlow = useCallback((closePopup = false) => { @@ -314,6 +319,21 @@ const LoginPage: React.FC = () => { }, [failHostedOAuthFlow, navigate, returnPathWithActiveFlow, stopHostedOAuthFlow]); const handleLogin = useCallback(() => { + if (desktop) { + setHostedOAuthError(null); + setIsDesktopAuthenticating(true); + void (async () => { + try { + await desktop.authenticate(); + await refreshCurrentUser(); + navigate(returnPathWithActiveFlow, { replace: true }); + } catch (error) { + setIsDesktopAuthenticating(false); + setHostedOAuthError(error instanceof Error ? error.message : 'GitHub sign-in did not complete.'); + } + })(); + return; + } // Local/self-hosted OAuth keeps using redirect_to for the final same-tab // navigation back to the page the user came from. // Hosted OAuth completes in a popup and the initiating tab polls its own @@ -325,7 +345,7 @@ const LoginPage: React.FC = () => { oauthUrl = buildGithubOAuthUrl( returnPath, window.location.origin, - OAUTH_API_URL, + getOAuthApiUrl(), window.location.hostname, { hostedPopupCompletion: hostedLogin } ); @@ -342,7 +362,7 @@ const LoginPage: React.FC = () => { return; } window.location.href = oauthUrl; - }, [returnPath, startHostedOAuthFlow]); + }, [desktop, navigate, refreshCurrentUser, returnPath, returnPathWithActiveFlow, startHostedOAuthFlow]); if (isRecovering) { return ( @@ -363,7 +383,7 @@ const LoginPage: React.FC = () => {
- ProPR + ProPR {loggedOut && (
@@ -383,13 +403,17 @@ const LoginPage: React.FC = () => { <> {hostedOAuthError && (
diff --git a/propr-ui/src/vite-env.d.ts b/propr-ui/src/vite-env.d.ts index c3c734be4..6abae6cad 100644 --- a/propr-ui/src/vite-env.d.ts +++ b/propr-ui/src/vite-env.d.ts @@ -3,3 +3,8 @@ // Injected at build time by Vite (see vite.config.ts) — the product version // taken from the root package.json. declare const __APP_VERSION__: string; +declare const __PROPR_DESKTOP__: boolean; + +interface Window { + proprDesktop?: import('../../apps/desktop/src/shared/contract').DesktopBridge; +} diff --git a/propr-ui/tailwind.config.js b/propr-ui/tailwind.config.js index 32975812d..259d7e38a 100644 --- a/propr-ui/tailwind.config.js +++ b/propr-ui/tailwind.config.js @@ -1,9 +1,12 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: { + relative: true, + files: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + }, theme: { extend: { colors: { @@ -23,4 +26,4 @@ export default { }, }, plugins: [], -} \ No newline at end of file +} diff --git a/propr-ui/tsconfig.json b/propr-ui/tsconfig.json index 8ee1c2b1e..90e86060c 100644 --- a/propr-ui/tsconfig.json +++ b/propr-ui/tsconfig.json @@ -8,6 +8,10 @@ /* Bundler mode */ "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@propr/client": ["../packages/client/src/index.ts"] + }, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, diff --git a/propr-ui/vite.config.ts b/propr-ui/vite.config.ts index 482396c7d..0f967a19c 100644 --- a/propr-ui/vite.config.ts +++ b/propr-ui/vite.config.ts @@ -33,8 +33,16 @@ function pwaShellAssetManifest(): Plugin { // https://vite.dev/config/ export default defineConfig({ + resolve: { + // Consume the workspace source in clean checkouts; @propr/client still + // builds to dist for packaged desktop/CLI consumers. + alias: { + '@propr/client': fileURLToPath(new URL('../packages/client/src/index.ts', import.meta.url)), + }, + }, define: { __APP_VERSION__: JSON.stringify(rootPkg.version), + __PROPR_DESKTOP__: 'false', }, plugins: [react(), pwaShellAssetManifest()], test: { diff --git a/scripts/verify-native-connect-authority.mjs b/scripts/verify-native-connect-authority.mjs new file mode 100644 index 000000000..5ae5185e3 --- /dev/null +++ b/scripts/verify-native-connect-authority.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; + +if (process.platform !== "darwin") { + process.stderr.write("Native Connect authority verification requires macOS.\n"); + process.exit(1); +} + +const root = resolve(import.meta.dirname, ".."); +const result = spawnSync(process.execPath, [ + "--import", "tsx", "--test", join(root, "test", "nativeConnectAuthority.test.ts"), +], { + cwd: root, + shell: false, + windowsHide: true, + encoding: "utf8", + env: process.env, + timeout: 30_000, + maxBuffer: 2 * 1024 * 1024, +}); + +const stdout = result.stdout ?? ""; +const stderr = result.stderr ?? ""; +process.stdout.write(stdout); +process.stderr.write(stderr); + +const tapValue = (name) => { + const matches = [...stdout.matchAll(new RegExp(`^# ${name} (\\d+)$`, "gm"))]; + return matches.length === 0 ? undefined : Number(matches.at(-1)[1]); +}; +const valid = result.status === 0 + && !result.error + && !result.signal + && tapValue("tests") === 6 + && tapValue("pass") === 6 + && tapValue("fail") === 0 + && tapValue("skipped") === 0; + +if (!valid) { + process.stderr.write("Native Darwin Connect authority proof was incomplete.\n"); + process.exit(1); +} +process.stdout.write("Native Darwin authority proof: tests=6 pass=6 fail=0 skipped=0\n"); diff --git a/scripts/verify-platform-safe-connect.mjs b/scripts/verify-platform-safe-connect.mjs new file mode 100644 index 000000000..7ffa2f4f8 --- /dev/null +++ b/scripts/verify-platform-safe-connect.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const files = [ + 'packages/cli/src/commands/connectCommand.test.ts', + 'packages/cli/src/connectRootAuthority.test.ts', + 'packages/cli/src/commands/initStack.test.ts', + 'packages/cli/src/config/ConfigManager.test.ts', + 'packages/cli/src/index.test.ts', + 'packages/cli/src/orchestrator/index.test.ts', + 'packages/api/test/statusRoutes.test.ts', +].map((file) => join(root, file)); + +const result = spawnSync(process.execPath, [ + '--import', 'tsx', '--experimental-test-module-mocks', '--test', ...files, +], { + cwd: root, + shell: false, + windowsHide: true, + encoding: 'utf8', + env: process.env, + timeout: 90_000, + maxBuffer: 16 * 1024 * 1024, +}); + +const stdout = result.stdout ?? ''; +const stderr = result.stderr ?? ''; +process.stdout.write(stdout); +process.stderr.write(stderr); + +const tapValue = (name) => { + const matches = [...stdout.matchAll(new RegExp(`^# ${name} (\\d+)$`, 'gm'))]; + return matches.length === 0 ? undefined : Number(matches.at(-1)[1]); +}; +const valid = result.status === 0 + && !result.error + && !result.signal + && tapValue('tests') === 86 + && tapValue('pass') === 86 + && tapValue('fail') === 0 + && tapValue('skipped') === 0; + +if (!valid) { + process.stderr.write('Platform-safe Connect proof did not complete 86/86 within 90000ms.\n'); + process.exitCode = 1; +} else { + process.stdout.write('Platform-safe Connect proof: tests=86 pass=86 fail=0 skipped=0 budgetMs=90000\n'); +} diff --git a/scripts/verify-windows-standard-user-connect.mjs b/scripts/verify-windows-standard-user-connect.mjs new file mode 100644 index 000000000..010d7c4f0 --- /dev/null +++ b/scripts/verify-windows-standard-user-connect.mjs @@ -0,0 +1,480 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { closeSync, constants, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { userInfo } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +if (process.platform !== "win32") { + process.stderr.write("Ordinary-user Windows Connect discovery proof requires Windows.\n"); + process.exit(1); +} + +const expectedUser = process.argv[2]; +const preparedFixture = process.argv[3]; +const actualUser = userInfo().username; + +const repo = resolve(import.meta.dirname, ".."); +const cli = join(repo, "packages", "cli", "dist", "index.js"); +const fetchFixture = pathToFileURL(join(repo, "test", "fixtures", "connectFetchMock.mjs")).href; +const processFixture = pathToFileURL(join(repo, "test", "fixtures", "windowsConnectProcessMock.mjs")).href; +const authorityModule = pathToFileURL(join(repo, "packages", "cli", "dist", "connectRootAuthority.js")).href; +const windowsAuthorityModule = pathToFileURL(join(repo, "packages", "cli", "dist", "connectWindowsAuthority.js")).href; +const initStackModule = pathToFileURL(join(repo, "packages", "cli", "dist", "commands", "initStack.js")).href; +const configManagerModule = pathToFileURL(join(repo, "packages", "cli", "dist", "config", "ConfigManager.js")).href; +const fixtureNodeArgs = Object.freeze([ + "--no-warnings", + "--import", processFixture, + "--import", fetchFixture, +]); +assert.deepEqual(fixtureNodeArgs, [ + "--no-warnings", + "--import", processFixture, + "--import", fetchFixture, +]); +const fixture = realpathSync.native(preparedFixture); +const root = realpathSync.native(join(fixture, "stack-private-path-SENTINEL")); +const endpoint = "https://t-abc123.propr.dev"; +const identity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +function tunnelFixtureEnvLines({ enabled }) { + return [ + `PROPR_UI_TUNNEL_ENABLED=${enabled ? "true" : "false"}`, + ...(enabled ? ["PROPR_UI_TUNNEL_TOKEN=root-token-SENTINEL"] : []), + ]; +} + +function windowsRootEnvironment(systemRootMode, systemRoot, windir, untrustedRoot) { + if (systemRootMode === "missing") return {}; + return { + SYSTEMROOT: systemRoot, + WINDIR: systemRootMode === "mismatched" ? untrustedRoot : windir, + }; +} + +const WINDOWS_ROOT_MISSING_MARKER = "PROPR_TEST_WINDOWS_ROOT_MISSING"; +const WINDOWS_ROOT_MISSING_MARKER_VALUE = "windows-root-missing-v1"; + +function missingWindowsRootFixtureEnvironment(systemRootMode) { + return systemRootMode === "missing" + ? { [WINDOWS_ROOT_MISSING_MARKER]: WINDOWS_ROOT_MISSING_MARKER_VALUE } + : {}; +} + +const WINDOWS_ROOT_UNTRUSTED_MARKER = "PROPR_TEST_WINDOWS_ROOT_UNTRUSTED"; +const WINDOWS_ROOT_UNTRUSTED_MARKER_VALUE = "windows-root-untrusted-v1"; +const WINDOWS_ROOT_UNTRUSTED_PATH = "PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH"; + +function untrustedWindowsRootFixtureEnvironment(systemRootMode, untrustedRoot) { + return systemRootMode === "untrusted" + ? { + [WINDOWS_ROOT_UNTRUSTED_MARKER]: WINDOWS_ROOT_UNTRUSTED_MARKER_VALUE, + [WINDOWS_ROOT_UNTRUSTED_PATH]: untrustedRoot, + } + : {}; +} + +const scenarioAllowlist = Object.freeze([ + "ready", "down", "disabled", "restart-required", "malformed", "oversized", "timeout", + "identity-mismatch", "secret-sentinel", "api", "path-aba", "authority-malformed", "authority-oversized", + "authority-extra-key", "authority-duplicate", "authority-entry-count", "authority-entry-shape", + "authority-stderr", "authority-nonzero", + "authority-timeout", "authority-descriptor-mismatch", "authority-index-mismatch", + "authority-kind-mismatch", "authority-authority-kind-mismatch", "authority-identity-mismatch", + "authority-sid-mismatch", "authority-broad-write", "authority-inherited-write", + "authority-unprotected", "authority-owner-mismatch", "authority-reparse", + "authority-missing-system-root", "authority-mismatched-system-root", "authority-untrusted-system-root", +]); +const assertionStageAllowlist = Object.freeze([ + "native-timing", "authority-probe", "scaffold", "identity-assertion", "config-init", "config-save", + "config-assertion", + "write-env", "spawn", "signal", "exit", "bounds", "schema", "status", "endpoint", + "identity", "reasons", "api-ready", "restart", "stderr", "sentinel", "api-spawn", + "api-exit", "api-count", +]); +const statusKindAllowlist = Object.freeze([ + "ready", "internalFailure", "notReady", "incompatible", "invalidConfig", "timeout", +]); +const reasonCodeAllowlist = Object.freeze([ + "NOT_CONFIGURED", "TUNNEL_DISABLED", "SIDECAR_NOT_RUNNING", "API_UNREACHABLE", "API_TIMEOUT", + "DISCOVERY_UNSUPPORTED", "DISCOVERY_INVALID", "DISCOVERY_TOO_LARGE", "API_INCOMPATIBLE", + "DESKTOP_AUTHENTICATION_UNSUPPORTED", + "IDENTITY_MISMATCH", "ENDPOINT_MISMATCH", "RESTART_REQUIRED", "INVALID_ROOT", "INVALID_ENDPOINT", + "IDENTITY_UNAVAILABLE", "INTERNAL_FAILURE", "ACL_DIAGNOSTIC_UNAVAILABLE", +]); +const nativeStageAllowlist = Object.freeze([ + "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", + "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", + "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", + "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", + "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", +]); +const probeMilestoneAllowlist = Object.freeze([ + "none", "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", + "standard-handle-identity", +]); +const probeTimingAllowlist = Object.freeze([ + "under-5s", "5-to-15s", "15-to-30s", "30-to-45s", "45-to-60s", "at-least-60s", +]); +const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2; +const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000; +const scenarioNames = new Set(scenarioAllowlist); +const assertionStages = new Set(assertionStageAllowlist); +const statusKinds = new Set(statusKindAllowlist); +const diagnosticStatuses = new Set([null, ...statusKindAllowlist]); +const reasonCodes = new Set(reasonCodeAllowlist); +const nativeStages = new Set(nativeStageAllowlist); +const probeMilestones = new Set(probeMilestoneAllowlist); +const probeTimings = new Set(probeTimingAllowlist); + +function parseBoundedFailureStatus(stdout) { + if (typeof stdout !== "string" || stdout.length === 0 || Buffer.byteLength(stdout, "utf8") >= 2048) return null; + const lines = stdout.trim().split(/\r?\n/); + if (lines.length !== 1) return null; + try { + const document = JSON.parse(lines[0]); + if (!document || typeof document !== "object" || !statusKinds.has(document.status) + || !Array.isArray(document.reasonCodes) || document.reasonCodes.length > reasonCodes.size + || new Set(document.reasonCodes).size !== document.reasonCodes.length + || document.reasonCodes.some((code) => !reasonCodes.has(code))) return null; + return { status: document.status, reasonCodes: document.reasonCodes }; + } catch { + return null; + } +} + +function createFailureDiagnostic(scenario, stage, failureStatus, nativeStage, probe) { + const status = failureStatus?.status ?? null; + const codes = failureStatus?.reasonCodes ?? []; + if (!scenarioNames.has(scenario) || !assertionStages.has(stage) || !diagnosticStatuses.has(status) + || (nativeStage !== null && !nativeStages.has(nativeStage)) + || (probe.milestone !== null && !probeMilestones.has(probe.milestone)) + || (probe.timing !== null && !probeTimings.has(probe.timing)) + || !Array.isArray(codes) || codes.length > reasonCodes.size + || new Set(codes).size !== codes.length || codes.some((code) => !reasonCodes.has(code))) { + return { + scenario: "ready", stage: "write-env", nativeStage: null, status: null, reasonCodes: [], + probeMilestone: null, probeTiming: null, + }; + } + return { + scenario, stage, nativeStage, status, reasonCodes: [...codes], + probeMilestone: probe.milestone, + probeTiming: probe.timing, + }; +} + +function extractNativeDiagnostic(stderr) { + let nativeStage = null; + const applicationStderr = stderr.replace(/^\[propr-windows-native-stage:([^\]]+)\]\r?\n/gm, (_line, stage) => { + nativeStage = nativeStages.has(stage) ? stage : "parent:json-shape"; + return ""; + }); + return { applicationStderr, nativeStage }; +} + +const cases = [ + { name: "ready", fetch: "ready", docker: "ready", enabled: true, status: "ready", exit: 0, reasons: [] }, + { name: "down", fetch: "ready", docker: "down", authorityMode: "valid-authority", enabled: true, status: "notReady", exit: 0, reasons: ["SIDECAR_NOT_RUNNING"] }, + { name: "disabled", fetch: "ready", docker: "ready", authorityMode: "valid-authority", enabled: false, status: "notReady", exit: 0, reasons: ["TUNNEL_DISABLED"] }, + { name: "restart-required", fetch: "restart-required", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "notReady", exit: 0, reasons: ["ENDPOINT_MISMATCH", "RESTART_REQUIRED"] }, + { name: "malformed", fetch: "invalid", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "incompatible", exit: 2, reasons: ["DISCOVERY_INVALID"] }, + { name: "oversized", fetch: "oversized", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "incompatible", exit: 2, reasons: ["DISCOVERY_TOO_LARGE"] }, + { name: "timeout", fetch: "timeout", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "timeout", exit: 0, reasons: ["API_TIMEOUT"] }, + { name: "identity-mismatch", fetch: "identity-mismatch", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "notReady", exit: 0, reasons: ["IDENTITY_MISMATCH"] }, + { name: "secret-sentinel", fetch: "secret-sentinel", docker: "ready", authorityMode: "valid-authority", enabled: true, status: "notReady", exit: 0, reasons: ["API_UNREACHABLE"] }, +]; +const authorityFailures = [ + { name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" }, + { name: "authority-malformed", mode: "malformed", nativeStage: "parent:json-parse" }, + { name: "authority-oversized", mode: "oversized" }, + { name: "authority-extra-key", mode: "extra-key", nativeStage: "parent:document-shape" }, + { name: "authority-duplicate", mode: "duplicate", nativeStage: "parent:json-canonical" }, + { name: "authority-entry-count", mode: "entry-count", nativeStage: "parent:entry-count" }, + { name: "authority-entry-shape", mode: "entry-shape", nativeStage: "parent:entry-shape" }, + { name: "authority-stderr", mode: "stderr" }, + { name: "authority-nonzero", mode: "nonzero" }, + { name: "authority-timeout", mode: "timeout" }, + { name: "authority-descriptor-mismatch", mode: "descriptor-mismatch" }, + { name: "authority-index-mismatch", mode: "index-mismatch" }, + { name: "authority-kind-mismatch", mode: "kind-mismatch" }, + { name: "authority-authority-kind-mismatch", mode: "authority-kind-mismatch" }, + { name: "authority-identity-mismatch", mode: "identity-mismatch" }, + { name: "authority-sid-mismatch", mode: "sid-mismatch" }, + { name: "authority-broad-write", mode: "broad-write", reason: "INVALID_ROOT" }, + { name: "authority-inherited-write", mode: "inherited-write", reason: "INVALID_ROOT" }, + { name: "authority-unprotected", mode: "unprotected", reason: "INVALID_ROOT" }, + { name: "authority-owner-mismatch", mode: "owner-mismatch", reason: "INVALID_ROOT" }, + { name: "authority-reparse", mode: "reparse", reason: "INVALID_ROOT" }, + { name: "authority-missing-system-root", systemRootMode: "missing", nativeStage: "resolver:env" }, + { name: "authority-mismatched-system-root", systemRootMode: "mismatched" }, + { name: "authority-untrusted-system-root", systemRootMode: "untrusted", nativeStage: "resolver:global-id" }, +]; + +let currentScenario = "ready"; +let currentStage = "write-env"; +let failureStatus = null; +let currentNativeStage = null; +const nativeProbe = { milestone: null, timing: null, evidence: null }; +try { + assert.ok(expectedUser && actualUser.toLowerCase() === expectedUser.toLowerCase(), "proof did not run as the limited user"); + currentStage = "native-timing"; + const nativeAuthority = await import(windowsAuthorityModule); + const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( + WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT + * nativeAuthority.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS + ) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; + const probeFd = openSync( + fixture, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + try { + const proof = nativeAuthority.runWindowsNativeTimingProbe(probeFd); + assert.equal(proof.version, 1); + nativeProbe.milestone = proof.lastMilestone; + nativeProbe.timing = proof.timingBucket; + if (proof.outcome === "timeout") currentNativeStage = "spawn:timeout"; + assert.equal(proof.outcome, "complete"); + assert.deepEqual(proof.milestones.map(({ milestone }) => milestone), [ + "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", + "standard-handle-identity", + ]); + assert.ok(proof.milestones.every(({ milestone, timingBucket }) => ( + probeMilestones.has(milestone) && probeTimings.has(timingBucket) + ))); + nativeProbe.evidence = proof.milestones.map( + ({ milestone, timingBucket }) => `${milestone}:${timingBucket}`, + ).join(","); + } catch (error) { + currentNativeStage = nativeStages.has(error?.stage) + ? error.stage + : (currentNativeStage ?? "parent:json-shape"); + throw error; + } + } finally { + closeSync(probeFd); + } + currentStage = "authority-probe"; + const authority = await import(authorityModule); + await assert.rejects( + authority.protectWindowsSetupEntries([{ path: root, kind: "directory" }]), + (error) => error?.code === authority.WINDOWS_AUTHORITY_REQUIRED_CODE + && /authority is required/i.test(error.message) + && /#1997/.test(error.message), + "privileged Windows mutation did not return the actionable follow-up result", + ); + + // Privileged mutation stays deferred even though read-only discovery now + // inspects the already-open descriptors through the OS PowerShell boundary. + currentStage = "scaffold"; + const { scaffoldStack } = await import(initStackModule); + const mutationRoot = realpathSync.native(mkdtempSync(join(fixture, "stack-"))); + writeFileSync(join(mutationRoot, ".env"), "SESSION_SECRET=existing\nNODE_ENV=production\n"); + const scaffold = await scaffoldStack( + { root: mutationRoot }, + { persistStackRoot: async () => undefined }, + ); + currentStage = "identity-assertion"; + assert.equal(scaffold.envSkipped, true); + assert.ok(readFileSync(join(mutationRoot, "data", "public-instance-identity.json"), "utf8").length > 0); + + currentStage = "config-init"; + const { ConfigManager } = await import(configManagerModule); + const configDirectory = join(fixture, "config"); + const manager = new ConfigManager(configDirectory, { warn: () => undefined }); + await manager.init(); + currentStage = "config-save"; + await manager.save(); + currentStage = "config-assertion"; + assert.deepEqual(JSON.parse(readFileSync(join(configDirectory, "config.json"), "utf8")), {}); + + for (const scenario of cases) { + currentScenario = scenario.name; + currentStage = "write-env"; + failureStatus = null; + currentNativeStage = null; + writeFileSync(join(root, ".env"), [ + "PROPR_STACK=authorized", + "PROPR_INSTANCE_ID=abc123", + `PROPR_UI_PUBLIC_API_URL=${endpoint}`, + ...tunnelFixtureEnvLines(scenario), + "", + ].join("\n")); + currentStage = "spawn"; + const result = spawnSync(process.execPath, [ + ...fixtureNodeArgs, + cli, + "connect", "status", "--json", "--root", root, + ], { + cwd: fixture, + shell: false, + windowsHide: true, + encoding: "utf8", + timeout: WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS, + maxBuffer: 16 * 1024, + env: { + PATH: dirname(process.execPath), + PATHEXT: process.env.PATHEXT, + SYSTEMROOT: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + COMSPEC: process.env.ComSpec, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + PROPR_TEST_DISCOVERY_MODE: scenario.fetch, + PROPR_TEST_DOCKER_MODE: scenario.docker, + PROPR_TEST_PUBLIC_IDENTITY: identity, + ...(scenario.authorityMode ? { + PROPR_TEST_AUTHORITY_MODE: scenario.authorityMode, + PROPR_TEST_AUTHORITY_ROOT: root, + } : {}), + PROPR_CONNECTOR_TOKEN: "connector-token-SENTINEL", + PROPR_RELAY_TOKEN: "relay-token-SENTINEL", + GITHUB_TOKEN: "github-token-SENTINEL", + }, + }); + const nativeDiagnostic = extractNativeDiagnostic(result.stderr); + currentNativeStage = nativeDiagnostic.nativeStage; + currentStage = "bounds"; + failureStatus = parseBoundedFailureStatus(result.stdout); + currentStage = "signal"; + assert.equal(result.signal, null, scenario.name); + currentStage = "exit"; + assert.equal(result.status, scenario.exit, scenario.name); + currentStage = "bounds"; + assert.ok(result.stdout.length > 0 && result.stdout.length < 2048, scenario.name); + currentStage = "schema"; + assert.equal(result.stdout.trim().split(/\r?\n/).length, 1, scenario.name); + const document = JSON.parse(result.stdout); + currentStage = "status"; + assert.equal(document.status, scenario.status, scenario.name); + currentStage = "endpoint"; + assert.equal(document.canonicalEndpoint, endpoint, scenario.name); + currentStage = "identity"; + assert.equal(document.publicInstanceIdentity, identity, scenario.name); + currentStage = "reasons"; + assert.deepEqual(document.reasonCodes, scenario.reasons, scenario.name); + currentStage = "api-ready"; + assert.equal(document.apiReady, scenario.status === "ready", scenario.name); + currentStage = "restart"; + assert.equal(document.restartRequired, scenario.name === "restart-required", scenario.name); + currentStage = "stderr"; + const expectedStderr = scenario.status === "ready" ? "" : `ProPR Connect discovery: ${scenario.status}.\n`; + assert.equal(nativeDiagnostic.applicationStderr, expectedStderr, scenario.name); + currentStage = "sentinel"; + for (const sentinel of [ + "root-token-SENTINEL", "connector-token-SENTINEL", "relay-token-SENTINEL", + "github-token-SENTINEL", "docker-secret-SENTINEL", "private-path-SENTINEL", fixture, + ]) { + assert.equal(result.stdout.includes(sentinel), false, `${scenario.name} stdout leaked ${sentinel}`); + assert.equal(nativeDiagnostic.applicationStderr.includes(sentinel), false, `${scenario.name} stderr leaked ${sentinel}`); + } + } + + for (const scenario of authorityFailures) { + currentScenario = scenario.name; + currentStage = "spawn"; + failureStatus = null; + currentNativeStage = null; + const result = spawnSync(process.execPath, [ + ...fixtureNodeArgs, + cli, + "connect", "status", "--json", "--root", root, + ], { + cwd: fixture, + shell: false, + windowsHide: true, + encoding: "utf8", + timeout: WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS, + maxBuffer: 16 * 1024, + env: { + PATH: dirname(process.execPath), + PATHEXT: process.env.PATHEXT, + ...windowsRootEnvironment( + scenario.systemRootMode, + process.env.SystemRoot, + process.env.WINDIR, + fixture, + ), + ...missingWindowsRootFixtureEnvironment(scenario.systemRootMode), + ...untrustedWindowsRootFixtureEnvironment(scenario.systemRootMode, fixture), + COMSPEC: process.env.ComSpec, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + PROPR_TEST_DISCOVERY_MODE: "ready", + PROPR_TEST_DOCKER_MODE: "ready", + PROPR_TEST_PUBLIC_IDENTITY: identity, + PROPR_TEST_AUTHORITY_MODE: scenario.mode, + ...(scenario.mode === "path-aba" ? { PROPR_TEST_AUTHORITY_ROOT: root } : {}), + }, + }); + const nativeDiagnostic = extractNativeDiagnostic(result.stderr); + currentNativeStage = nativeDiagnostic.nativeStage; + if (scenario.nativeStage !== undefined) { + assert.equal(currentNativeStage, scenario.nativeStage, scenario.name); + } + currentStage = "bounds"; + failureStatus = parseBoundedFailureStatus(result.stdout); + currentStage = "signal"; + assert.equal(result.signal, null, scenario.name); + currentStage = "exit"; + assert.equal(result.status, 1, scenario.name); + currentStage = "schema"; + const document = JSON.parse(result.stdout); + currentStage = "status"; + assert.equal(document.status, "invalidConfig", scenario.name); + currentStage = "reasons"; + assert.deepEqual(document.reasonCodes, [scenario.reason ?? "ACL_DIAGNOSTIC_UNAVAILABLE"], scenario.name); + currentStage = "stderr"; + assert.equal(nativeDiagnostic.applicationStderr, "ProPR Connect discovery: invalidConfig.\n", scenario.name); + currentStage = "sentinel"; + for (const sentinel of [ + fixture, "private-path-SENTINEL", "attacker-replacement-SENTINEL", + "S-1-5-21-999", "raw-error-SENTINEL", + ]) { + assert.equal(result.stdout.includes(sentinel), false, scenario.name); + assert.equal(nativeDiagnostic.applicationStderr.includes(sentinel), false, scenario.name); + } + } + + currentScenario = "api"; + currentStage = "api-spawn"; + failureStatus = null; + const api = spawnSync(process.execPath, [ + "--import", "tsx", "--test", join(repo, "packages", "api", "test", "statusRoutes.test.ts"), + ], { + cwd: repo, + shell: false, + windowsHide: true, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + env: process.env, + }); + currentStage = "api-exit"; + assert.equal(api.status, 0, api.stderr || api.stdout); + currentStage = "api-count"; + const pass = [...api.stdout.matchAll(/^# pass (\d+)$/gm)].at(-1); + const fail = [...api.stdout.matchAll(/^# fail (\d+)$/gm)].at(-1); + assert.ok(pass && Number(pass[1]) > 0, "API discovery tests did not report passes"); + assert.equal(Number(fail?.[1]), 0, "API discovery tests reported failures"); + process.stdout.write(`Windows ordinary-user discovery proof: ready=standard-handle-passed native-timing=${nativeProbe.evidence};total:${nativeProbe.timing} cli=${cases.length} api=${pass[1]} authority=${authorityFailures.length}\n`); +} catch { + const diagnostic = createFailureDiagnostic( + currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe, + ); + process.stderr.write(`Windows ordinary-user discovery assertion failed: ${JSON.stringify( + diagnostic, + )}\n`); + process.exitCode = 1; +} finally { + // The elevated workflow owner removes the prepared fixture after the + // limited-user process exits. +} diff --git a/test/config-followup.test.ts b/test/config-followup.test.ts index a4d008e94..fe48af4be 100644 --- a/test/config-followup.test.ts +++ b/test/config-followup.test.ts @@ -64,7 +64,7 @@ test('saveSettingsWithRollback returns a specific failure without partial-commit database: testDb, settings: { planner_context_model: 'gpt-5', - pr_review_model: 'claude-sonnet-4-6' + pr_review_model: '' }, publishConfigUpdate: async () => { published += 1; diff --git a/test/connectCliIntegration.test.ts b/test/connectCliIntegration.test.ts new file mode 100644 index 000000000..9e509db98 --- /dev/null +++ b/test/connectCliIntegration.test.ts @@ -0,0 +1,520 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + closeSync, + constants, + cpSync, + existsSync, + fstatSync, + lstatSync, + mkdtempSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir, userInfo } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { getOrCreatePublicInstanceIdentity } from '../packages/cli/src/connectIdentity.js'; + +const CLI = join(process.cwd(), 'packages', 'cli', 'dist', 'index.js'); +const FETCH_FIXTURE = join(process.cwd(), 'test', 'fixtures', 'connectFetchMock.mjs'); +const OS_HOME_FIXTURE = join(process.cwd(), 'test', 'fixtures', 'connectOsHomeMock.mjs'); +const IDENTITY = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const ENDPOINT = 'https://t-abc123.propr.dev'; +const FIXTURE_NODE_ARGS = Object.freeze([ + '--no-warnings', + '--import', + OS_HOME_FIXTURE, + '--import', + FETCH_FIXTURE, +]); + +assert.deepEqual(FIXTURE_NODE_ARGS, [ + '--no-warnings', + '--import', + OS_HOME_FIXTURE, + '--import', + FETCH_FIXTURE, +]); + +interface PathSnapshot { + kind: 'absent' | 'directory' | 'file' | 'other' | 'symlink'; + metadata?: { + birthtimeMs: number; + ctimeMs: number; + dev: number; + gid: number; + ino: number; + mode: number; + mtimeMs: number; + nlink: number; + size: number; + uid: number; + }; + sha256?: string; + target?: string; +} + +function snapshotPath(path: string): PathSnapshot { + let named: ReturnType; + try { + named = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' }; + throw error; + } + const metadata = { + birthtimeMs: named.birthtimeMs, + ctimeMs: named.ctimeMs, + dev: named.dev, + gid: named.gid, + ino: named.ino, + mode: named.mode, + mtimeMs: named.mtimeMs, + nlink: named.nlink, + size: named.size, + uid: named.uid, + }; + if (named.isSymbolicLink()) return { kind: 'symlink', metadata, target: readlinkSync(path) }; + if (named.isDirectory()) return { kind: 'directory', metadata }; + if (!named.isFile()) return { kind: 'other', metadata }; + + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const held = fstatSync(fd); + assert.equal(held.dev, named.dev, 'OS config changed while its bytes were snapshotted'); + assert.equal(held.ino, named.ino, 'OS config changed while its bytes were snapshotted'); + return { + kind: 'file', + metadata, + sha256: createHash('sha256').update(readFileSync(fd)).digest('hex'), + }; + } finally { + closeSync(fd); + } +} + +function makeRoot( + parent: string, + name: string, + endpoint = ENDPOINT, + tunnel: { token?: string; enabled?: string } = { + token: 'relay-token-in-root-SENTINEL', + enabled: 'true', + }, +): string { + const root = join(parent, name); + mkdirSync(join(root, 'data'), { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); + chmodSync(join(root, 'data'), 0o700); + writeFileSync(join(root, '.env'), [ + 'PROPR_STACK=authorized', + 'PROPR_INSTANCE_ID=abc123', + `PROPR_UI_PUBLIC_API_URL=${endpoint}`, + ...(tunnel.enabled === undefined ? [] : [`PROPR_UI_TUNNEL_ENABLED=${tunnel.enabled}`]), + ...(tunnel.token === undefined ? [] : [`PROPR_UI_TUNNEL_TOKEN=${tunnel.token}`]), + '', + ].join('\n'), { mode: 0o600 }); + chmodSync(join(root, '.env'), 0o600); + return root; +} + +function persistTunnelOverride(home: string, root: string, enabled: boolean): void { + const configDir = join(home, '.propr'); + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + chmodSync(configDir, 0o700); + const configPath = join(configDir, 'config.json'); + writeFileSync(configPath, JSON.stringify({ + tunnelEnabledByRoot: { [root]: enabled }, + }), { mode: 0o600 }); + chmodSync(configPath, 0o600); +} + +function installFakeDocker(parent: string): string { + const bin = join(parent, 'bin'); + mkdirSync(bin, { mode: 0o700 }); + const docker = join(bin, 'docker'); + writeFileSync(docker, `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const behaviorPath = path.join(__dirname, 'docker-behavior'); +const behavior = fs.existsSync(behaviorPath) ? fs.readFileSync(behaviorPath, 'utf8') : 'ready'; +const expectationsPath = path.join(__dirname, 'docker-env-expectations'); +if (fs.existsSync(expectationsPath)) { + const expected = JSON.parse(fs.readFileSync(expectationsPath, 'utf8')); + if (Object.entries(expected).some(([name, value]) => process.env[name] !== value)) process.exit(8); +} +const denied = ['DOCKER_AUTH_CONFIG', 'REGISTRY_PASSWORD', 'PROPR_CONNECTOR_TOKEN', 'PROPR_RELAY_TOKEN', 'GITHUB_TOKEN', 'NODE_OPTIONS', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'UNTRUSTED_AMBIENT']; +if (denied.some((name) => process.env[name] !== undefined)) process.exit(8); +const expectedArgs = ['ps', '-a', '--filter', 'label=propr.stack=authorized', '--format', '{{.Names}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}']; +const exactFilter = JSON.stringify(process.argv.slice(2)) === JSON.stringify(expectedArgs); +const replacementPath = path.join(__dirname, 'replace-root'); +if (fs.existsSync(replacementPath)) { + const root = fs.readFileSync(replacementPath, 'utf8'); + const detached = root + '.detached'; + fs.renameSync(root, detached); + fs.mkdirSync(path.join(root, 'data'), { recursive: true, mode: 0o700 }); + fs.chmodSync(root, 0o700); + fs.chmodSync(path.join(root, 'data'), 0o700); + fs.writeFileSync(path.join(root, '.env'), 'REPLACEMENT_BYTES_SENTINEL=never-read\\n', { mode: 0o600 }); +} +process.stderr.write('docker-private-output-SENTINEL\\n'); +if (behavior === 'nonzero') process.exit(9); +if (behavior === 'timeout') setInterval(() => {}, 60_000); +if (behavior === 'signal') process.kill(process.pid, 'SIGTERM'); +if (behavior === 'large-unrelated') process.stdout.write(exactFilter ? 'authorized-tunnel\\trunning\\tUp 1 second\\t\\n' : 'unrelated-api\\trunning\\tUp\\t\\n'.repeat(5000)); +else if (behavior === 'duplicate') process.stdout.write('authorized-tunnel\\trunning\\tUp\\t\\nauthorized-tunnel\\trunning\\tUp\\t\\n'); +else if (behavior === 'unknown') process.stdout.write('authorized-hostile\\trunning\\tUp\\t\\n'); +else if (behavior === 'malformed') process.stdout.write('authorized-tunnel running malformed-output-SENTINEL\\n'); +else if (behavior === 'truncated') process.stdout.write('x'.repeat(70 * 1024)); +else if (behavior === 'absent') process.stdout.write(''); +else if (behavior === 'stopped') process.stdout.write('authorized-tunnel\\texited\\tExited (0) 1 second ago\\t\\n'); +else process.stdout.write('authorized-tunnel\\trunning\\tUp 1 second\\t\\n'); +`, { mode: 0o700 }); + chmodSync(docker, 0o700); + return bin; +} + +interface InvocationOptions { + cli?: string; + dockerBehavior?: 'ready' | 'absent' | 'stopped' | 'nonzero' | 'timeout' | 'signal' | 'malformed' | 'truncated' | 'large-unrelated' | 'duplicate' | 'unknown'; + replaceRoot?: boolean; + windowsSemantics?: boolean; + arguments?: string[]; + environment?: Record; + dockerEnvironmentExpectations?: Record; +} + +function invoke( + root: string, + mode: string, + bin: string, + privateParent: string, + options: InvocationOptions = {}, +): { status: number | null; stdout: string; stderr: string; document: Record } { + const credentialPath = join(privateParent, 'credential-path-SENTINEL'); + const behaviorPath = join(bin, 'docker-behavior'); + const replacementPath = join(bin, 'replace-root'); + const expectationsPath = join(bin, 'docker-env-expectations'); + if (options.dockerBehavior) writeFileSync(behaviorPath, options.dockerBehavior, { mode: 0o600 }); + if (options.replaceRoot) writeFileSync(replacementPath, root, { mode: 0o600 }); + if (options.dockerEnvironmentExpectations) { + writeFileSync(expectationsPath, JSON.stringify(options.dockerEnvironmentExpectations), { mode: 0o600 }); + } + const result = spawnSync(process.execPath, [ + ...FIXTURE_NODE_ARGS, + options.cli ?? CLI, + ...(options.arguments ?? ['connect', 'status', '--json', '--root', root]), + ], { + shell: false, + cwd: join(privateParent, 'hostile-cwd'), + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + PATH: bin, + HOME: join(privateParent, 'home-private-SENTINEL'), + PROPR_TEST_OS_HOME: join(privateParent, 'isolated-os-home'), + PROPR_TEST_DISCOVERY_MODE: mode, + PROPR_TEST_PUBLIC_IDENTITY: IDENTITY, + PROPR_TEST_PLATFORM: options.windowsSemantics ? 'win32' : '', + PROPR_STACK: 'ambient-stack-SENTINEL', + PROPR_NETWORK: 'ambient-network-SENTINEL', + PROPR_ROOT: join(privateParent, 'ambient-root-SENTINEL'), + PROPR_INSTANCE_ID: 'ambient-instance-SENTINEL', + PROPR_UI_PUBLIC_API_URL: 'https://t-ambient.propr.dev', + PROPR_UI_TUNNEL_ENABLED: 'false', + PROPR_UI_TUNNEL_TOKEN: 'ambient-tunnel-token-SENTINEL', + API_PUBLIC_URL: 'https://t-ambient-api.propr.dev', + API_PORT: '4999', + UI_PORT: '5999', + DOCS_PORT: '6999', + HOST_DATA_DIR: join(privateParent, 'ambient-data-SENTINEL'), + HOST_LOGS_DIR: join(privateParent, 'ambient-logs-SENTINEL'), + HOST_REPOS_DIR: join(privateParent, 'ambient-repos-SENTINEL'), + PROPR_CONNECTOR_TOKEN: 'connector-token-SENTINEL', + PROPR_RELAY_TOKEN: 'relay-token-SENTINEL', + GITHUB_TOKEN: 'github-token-SENTINEL', + GH_PRIVATE_KEY_PATH: credentialPath, + UNTRUSTED_RAW_URL: 'https://userinfo:secret@raw-url-SENTINEL.invalid/path', + DOCKER_AUTH_CONFIG: 'docker-auth-SENTINEL', + REGISTRY_PASSWORD: 'registry-password-SENTINEL', + HTTP_PROXY: 'http://proxy-SENTINEL.invalid', + HTTPS_PROXY: 'http://proxy-SENTINEL.invalid', + NO_PROXY: 'no-proxy-SENTINEL', + UNTRUSTED_AMBIENT: 'ambient-SENTINEL', + ...options.environment, + NODE_OPTIONS: undefined, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + rmSync(behaviorPath, { force: true }); + rmSync(replacementPath, { force: true }); + rmSync(expectationsPath, { force: true }); + assert.equal(result.signal, null); + assert.ok( + result.stdout.length > 0 && result.stdout.length < 2048, + `status=${result.status} stderr=${result.stderr}`, + ); + assert.equal(result.stdout.trim().split(/\r?\n/).length, 1); + const document = JSON.parse(result.stdout) as Record; + const expectedStderr = document.status === 'ready' + ? '' + : `ProPR Connect discovery: ${document.status}.\n`; + assert.equal(result.stderr, expectedStderr); + assert.ok(result.stderr.length < 128); + for (const sentinel of [ + 'connector-token-SENTINEL', + 'relay-token-SENTINEL', + 'github-token-SENTINEL', + credentialPath, + privateParent, + 'docker-private-output-SENTINEL', + 'raw-url-SENTINEL', + 'REPLACEMENT_BYTES_SENTINEL', + 'transport-SENTINEL', + 'ambient-stack-SENTINEL', + 'ambient-instance-SENTINEL', + 'INTERPOLATION_SECRET_PATH_SENTINEL', + ]) { + assert.equal(result.stdout.includes(sentinel), false, `stdout leaked ${sentinel}`); + assert.equal(result.stderr.includes(sentinel), false, `stderr leaked ${sentinel}`); + } + return { status: result.status, stdout: result.stdout, stderr: result.stderr, document }; +} + +test('the built CLI emits one bounded secret-free JSON document for every exit class', async () => { + const parent = mkdtempSync(join(tmpdir(), 'propr-built-connect-cli-')); + chmodSync(parent, 0o700); + const bin = installFakeDocker(parent); + const isolatedOsHome = join(parent, 'isolated-os-home'); + mkdirSync(isolatedOsHome, { mode: 0o700 }); + mkdirSync(join(parent, 'home-private-SENTINEL'), { mode: 0o700 }); + mkdirSync(join(parent, 'hostile-cwd'), { mode: 0o700 }); + const osConfigDir = join(userInfo().homedir, '.propr'); + const osConfigPath = join(osConfigDir, 'config.json'); + const osConfigDirBefore = snapshotPath(osConfigDir); + const osConfigBefore = osConfigDirBefore.kind === 'directory' + ? snapshotPath(osConfigPath) + : undefined; + writeFileSync(join(parent, 'hostile-cwd', '.env'), [ + 'PROPR_STACK=cwd-stack-SENTINEL', + 'PROPR_UI_PUBLIC_API_URL=https://t-cwd-SENTINEL.propr.dev', + 'HOST_DATA_DIR=${INTERPOLATION_SECRET_PATH_SENTINEL}', + ].join('\n'), { mode: 0o600 }); + try { + const readyRoot = makeRoot(parent, 'ready-private-root-SENTINEL'); + assert.equal(await getOrCreatePublicInstanceIdentity(join(readyRoot, 'data'), () => IDENTITY), IDENTITY); + const ready = invoke(readyRoot, 'ready', bin, parent); + assert.equal(ready.status, 0, JSON.stringify(ready.document)); + assert.equal(ready.document.status, 'ready'); + assert.equal(ready.document.canonicalEndpoint, ENDPOINT); + assert.equal( + existsSync(join(isolatedOsHome, '.propr')), + false, + 'an absent isolated OS config directory must not be created', + ); + + persistTunnelOverride(isolatedOsHome, readyRoot, false); + const persistedOff = invoke(readyRoot, 'ready', bin, parent); + assert.equal(persistedOff.status, 0); + assert.equal(persistedOff.document.enabled, false); + assert.deepEqual(persistedOff.document.reasonCodes, ['TUNNEL_DISABLED']); + + const envDisabledRoot = makeRoot(parent, 'env-disabled-root', ENDPOINT, { enabled: 'false' }); + assert.equal(await getOrCreatePublicInstanceIdentity(join(envDisabledRoot, 'data'), () => IDENTITY), IDENTITY); + persistTunnelOverride(isolatedOsHome, envDisabledRoot, true); + const persistedOn = invoke(envDisabledRoot, 'ready', bin, parent); + assert.equal(persistedOn.status, 0, JSON.stringify(persistedOn.document)); + assert.equal(persistedOn.document.status, 'ready'); + assert.equal(persistedOn.document.enabled, true); + + const dockerTransport = { + DOCKER_HOST: 'ssh://docker.example.test', + DOCKER_CONTEXT: 'trusted-context', + DOCKER_TLS_VERIFY: '1', + DOCKER_CERT_PATH: join(parent, 'private-cert-path-SENTINEL'), + DOCKER_CONFIG: join(parent, 'private-docker-config-SENTINEL'), + HOME: join(parent, 'docker-home-SENTINEL'), + SSH_AUTH_SOCK: join(parent, 'ssh-agent-SENTINEL'), + }; + const customDocker = invoke(readyRoot, 'ready', bin, parent, { + environment: dockerTransport, + dockerEnvironmentExpectations: dockerTransport, + }); + assert.equal(customDocker.status, 0); + const tlsOnlyTransport = { DOCKER_TLS: '1' }; + const tlsOnlyDocker = invoke(readyRoot, 'ready', bin, parent, { + environment: tlsOnlyTransport, + dockerEnvironmentExpectations: tlsOnlyTransport, + }); + assert.equal(tlsOnlyDocker.status, 0); + const unrelatedInventory = invoke(readyRoot, 'ready', bin, parent, { dockerBehavior: 'large-unrelated' }); + assert.equal(unrelatedInventory.status, 0); + for (const dockerBehavior of ['duplicate', 'unknown'] as const) { + const hostile = invoke(readyRoot, 'ready', bin, parent, { dockerBehavior }); + assert.equal(hostile.status, 1, dockerBehavior); + assert.deepEqual(hostile.document.reasonCodes, ['INTERNAL_FAILURE']); + } + const oversizedDocker = invoke(readyRoot, 'ready', bin, parent, { + environment: { DOCKER_HOST: 'x'.repeat(4097) }, + }); + assert.equal(oversizedDocker.status, 1); + assert.deepEqual(oversizedDocker.document.reasonCodes, ['INTERNAL_FAILURE']); + + persistTunnelOverride(join(parent, 'home-private-SENTINEL'), readyRoot, false); + const hostileAmbientHomeIgnored = invoke(readyRoot, 'ready', bin, parent); + assert.equal(hostileAmbientHomeIgnored.status, 0); + persistTunnelOverride(join(parent, 'home-private-SENTINEL'), readyRoot, true); + assert.equal(invoke(readyRoot, 'ready', bin, parent).status, 0); + + const tokenlessRoot = makeRoot(parent, 'tokenless-root', ENDPOINT, {}); + assert.equal(await getOrCreatePublicInstanceIdentity(join(tokenlessRoot, 'data'), () => IDENTITY), IDENTITY); + persistTunnelOverride(join(parent, 'home-private-SENTINEL'), tokenlessRoot, false); + const tokenlessOff = invoke(tokenlessRoot, 'ready', bin, parent); + assert.deepEqual(tokenlessOff.document.reasonCodes, ['TUNNEL_DISABLED']); + + const equalsRoot = invoke(readyRoot, 'ready', bin, parent, { + arguments: ['--project', 'owner/repo', 'connect', 'status', `--root=${readyRoot}`, '--json'], + }); + assert.equal(equalsRoot.status, 0); + assert.equal(equalsRoot.document.canonicalEndpoint, ENDPOINT); + + for (const dockerBehavior of ['absent', 'stopped'] as const) { + const notReady = invoke(readyRoot, 'ready', bin, parent, { dockerBehavior }); + assert.equal(notReady.status, 0, dockerBehavior); + assert.equal(notReady.document.status, 'notReady', dockerBehavior); + assert.deepEqual(notReady.document.reasonCodes, ['SIDECAR_NOT_RUNNING'], dockerBehavior); + } + + for (const [name, failureBin, dockerBehavior] of [ + ['ENOENT', join(parent, 'missing-docker-bin'), undefined], + ['daemon nonzero', bin, 'nonzero'], + ['timeout', bin, 'timeout'], + ['signal', bin, 'signal'], + ['malformed output', bin, 'malformed'], + ['truncated output', bin, 'truncated'], + ] as const) { + mkdirSync(failureBin, { recursive: true, mode: 0o700 }); + const failure = invoke(readyRoot, 'ready', failureBin, parent, { dockerBehavior }); + assert.equal(failure.status, 1, name); + assert.equal(failure.document.status, 'internalFailure', name); + assert.deepEqual(failure.document.reasonCodes, ['INTERNAL_FAILURE'], name); + } + + const unreachable = invoke(readyRoot, 'unreachable', bin, parent); + assert.equal(unreachable.status, 0); + assert.deepEqual(unreachable.document.reasonCodes, ['API_UNREACHABLE']); + + for (const mode of ['unsupported', 'invalid', 'invalid-utf8']) { + const incompatible = invoke(readyRoot, mode, bin, parent); + assert.equal(incompatible.status, 2, mode); + assert.equal(incompatible.document.status, 'incompatible'); + } + + const invalidEndpointRoot = makeRoot(parent, 'invalid-endpoint-root', `${ENDPOINT}/path`); + assert.equal(await getOrCreatePublicInstanceIdentity(join(invalidEndpointRoot, 'data'), () => IDENTITY), IDENTITY); + const invalidEndpoint = invoke(invalidEndpointRoot, 'ready', bin, parent); + assert.equal(invalidEndpoint.status, 1); + assert.deepEqual(invalidEndpoint.document.reasonCodes, ['INVALID_ENDPOINT']); + + const missingRoot = invoke(join(parent, 'missing-private-root'), 'ready', bin, parent); + assert.equal(missingRoot.status, 1, JSON.stringify(missingRoot.document)); + assert.deepEqual(missingRoot.document.reasonCodes, ['INVALID_ROOT']); + + let malformedRootDocument: Record | undefined; + for (const arguments_ of [ + ['connect', 'status', '--json'], + ['connect', 'status', '--json', '--root'], + ['connect', 'status', '--json', '--root='], + ['connect', 'status', '--json', '--root', ''], + ['connect', 'status', '--json', '--root', readyRoot, '--root', readyRoot], + ['connect', 'status', '--json', `--root=${readyRoot}`, `--root=${readyRoot}`], + ['connect', 'status', '--json', '--', '--root', '/x'], + ['connect', 'status', '--json', '--', '--root=/x'], + ['connect', 'status', '--json', '--', '--help'], + ['connect', 'status', '--json', '--', '-h'], + ]) { + const malformedRoot = invoke(readyRoot, 'ready', bin, parent, { arguments: arguments_ }); + assert.equal(malformedRoot.status, 1, arguments_.join(' ')); + assert.equal(malformedRoot.document.status, 'invalidConfig', arguments_.join(' ')); + assert.deepEqual(malformedRoot.document.reasonCodes, ['INVALID_ROOT'], arguments_.join(' ')); + assert.doesNotMatch(malformedRoot.stdout, /(?:^|\n)(?:Usage:|error:)/i, arguments_.join(' ')); + assert.doesNotMatch(malformedRoot.stderr, /(?:Usage:|error:)/i, arguments_.join(' ')); + malformedRootDocument ??= malformedRoot.document; + assert.deepEqual(malformedRoot.document, malformedRootDocument, arguments_.join(' ')); + } + + const timeout = invoke(readyRoot, 'timeout', bin, parent); + assert.equal(timeout.status, 0); + assert.equal(timeout.document.status, 'timeout'); + + const replacedRoot = makeRoot(parent, 'replaced-private-root'); + assert.equal(await getOrCreatePublicInstanceIdentity(join(replacedRoot, 'data'), () => IDENTITY), IDENTITY); + const replaced = invoke(replacedRoot, 'ready', bin, parent, { replaceRoot: true }); + assert.equal(replaced.status, 1); + assert.deepEqual(replaced.document.reasonCodes, ['INVALID_ROOT']); + + const copiedPackage = join(parent, 'copied-built-cli'); + cpSync(join(process.cwd(), 'packages', 'cli'), copiedPackage, { recursive: true }); + symlinkSync(join(process.cwd(), 'node_modules'), join(parent, 'node_modules'), 'dir'); + rmSync(join(copiedPackage, 'dist', 'orchestrator', 'manifest.json')); + const internal = invoke( + readyRoot, + 'ready', + bin, + parent, + { cli: join(copiedPackage, 'dist', 'index.js') }, + ); + assert.equal(internal.status, 1); + assert.equal(internal.document.status, 'internalFailure'); + } finally { + try { + assert.deepEqual(snapshotPath(osConfigDir), osConfigDirBefore, 'the actual OS config directory changed'); + if (osConfigBefore) { + assert.deepEqual(snapshotPath(osConfigPath), osConfigBefore, 'the actual OS config bytes or metadata changed'); + } + } finally { + rmSync(parent, { recursive: true, force: true }); + } + } +}); + +test('the built CLI rejects malformed Unix roots and reports unavailable Windows ACL diagnostics', async () => { + const parent = mkdtempSync(join(tmpdir(), 'propr-built-connect-root-')); + chmodSync(parent, 0o700); + const bin = installFakeDocker(parent); + mkdirSync(join(parent, 'isolated-os-home'), { mode: 0o700 }); + mkdirSync(join(parent, 'home-private-SENTINEL'), { mode: 0o700 }); + mkdirSync(join(parent, 'hostile-cwd'), { mode: 0o700 }); + writeFileSync(join(parent, 'hostile-cwd', '.env'), 'PROPR_STACK=cwd-stack-SENTINEL\n', { mode: 0o600 }); + try { + const root = makeRoot(parent, 'real-root'); + const alias = join(parent, 'root-alias'); + symlinkSync(root, alias, 'dir'); + const symlink = invoke(alias, 'ready', bin, parent); + assert.equal(symlink.status, 1); + assert.deepEqual(symlink.document.reasonCodes, ['INVALID_ROOT']); + + chmodSync(join(root, 'data'), 0o777); + const unsafe = invoke(root, 'ready', bin, parent); + assert.equal(unsafe.status, 1); + assert.deepEqual(unsafe.document.reasonCodes, ['INVALID_ROOT']); + + chmodSync(join(root, 'data'), 0o700); + assert.equal(await getOrCreatePublicInstanceIdentity(join(root, 'data'), () => IDENTITY), IDENTITY); + const windows = invoke(root, 'ready', bin, parent, { windowsSemantics: true }); + assert.equal(windows.status, 1); + assert.equal(windows.document.status, 'invalidConfig'); + assert.deepEqual(windows.document.reasonCodes, ['ACL_DIAGNOSTIC_UNAVAILABLE']); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); diff --git a/test/fixtures/connectFetchMock.mjs b/test/fixtures/connectFetchMock.mjs new file mode 100644 index 000000000..c71c4de5b --- /dev/null +++ b/test/fixtures/connectFetchMock.mjs @@ -0,0 +1,71 @@ +const realSetTimeout = globalThis.setTimeout; +if (process.env.PROPR_TEST_PLATFORM === 'win32') { + Object.defineProperty(process, 'platform', { value: 'win32' }); +} +globalThis.setTimeout = (callback, delay, ...args) => realSetTimeout( + callback, + delay === 5000 ? 20 : delay, + ...args, +); + +const endpoint = 'https://t-abc123.propr.dev'; +const identity = process.env.PROPR_TEST_PUBLIC_IDENTITY; +const discovery = { + schemaVersion: 1, + product: 'ProPR', + canonicalEndpoint: endpoint, + publicInstanceIdentity: identity, + version: '0.8.15', + apiCompatibility: '2026-06-27', + uiCompatibility: '2026-06-27', + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +const endless = (status, contentType = 'application/json') => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + }, +}), { status, headers: { 'content-type': contentType } }); + +globalThis.fetch = async () => { + switch (process.env.PROPR_TEST_DISCOVERY_MODE) { + case 'ready': + return new Response(JSON.stringify(discovery), { headers: { 'content-type': 'application/json' } }); + case 'restart-required': + return new Response(JSON.stringify({ ...discovery, canonicalEndpoint: null }), { + headers: { 'content-type': 'application/json' }, + }); + case 'identity-mismatch': + return new Response(JSON.stringify({ + ...discovery, + publicInstanceIdentity: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + }), { headers: { 'content-type': 'application/json' } }); + case 'oversized': + return new Response('{}', { + headers: { 'content-type': 'application/json', 'content-length': '9000' }, + }); + case 'invalid': + return new Response(JSON.stringify({ ...discovery, desktopAuthentication: {} }), { + headers: { 'content-type': 'application/json' }, + }); + case 'invalid-utf8': + return new Response(Uint8Array.from([0xc3, 0x28]), { + headers: { 'content-type': 'application/json' }, + }); + case 'unsupported': + return endless(404); + case 'unreachable': + throw new Error('transport-SENTINEL must remain private'); + case 'secret-sentinel': + throw new Error('connector-token-SENTINEL relay-token-SENTINEL private-path-SENTINEL'); + case 'timeout': + return endless(200); + default: + throw new Error('unexpected discovery fixture mode'); + } +}; diff --git a/test/fixtures/connectOsHomeMock.mjs b/test/fixtures/connectOsHomeMock.mjs new file mode 100644 index 000000000..1626bdbe3 --- /dev/null +++ b/test/fixtures/connectOsHomeMock.mjs @@ -0,0 +1,10 @@ +import os from 'node:os'; +import { syncBuiltinESMExports } from 'node:module'; + +const isolatedHome = process.env.PROPR_TEST_OS_HOME; +if (!isolatedHome) throw new Error('PROPR_TEST_OS_HOME is required'); +delete process.env.PROPR_TEST_OS_HOME; + +const realUserInfo = os.userInfo; +os.userInfo = (...args) => ({ ...realUserInfo(...args), homedir: isolatedHome }); +syncBuiltinESMExports(); diff --git a/test/fixtures/publicIdentityCreator.ts b/test/fixtures/publicIdentityCreator.ts new file mode 100644 index 000000000..47afff78f --- /dev/null +++ b/test/fixtures/publicIdentityCreator.ts @@ -0,0 +1,9 @@ +import { getOrCreatePublicInstanceIdentity as getCliIdentity } from '../../packages/cli/src/connectIdentity.js'; +import { getOrCreatePublicInstanceIdentity as getApiIdentity } from '../../packages/api/publicInstanceIdentity.js'; + +const [kind, data, identity] = process.argv.slice(2); +if ((kind !== 'cli' && kind !== 'api') || !data || !identity) process.exit(64); +const value = await (kind === 'cli' + ? getCliIdentity(data, () => identity) + : getApiIdentity(data, () => identity)); +process.stdout.write(`${value}\n`); diff --git a/test/fixtures/windowsConnectProcessMock.mjs b/test/fixtures/windowsConnectProcessMock.mjs new file mode 100644 index 000000000..a9405c0b9 --- /dev/null +++ b/test/fixtures/windowsConnectProcessMock.mjs @@ -0,0 +1,189 @@ +import childProcess from "node:child_process"; +import { fstatSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { join, resolve } from "node:path"; + +const WINDOWS_ROOT_MISSING_MARKER = "PROPR_TEST_WINDOWS_ROOT_MISSING"; +const WINDOWS_ROOT_MISSING_MARKER_VALUE = "windows-root-missing-v1"; + +function consumeMissingWindowsRootFixtureMarker(environment = process.env) { + const marker = Object.keys(environment).find((name) => name === WINDOWS_ROOT_MISSING_MARKER); + if (marker === undefined || environment[marker] !== WINDOWS_ROOT_MISSING_MARKER_VALUE) return; + delete environment[marker]; + for (const name of Object.keys(environment)) { + if (/^(?:systemroot|windir)$/i.test(name)) delete environment[name]; + } +} + +const WINDOWS_ROOT_UNTRUSTED_MARKER = "PROPR_TEST_WINDOWS_ROOT_UNTRUSTED"; +const WINDOWS_ROOT_UNTRUSTED_MARKER_VALUE = "windows-root-untrusted-v1"; +const WINDOWS_ROOT_UNTRUSTED_PATH = "PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH"; + +function consumeUntrustedWindowsRootFixtureMarker(environment = process.env, fixtureRoot = process.cwd()) { + const marker = Object.keys(environment).find((name) => name === WINDOWS_ROOT_UNTRUSTED_MARKER); + const rootPath = Object.keys(environment).find((name) => name === WINDOWS_ROOT_UNTRUSTED_PATH); + if ( + marker === undefined + || environment[marker] !== WINDOWS_ROOT_UNTRUSTED_MARKER_VALUE + || rootPath === undefined + || typeof environment[rootPath] !== "string" + || resolve(environment[rootPath]).toLowerCase() !== resolve(fixtureRoot).toLowerCase() + ) return; + const untrustedRoot = environment[rootPath]; + delete environment[marker]; + delete environment[rootPath]; + for (const name of Object.keys(environment)) { + if (/^(?:systemroot|windir)$/i.test(name)) delete environment[name]; + } + environment.SystemRoot = untrustedRoot; + environment.WINDIR = untrustedRoot; +} + +consumeMissingWindowsRootFixtureMarker(); +consumeUntrustedWindowsRootFixtureMarker(); + +const originalSpawnSync = childProcess.spawnSync; +const forbidden = /(?:connect-authority|ProPRConnectAuthority|pwsh|csc|msiexec)(?:\.exe)?$/i; +let abaPerformed = false; +const nativeStages = new Set([ + "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", + "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", + "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", + "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", + "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", +]); +globalThis[Symbol.for("propr.test.windowsNativeDiagnostic")] = (stage) => { + const fixed = nativeStages.has(stage) ? stage : "parent:json-shape"; + process.stderr.write(`[propr-windows-native-stage:${fixed}]\n`); +}; + +function authorityDocument(args, options, mode) { + const encodedIndex = args.indexOf("-EncodedCommand") + 1; + const source = Buffer.from(args[encodedIndex], "base64").toString("utf16le"); + const specs = [...source.matchAll(/index=(\d+);kind='(directory|file)';authorityKind='(ancestor|home|root|data|env)'/g)]; + const identities = [options.stdio[0]].map((fd) => { + const stat = fstatSync(fd, { bigint: true }); + return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; + }); + const userSid = "S-1-5-21-100-200-300-1001"; + const entries = specs.map((spec, index) => ({ + index: Number(spec[1]), + kind: spec[2], + authorityKind: spec[3], + currentUserSid: userSid, + ownerSid: userSid, + daclProtected: true, + reparsePoint: false, + volumeSerialNumber: identities[index].device, + fileId: identities[index].file, + verifiedVolumeSerialNumber: identities[index].device, + verifiedFileId: identities[index].file, + rules: [{ + identitySid: userSid, + inherited: false, + accessType: "allow", + appliesToSelf: true, + rights: "2032127", + }], + })); + const protectedEntry = entries.find((entry) => ["root", "data", "env"].includes(entry.authorityKind)); + if (mode === "descriptor-mismatch") { + entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); + entries[0].verifiedFileId = entries[0].fileId; + } else if (mode === "index-mismatch") entries[0].index += 1; + else if (mode === "kind-mismatch") entries[0].kind = entries[0].kind === "file" ? "directory" : "file"; + else if (mode === "authority-kind-mismatch") entries[0].authorityKind = entries[0].authorityKind === "root" ? "data" : "root"; + else if (mode === "identity-mismatch") { + entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); + } else if (mode === "sid-mismatch" && entries[0].index > 0) { + entries[0].currentUserSid = "S-1-5-21-100-200-300-1002"; + } else if (mode === "broad-write" && protectedEntry) { + protectedEntry.rules = [{ + identitySid: "S-1-1-0", inherited: false, accessType: "allow", appliesToSelf: true, rights: "2", + }]; + } else if (mode === "inherited-write" && protectedEntry) { + protectedEntry.rules[0].inherited = true; + } else if (mode === "unprotected" && protectedEntry) { + protectedEntry.daclProtected = false; + } else if (mode === "owner-mismatch" && protectedEntry) { + protectedEntry.ownerSid = "S-1-5-18"; + } else if (mode === "reparse" && protectedEntry) { + protectedEntry.reparsePoint = true; + } + return JSON.stringify({ version: 1, entries }); +} + +childProcess.spawnSync = (command, args, options) => { + const executable = String(command); + if (forbidden.test(executable)) throw new Error("forbidden Windows authority executable"); + if (/powershell\.exe$/i.test(executable)) { + const mode = process.env.PROPR_TEST_AUTHORITY_MODE; + const result = (status, stdout = "", stderr = "", error = undefined, signal = null) => ({ + status, signal, error, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr), + }); + if (mode === "malformed") return result(0, "{"); + if (mode === "oversized") return result(0, "x".repeat(128 * 1024 + 1)); + if (mode === "extra-key") return result(0, '{"version":1,"entries":[],"extra":true}'); + if (mode === "duplicate") return result(0, '{"version":1,"version":1,"entries":[]}'); + if (mode === "entry-count") return result(0, '{"version":1,"entries":[]}'); + if (mode === "entry-shape") { + const document = JSON.parse(authorityDocument(args, options, mode)); + document.entries[0].extra = true; + return result(0, JSON.stringify(document)); + } + if (mode === "stderr") return result(0, "{}", "private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL"); + if (mode === "nonzero") return result(70, "", ""); + if (mode === "timeout") { + return result(null, "", "", Object.assign(new Error("private-path-SENTINEL"), { code: "ETIMEDOUT" }), "SIGKILL"); + } + if (mode === "valid-authority") return result(0, authorityDocument(args, options, mode)); + if ([ + "descriptor-mismatch", "index-mismatch", "kind-mismatch", "authority-kind-mismatch", + "identity-mismatch", "sid-mismatch", "broad-write", "inherited-write", "unprotected", + "owner-mismatch", "reparse", + ].includes(mode)) return result(0, authorityDocument(args, options, mode)); + if (mode === "path-aba" && !abaPerformed) { + abaPerformed = true; + const envPath = join(process.env.PROPR_TEST_AUTHORITY_ROOT, ".env"); + const detached = `${envPath}-aba-detached`; + renameSync(envPath, detached); + writeFileSync(envPath, [ + "PROPR_STACK=attacker-replacement-SENTINEL", + "PROPR_INSTANCE_ID=attacker", + "PROPR_UI_PUBLIC_API_URL=https://t-attacker.propr.dev", + "PROPR_UI_TUNNEL_ENABLED=true", + "PROPR_UI_TUNNEL_TOKEN=attacker-replacement-SENTINEL", + "", + ].join("\n")); + process.once("exit", () => { + rmSync(envPath, { force: true }); + renameSync(detached, envPath); + }); + return originalSpawnSync(command, args, options); + } + return originalSpawnSync(command, args, options); + } + if (executable.toLowerCase() !== "docker") return originalSpawnSync(command, args, options); + const expected = [ + "ps", "-a", "--filter", "label=propr.stack=authorized", "--format", + "{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}", + ]; + if (JSON.stringify(args) !== JSON.stringify(expected)) { + return { status: 9, signal: null, error: undefined, stdout: "", stderr: "docker-argv-SENTINEL" }; + } + const stdout = process.env.PROPR_TEST_DOCKER_MODE === "down" + ? "" + : "authorized-tunnel\trunning\tUp 1 second\t\r\n"; + return { + status: 0, + signal: null, + error: undefined, + stdout, + stderr: "docker-secret-SENTINEL", + }; +}; +syncBuiltinESMExports(); diff --git a/test/nativeConnectAuthority.test.ts b/test/nativeConnectAuthority.test.ts new file mode 100644 index 000000000..225b2b693 --- /dev/null +++ b/test/nativeConnectAuthority.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + assertNativeEntryAuthority, + assertSafeDarwinAclOutput, + nativeConnectRootAuthorityInspector, + stableAuthorityIdentity, + type ConnectRootAuthorityInspector, +} from "../packages/cli/src/connectRootAuthority.js"; + +const EMPTY_ACL = "!#acl 1\n"; +const READ_ONLY_ACL = [ + "!#acl 1", + "user:AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE:reader:501:allow:read,readattr,readsecurity", + "", +].join("\n"); + +test("Darwin ACL contract accepts bounded empty and read-only documents", () => { + assert.doesNotThrow(() => assertSafeDarwinAclOutput("")); + assert.doesNotThrow(() => assertSafeDarwinAclOutput(EMPTY_ACL)); + assert.doesNotThrow(() => assertSafeDarwinAclOutput(READ_ONLY_ACL)); +}); + +test("Darwin ACL contract rejects mutation grants", () => { + const writable = [ + "!#acl 1", + "group:AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE:writers:20:allow:read,write", + "", + ].join("\n"); + assert.throws(() => assertSafeDarwinAclOutput(writable), /unexpected write authority/); +}); + +test("Darwin ACL contract rejects malformed and oversized output", () => { + for (const malformed of [ + "\n", + "user supplied path", + "!#acl 1 extra\n", + "!#acl 2\n", + "!#acl 1\nunknown\n", + `${"x".repeat(25 * 1024)}\n`, + ]) assert.throws(() => assertSafeDarwinAclOutput(malformed), /malformed/); +}); + +function withPinnedFile(run: (path: string, fd: number) => Promise): Promise { + const directory = mkdtempSync(join(tmpdir(), "propr-darwin-contract-")); + const path = join(directory, "entry"); + writeFileSync(path, "fixture"); + const fd = openSync(path, "r"); + return run(path, fd).finally(() => { + closeSync(fd); + rmSync(directory, { recursive: true, force: true }); + }); +} + +test("Darwin authority binds an inspection to the held descriptor identity", async () => { + await withPinnedFile(async (path, fd) => { + const identity = stableAuthorityIdentity(fd); + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: () => ({ version: 1, ...identity, acl: EMPTY_ACL }), + inspectWindowsAcl: async () => { throw new Error("unused"); }, + }; + await assert.doesNotReject(assertNativeEntryAuthority(inspector, "darwin", path, "env", fd)); + }); +}); + +test("Darwin authority rejects an inspection for another object", async () => { + await withPinnedFile(async (path, fd) => { + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: () => ({ version: 1, device: "0", file: "0", acl: EMPTY_ACL }), + inspectWindowsAcl: async () => { throw new Error("unused"); }, + }; + await assert.rejects( + assertNativeEntryAuthority(inspector, "darwin", path, "env", fd), + /did not match the pinned object/, + ); + }); +}); + +test("packaged Darwin broker inspects an ordinary held file without path re-resolution", { + skip: process.platform !== "darwin" ? "requires native Darwin ACL APIs" : false, +}, async () => { + await withPinnedFile(async (path, fd) => { + await assert.doesNotReject(assertNativeEntryAuthority( + nativeConnectRootAuthorityInspector, + "darwin", + path, + "env", + fd, + )); + }); +}); diff --git a/test/orchestratorConfig.test.mjs b/test/orchestratorConfig.test.mjs index ae2a42526..b1ce191d8 100644 --- a/test/orchestratorConfig.test.mjs +++ b/test/orchestratorConfig.test.mjs @@ -409,15 +409,13 @@ test('api container receives explicit request rate-limit overrides', () => { } }); -test('an explicit PROPR_UI_PUBLIC_API_URL is normalized (trailing slash stripped) once at resolve time', () => { +test('an alternate explicit Connect URL remains raw and fails validation', () => { const cfg = resolveConfig({ PROPR_UI_TUNNEL_TOKEN: 'secret-token', PROPR_UI_PUBLIC_API_URL: 'https://t-abc123.propr.dev/', }, { manifestPath }); - assert.equal(cfg.uiPublicApiUrl, 'https://t-abc123.propr.dev'); - // and every consumer sees the canonical (no trailing slash) form. - assert.deepEqual(envValues(buildServiceSpec(cfg, 'api').args, 'PROPR_UI_PUBLIC_API_URL'), ['https://t-abc123.propr.dev']); - assert.deepEqual(envValues(buildServiceSpec(cfg, 'ui').args, 'PROPR_UI_PUBLIC_API_URL'), ['https://t-abc123.propr.dev']); + assert.equal(cfg.uiPublicApiUrl, 'https://t-abc123.propr.dev/'); + assert.match(validateEnv(cfg).errors.join('\n'), /not a hosted proxy URL/); }); test('ui container receives the tunnel public API URL (no /api appended) when set', () => { diff --git a/test/orchestratorProprUrlsDrift.test.ts b/test/orchestratorProprUrlsDrift.test.ts index 78b74a001..550f91967 100644 --- a/test/orchestratorProprUrlsDrift.test.ts +++ b/test/orchestratorProprUrlsDrift.test.ts @@ -13,6 +13,8 @@ import { PROPR_UI_COMPATIBILITY, PROPR_UI_SUPPORTED_API_COMPATIBILITY, proprInstanceProxyUrl as sharedProxyUrl, + canonicalProprProxySelector, + canonicalProprProxyUrl as sharedCanonicalProxyUrl, isValidProprInstanceId as sharedIsValidId, isProprProxyUrl as sharedIsProxyUrl, proprTunnelEndpoints as sharedTunnelEndpoints, @@ -28,6 +30,7 @@ import { DEFAULT_CLOUDFLARED_IMAGE as LAUNCHER_CLOUDFLARED_IMAGE, DEFAULT_PROPR_UI_ORIGIN as LAUNCHER_PROPR_UI_ORIGIN, proprInstanceProxyUrl as launcherProxyUrl, + canonicalProprProxyUrl as launcherCanonicalProxyUrl, isValidProprInstanceId as launcherIsValidId, isProprProxyUrl as launcherIsProxyUrl, proprTunnelEndpoints as launcherTunnelEndpoints, @@ -42,7 +45,7 @@ describe('launcher hosted-UI constants stay in sync with @propr/shared', () => { }); test('proprInstanceProxyUrl agrees for valid, blank, and invalid ids', () => { - const cases = ['abc123', 'a', 'with-hyphen', '', ' ', null, undefined, 'bad id', 'has/slash', 'under_score', 'has.dot', '-leading', 'trailing-']; + const cases = ['abc123', 'a', 'with-hyphen', 't-Abc', 'T-Abc', '', ' ', null, undefined, 'bad id', 'has/slash', 'under_score', 'has.dot', '-leading', 'trailing-']; for (const id of cases) { assert.equal( launcherProxyUrl(id as string | undefined), @@ -52,6 +55,11 @@ describe('launcher hosted-UI constants stay in sync with @propr/shared', () => { } }); + test('mixed-case existing prefixes are removed before adding the canonical prefix', () => { + assert.equal(sharedProxyUrl('T-Abc'), 'https://t-abc.propr.dev'); + assert.equal(launcherProxyUrl('T-Abc'), 'https://t-abc.propr.dev'); + }); + test('isValidProprInstanceId agrees across the same cases', () => { const cases = ['abc123', 'a', 'with-hyphen', '', ' ', 'bad id', 'has/slash', 'under_score', 'has.dot', '-leading', 'trailing-', 'A'.repeat(63), 'A'.repeat(64)]; for (const id of cases) { @@ -67,29 +75,83 @@ describe('launcher hosted-UI constants stay in sync with @propr/shared', () => { const cases = [ 'https://t-abc123.propr.dev', 'https://t-abc123.propr.dev/', + ' https://t-abc123.propr.dev', + 'https://t-abc123.propr.dev ', + 'https://t-abc123.propr.dev//', + 'HTTPS://t-abc123.propr.dev', + 'https://T-abc123.propr.dev', 'https://app.propr.dev', 'http://t-abc123.propr.dev', 'https://abc123.example.com', 'https://propr.dev', 'https://t-foo.bar.propr.dev', + 'https://x.t-abc123.propr.dev', + 'https://nested.t-abc123.propr.dev', 'https://t-.propr.dev', 'https://t-abc123.propr.dev/api', 'https://t-abc123.propr.dev?x=1', 'https://t-abc123.propr.dev/#frag', + 'https://user:secret@t-abc123.propr.dev', + 'https://t-abc123.propr.dev:443', + 'https://t-abc123.propr.dev:8443', + 'https://t-%61bc123.propr.dev', + 'https://t-abc123.propr%2edev', + 'https://t-abc123.propr.dev.example.com', + 'https://t-abc123.pr\u03bfpr.dev', 'not a url', '', null, undefined, ]; for (const url of cases) { + const expected = url === 'https://t-abc123.propr.dev'; + assert.equal(sharedIsProxyUrl(url as string | undefined), expected); assert.equal( launcherIsProxyUrl(url as string | undefined), - sharedIsProxyUrl(url as string | undefined), + expected, `isProprProxyUrl diverged for ${JSON.stringify(url)}`, ); } }); + test('canonical proxy parsing agrees and rejects authority lookalikes', () => { + const cases = [ + 'https://t-abc123.propr.dev', + 'https://t-abc123.propr.dev/', + 'https://T-AbC123.ProPR.dev/', + 'HTTPS://t-abc123.propr.dev', + ' https://t-abc123.propr.dev', + 'https://user@t-abc123.propr.dev', + 'https://t-abc123.propr.dev:443', + 'https://t-abc123.propr.dev.', + 'https://t-abc123.propr.dev//', + 'https://t-аbc.propr.dev', + `https://t-${'a'.repeat(62)}.propr.dev`, + ]; + for (const url of cases) { + assert.equal(launcherCanonicalProxyUrl(url), sharedCanonicalProxyUrl(url)); + } + }); + + test('scheme-less Connect selectors accept only one canonical host spelling', () => { + assert.equal(canonicalProprProxySelector('t-abc123.propr.dev'), 't-abc123.propr.dev'); + assert.equal(canonicalProprProxySelector('T-AbC123.ProPR.dev'), undefined); + for (const selector of [ + 'abc123', + 'https://t-abc123.propr.dev', + 'user@t-abc123.propr.dev', + 't-abc123.propr.dev:443', + 't-abc123.propr.dev/', + 't-abc123.propr.dev?x=1', + 't-abc123.propr.dev#x', + ' t-abc123.propr.dev', + 't-abc123%2epropr.dev', + 't-abc123.propr.dev.', + 't-a.b.propr.dev', + 't-аbc.propr.dev', + ]) assert.equal(canonicalProprProxySelector(selector), undefined, selector); + }); + test('proprTunnelEndpoints agrees, including trailing-slash normalization', () => { const cases = ['https://t-abc123.propr.dev', 'https://t-abc123.propr.dev/', 'https://t-abc123.propr.dev///']; for (const url of cases) { diff --git a/test/publicInstanceIdentity.test.ts b/test/publicInstanceIdentity.test.ts new file mode 100644 index 000000000..a8d3e7a3b --- /dev/null +++ b/test/publicInstanceIdentity.test.ts @@ -0,0 +1,1047 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + appendFileSync, + chmodSync, + closeSync, + constants, + existsSync, + linkSync, + lstatSync, + mkdtempSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { + ConnectRootError, + getOrCreatePublicInstanceIdentity as getCliIdentity, + getOrCreateSnapshotPublicInstanceIdentity, + readSnapshotPublicInstanceIdentity, + readTrustedConnectTunnelOverride, + TrustedConnectConfigError, + withOwnedConnectRootSnapshot, +} from '../packages/cli/src/connectIdentity.js'; +import { getOrCreatePublicInstanceIdentity as getApiIdentity } from '../packages/api/publicInstanceIdentity.js'; +import { + PUBLIC_IDENTITY_DIRECTORY_MODE, + PUBLIC_IDENTITY_FILE_MODE, + getOrCreatePublicInstanceIdentity, + publicIdentityFilePermissionsAllowed, + samePublicFileIdentity, + type PublicIdentityBoundary, +} from '../packages/local-setup/src/publicInstanceIdentity.js'; +import { PUBLIC_INSTANCE_IDENTITY_FILENAME } from '@propr/shared'; +import { + assertNativeWindowsEntriesAuthority, + assertSafeDarwinAclOutput, + assertSafeWindowsAuthority, + stableAuthorityIdentity, + type ConnectRootAuthorityInspector, + type WindowsAuthorityInspection, +} from '../packages/cli/src/connectRootAuthority.js'; +import { setNativeDirectoryOpenTestHook } from '../packages/cli/src/utils/directoryDescriptor.js'; + +afterEach(() => setNativeDirectoryOpenTestHook()); + +const IDS = { + first: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + second: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + third: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + fourth: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', +} as const; + +test('exact identity comparison does not collapse adjacent values above Number.MAX_SAFE_INTEGER', () => { + assert.equal(samePublicFileIdentity( + { device: '7', file: '9007199254740992' }, + { device: '7', file: '9007199254740993' }, + ), false); + assert.equal(samePublicFileIdentity( + { device: '18446744073709551614', file: '18446744073709551615' }, + { device: '18446744073709551614', file: '18446744073709551615' }, + ), true); + assert.throws(() => samePublicFileIdentity( + { device: '7', file: '09007199254740992' }, + { device: '7', file: '9007199254740992' }, + ), /canonical/); +}); + +function temporaryRoot(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +function privateDirectory(path: string): void { + mkdirSync(path, { recursive: true, mode: PUBLIC_IDENTITY_DIRECTORY_MODE }); + chmodSync(path, PUBLIC_IDENTITY_DIRECTORY_MODE); +} + +function connectRoot(parent: string, env = 'PROPR_INSTANCE_ID=abc123\n'): string { + const root = join(parent, 'stack'); + privateDirectory(join(root, 'data')); + writeFileSync(join(root, '.env'), env, { mode: 0o600 }); + chmodSync(join(root, '.env'), 0o600); + return root; +} + +function identityPath(data: string): string { + return join(data, PUBLIC_INSTANCE_IDENTITY_FILENAME); +} + +test('public identity persists across CLI/API restart and changes with replaced stack data', async () => { + const root = temporaryRoot('propr-public-identity-'); + const data = join(root, 'data'); + privateDirectory(data); + try { + const first = await getCliIdentity(data, () => IDS.first); + assert.equal(await getApiIdentity(data, () => IDS.second), first); + assert.equal(await getCliIdentity(data, () => IDS.third), first); + + rmSync(data, { recursive: true }); + privateDirectory(data); + const replacement = await getApiIdentity(data, () => IDS.fourth); + assert.notEqual(replacement, first); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function runCreator(kind: 'cli' | 'api', data: string, id: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + '--import', + 'tsx', + 'test/fixtures/publicIdentityCreator.ts', + kind, + data, + id, + ], { cwd: process.cwd(), shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`creator exited ${code}: ${stderr}`)); + }); + }); +} + +test('concurrent CLI and API creators publish one complete durable winner', async () => { + const root = temporaryRoot('propr-public-identity-concurrent-'); + const data = join(root, 'data'); + privateDirectory(data); + try { + const [cli, api] = await Promise.all([ + runCreator('cli', data, IDS.first), + runCreator('api', data, IDS.second), + ]); + assert.equal(cli, api); + assert.ok(cli === IDS.first || cli === IDS.second); + assert.equal(await getCliIdentity(data, () => IDS.third), cli); + const bytes = readFileSync(identityPath(data), 'utf8'); + assert.ok(bytes.length > 0); + assert.equal(JSON.parse(bytes).publicInstanceIdentity, cli); + assert.equal(lstatSync(identityPath(data)).nlink, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const boundary of [ + 'temporary-opened', + 'temporary-written', + 'temporary-synced', + 'recovery-published', + 'identity-published', + 'directory-synced', +] as const satisfies readonly PublicIdentityBoundary[]) { + test(`identity restart is durable after interruption at ${boundary}`, async () => { + const root = temporaryRoot(`propr-public-identity-${boundary}-`); + const data = join(root, 'data'); + privateDirectory(data); + let interrupted = false; + try { + await assert.rejects(getOrCreatePublicInstanceIdentity(data, { + generate: () => IDS.first, + role: 'host', + onBoundary: (current) => { + if (!interrupted && current === boundary) { + interrupted = true; + throw new Error('simulated interruption'); + } + }, + }), /simulated interruption/); + const winner = await getApiIdentity(data, () => IDS.second); + assert.ok(winner === IDS.first || winner === IDS.second); + assert.equal(await getCliIdentity(data, () => IDS.third), winner); + assert.ok(lstatSync(identityPath(data)).size > 0); + assert.equal(lstatSync(identityPath(data)).nlink, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('creation modes are independent of umask', async () => { + const root = temporaryRoot('propr-public-identity-umask-'); + const data = join(root, 'data'); + const previous = process.umask(0); + try { + assert.equal(await getCliIdentity(data, () => IDS.first), IDS.first); + assert.equal(lstatSync(data).mode & 0o777, PUBLIC_IDENTITY_DIRECTORY_MODE); + assert.equal(lstatSync(identityPath(data)).mode & 0o777, PUBLIC_IDENTITY_FILE_MODE); + } finally { + process.umask(previous); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('identity storage rejects the filesystem root before creating state', async () => { + await assert.rejects(getApiIdentity('/', () => IDS.first), /filesystem root/); +}); + +test('identity storage rejects replaceable directories, symlinks, hardlinks, and unsafe modes', async () => { + const root = temporaryRoot('propr-public-identity-malicious-'); + try { + const unsafe = join(root, 'unsafe'); + mkdirSync(unsafe, { mode: 0o777 }); + chmodSync(unsafe, 0o777); + await assert.rejects(getCliIdentity(unsafe), /identity/); + + const real = join(root, 'real'); + privateDirectory(real); + const alias = join(root, 'alias'); + symlinkSync(real, alias, 'dir'); + await assert.rejects(getCliIdentity(alias), /identity/); + + assert.equal(await getCliIdentity(real, () => IDS.first), IDS.first); + chmodSync(identityPath(real), 0o666); + await assert.rejects(getApiIdentity(real), /permissions/); + chmodSync(identityPath(real), PUBLIC_IDENTITY_FILE_MODE); + linkSync(identityPath(real), join(real, 'identity-hardlink')); + await assert.rejects(getApiIdentity(real), /single-link/); + + const special = join(root, 'special'); + privateDirectory(special); + mkdirSync(identityPath(special), { mode: 0o700 }); + await assert.rejects(getApiIdentity(special), /regular file|identity file/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('identity repairs only the exact recovery/final same-inode crash remnant', async () => { + const root = temporaryRoot('propr-public-identity-link-crash-'); + const data = join(root, 'data'); + privateDirectory(data); + const recovery = join(data, `.${PUBLIC_INSTANCE_IDENTITY_FILENAME}.ready-v1`); + try { + assert.equal(await getCliIdentity(data, () => IDS.first), IDS.first); + linkSync(identityPath(data), recovery); + assert.equal(lstatSync(identityPath(data)).nlink, 2); + assert.equal(await getApiIdentity(data, () => IDS.second), IDS.first); + assert.equal(lstatSync(identityPath(data)).nlink, 1); + assert.throws(() => lstatSync(recovery), /ENOENT/); + + linkSync(identityPath(data), join(data, 'hostile-unknown-hardlink')); + await assert.rejects(getApiIdentity(data), /identity|single-link/); + assert.equal(lstatSync(identityPath(data)).nlink, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('identity restart repairs interruption between temporary-to-READY link and unlink', { + skip: process.platform !== 'linux', +}, async () => { + const root = temporaryRoot('propr-public-identity-temporary-link-crash-'); + const data = join(root, 'data'); + privateDirectory(data); + const temporary = join( + data, + `.${PUBLIC_INSTANCE_IDENTITY_FILENAME}.creating-v1-123-${IDS.first}`, + ); + const recovery = join(data, `.${PUBLIC_INSTANCE_IDENTITY_FILENAME}.ready-v1`); + try { + writeFileSync(temporary, `${JSON.stringify({ + schemaVersion: 1, + publicInstanceIdentity: IDS.second, + })}\n`, { mode: PUBLIC_IDENTITY_FILE_MODE }); + chmodSync(temporary, PUBLIC_IDENTITY_FILE_MODE); + linkSync(temporary, recovery); + assert.equal(lstatSync(temporary).nlink, 2); + + assert.equal(await getApiIdentity(data, () => IDS.third), IDS.second); + assert.equal(lstatSync(identityPath(data)).nlink, 1); + assert.throws(() => lstatSync(temporary), /ENOENT/); + assert.throws(() => lstatSync(recovery), /ENOENT/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('identity bounded reads reject growth and named replacement after the initial stat', async () => { + const root = temporaryRoot('propr-public-identity-read-race-'); + const data = join(root, 'data'); + privateDirectory(data); + try { + assert.equal(await getCliIdentity(data, () => IDS.first), IDS.first); + let grew = false; + await assert.rejects(getOrCreatePublicInstanceIdentity(data, { + role: 'host', + onBoundary: (boundary) => { + if (boundary === 'identity-read-statted' && !grew) { + grew = true; + appendFileSync(identityPath(data), 'growth'); + } + }, + }), /changed|size|identity/); + + writeFileSync(identityPath(data), `${JSON.stringify({ + schemaVersion: 1, + publicInstanceIdentity: IDS.first, + })}\n`, { mode: PUBLIC_IDENTITY_FILE_MODE }); + let replaced = false; + await assert.rejects(getOrCreatePublicInstanceIdentity(data, { + role: 'host', + onBoundary: (boundary) => { + if (boundary !== 'identity-read-statted' || replaced) return; + replaced = true; + renameSync(identityPath(data), join(data, 'detached-identity')); + writeFileSync(identityPath(data), `${JSON.stringify({ + schemaVersion: 1, + publicInstanceIdentity: IDS.second, + })}\n`, { mode: PUBLIC_IDENTITY_FILE_MODE }); + }, + }), /changed|identity|ENOENT/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('the cross-container model accepts a host-readable root-owned file only', () => { + const hostOwner = 1000; + assert.equal(publicIdentityFilePermissionsAllowed({ uid: 0, mode: 0o100644 }, hostOwner, 'linux'), true); + assert.equal(publicIdentityFilePermissionsAllowed({ uid: 0, mode: 0o100600 }, hostOwner, 'linux'), false); + assert.equal(publicIdentityFilePermissionsAllowed({ uid: hostOwner, mode: 0o100644 }, hostOwner, 'linux'), true); + assert.equal(publicIdentityFilePermissionsAllowed({ uid: hostOwner, mode: 0o100600 }, hostOwner, 'linux'), false); + assert.equal(publicIdentityFilePermissionsAllowed({ uid: 2000, mode: 0o100644 }, hostOwner, 'linux'), false); + assert.equal(publicIdentityFilePermissionsAllowed({ uid: 0, mode: 0o100666 }, hostOwner, 'linux'), false); +}); + +test('status identity reads neither create nor repair snapshot state', async () => { + const parent = temporaryRoot('propr-connect-read-only-identity-'); + const root = connectRoot(parent); + try { + await withOwnedConnectRootSnapshot(root, async (snapshot) => { + await assert.rejects(readSnapshotPublicInstanceIdentity(snapshot.identityDirectory)); + assert.throws(() => lstatSync(identityPath(join(root, 'data'))), /ENOENT/); + assert.equal(await getOrCreateSnapshotPublicInstanceIdentity(snapshot.identityDirectory, () => IDS.first), IDS.first); + assert.equal(await readSnapshotPublicInstanceIdentity(snapshot.identityDirectory), IDS.first); + }, { parseEnvFile: () => ({}) }); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Connect root replacement never redirects env/data reads and fails closed', async () => { + const parent = temporaryRoot('propr-connect-root-race-'); + const root = connectRoot(parent, 'ORIGINAL=value\n'); + const detached = join(parent, 'detached'); + let parsedBytes = ''; + try { + await assert.rejects(withOwnedConnectRootSnapshot(root, async (snapshot) => { + assert.equal(snapshot.envFileValues.ORIGINAL, 'value'); + assert.equal(await getOrCreateSnapshotPublicInstanceIdentity(snapshot.identityDirectory, () => IDS.first), IDS.first); + }, { + parseEnvFile: (contents) => { + parsedBytes = contents; + return { ORIGINAL: 'value' }; + }, + onBoundary: (boundary) => { + if (boundary !== 'acquired') return; + renameSync(root, detached); + connectRoot(parent, 'REPLACEMENT_SENTINEL=never-read\n'); + }, + }), ConnectRootError); + assert.equal(parsedBytes, 'ORIGINAL=value\n'); + assert.equal(parsedBytes.includes('REPLACEMENT_SENTINEL'), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Linux authority walk accepts the pinned read-only fallback after consecutive EINVAL opens', { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, +}, async () => { + const parent = temporaryRoot('propr-connect-authority-fallback-'); + const root = connectRoot(parent); + let fallbackOpens = 0; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory !== root) return; + if (phase === 'before-primary-open') { + throw Object.assign(new Error('injected strict-open failure'), { code: 'EINVAL' }); + } + if (phase === 'before-directory-fallback-open') { + throw Object.assign(new Error('injected directory-open failure'), { code: 'EINVAL' }); + } + if (phase === 'after-fallback-open') fallbackOpens += 1; + }, true); + await withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile: () => ({}) }); + assert.equal(fallbackOpens, 2, 'initial and final authority walks both use the pinned fallback'); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Linux authority walk fallback rejects named-directory replacement', { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, +}, async () => { + const parent = temporaryRoot('propr-connect-authority-replacement-'); + const root = connectRoot(parent); + const detached = join(parent, 'detached'); + let replaced = false; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory !== root) return; + if (phase === 'before-primary-open') { + throw Object.assign(new Error('injected strict-open failure'), { code: 'EINVAL' }); + } + if (phase === 'before-directory-fallback-open') { + throw Object.assign(new Error('injected directory-open failure'), { code: 'EINVAL' }); + } + if (phase === 'after-fallback-open' && !replaced) { + replaced = true; + renameSync(root, detached); + connectRoot(parent, 'REPLACEMENT_SENTINEL=never-read\n'); + } + }, true); + await assert.rejects( + withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile: () => ({}) }), + ConnectRootError, + ); + assert.equal(replaced, true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Linux authority walk fallback rejects a symlink substituted after open', { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, +}, async () => { + const parent = temporaryRoot('propr-connect-authority-symlink-'); + const root = connectRoot(parent); + const detached = join(parent, 'detached'); + let replaced = false; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory !== root) return; + if (phase === 'before-primary-open') { + throw Object.assign(new Error('injected strict-open failure'), { code: 'EINVAL' }); + } + if (phase === 'before-directory-fallback-open') { + throw Object.assign(new Error('injected directory-open failure'), { code: 'EINVAL' }); + } + if (phase === 'after-fallback-open' && !replaced) { + replaced = true; + renameSync(root, detached); + symlinkSync(detached, root, 'dir'); + } + }, true); + await assert.rejects( + withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile: () => ({}) }), + ConnectRootError, + ); + assert.equal(replaced, true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Linux authority walk never reaches the read-only fallback after a non-EINVAL directory-open failure', { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, +}, async () => { + const parent = temporaryRoot('propr-connect-authority-non-einval-'); + const root = connectRoot(parent); + let fallbackObserved = false; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory !== root) return; + if (phase === 'before-primary-open') { + throw Object.assign(new Error('injected strict-open failure'), { code: 'EINVAL' }); + } + if (phase === 'before-directory-fallback-open') { + throw Object.assign(new Error('injected denied directory open'), { code: 'EACCES' }); + } + if (phase === 'before-readonly-fallback-open') fallbackObserved = true; + }, true); + await assert.rejects( + withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile: () => ({}) }), + ConnectRootError, + ); + assert.equal(fallbackObserved, false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +type AuthorityChildCallsite = 'trusted-home .propr' | 'existing identity data' | 'new identity data'; + +function authorityChildFixture(callsite: AuthorityChildCallsite): { + parent: string; + target: string; + run: () => Promise; +} { + const parent = temporaryRoot(`propr-connect-child-authority-${callsite.replaceAll(' ', '-')}-`); + if (callsite === 'trusted-home .propr') { + const home = join(parent, 'home'); + const target = join(home, '.propr'); + privateDirectory(target); + writeFileSync(join(target, 'config.json'), JSON.stringify({ + tunnelEnabledByRoot: { '/trusted/stack': false }, + }), { mode: 0o600 }); + chmodSync(join(target, 'config.json'), 0o600); + return { + parent, + target, + run: () => readTrustedConnectTunnelOverride('/trusted/stack', { trustedHome: home }), + }; + } + if (callsite === 'existing identity data') { + const root = connectRoot(parent); + return { + parent, + target: join(root, 'data'), + run: () => withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile: () => ({}) }), + }; + } + const target = join(parent, 'data'); + return { + parent, + target, + run: () => getCliIdentity(target, () => IDS.first), + }; +} + +for (const callsite of [ + 'trusted-home .propr', + 'existing identity data', + 'new identity data', +] as const satisfies readonly AuthorityChildCallsite[]) { + test(`Linux ${callsite} authority child is opened through the full pinned fallback`, { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, + }, async () => { + for (const scenario of [ + 'second-stage EINVAL', + 'replacement', + 'symlink', + 'non-directory', + 'non-EINVAL', + ] as const) { + const fixture = authorityChildFixture(callsite); + const detached = `${fixture.target}.detached`; + let targetOpen = 0; + let directoryFallbackObserved = false; + let readOnlyFallbackObserved = false; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory !== fixture.target) return; + if (phase === 'before-primary-open') { + targetOpen += 1; + if (targetOpen !== 1) return; + if (scenario === 'non-EINVAL') { + throw Object.assign(new Error('injected denied child open'), { code: 'EACCES' }); + } + if (scenario === 'non-directory') { + renameSync(fixture.target, detached); + writeFileSync(fixture.target, 'not a directory\n', { mode: 0o600 }); + } + throw Object.assign(new Error('injected strict child-open failure'), { code: 'EINVAL' }); + } + if (targetOpen !== 1) return; + if (phase === 'before-directory-fallback-open') { + directoryFallbackObserved = true; + throw Object.assign(new Error('injected directory child-open failure'), { code: 'EINVAL' }); + } + if (phase === 'before-readonly-fallback-open') readOnlyFallbackObserved = true; + if (phase === 'after-fallback-open' && (scenario === 'replacement' || scenario === 'symlink')) { + renameSync(fixture.target, detached); + if (scenario === 'replacement') privateDirectory(fixture.target); + else symlinkSync(detached, fixture.target, 'dir'); + } + }, true); + + if (scenario === 'second-stage EINVAL') { + await assert.doesNotReject(fixture.run(), `${callsite}: ${scenario}`); + assert.equal(directoryFallbackObserved, true, `${callsite}: ${scenario}`); + assert.equal(readOnlyFallbackObserved, true, `${callsite}: ${scenario}`); + } else { + await assert.rejects(fixture.run(), undefined, `${callsite}: ${scenario}`); + if (scenario === 'non-EINVAL') { + assert.equal(directoryFallbackObserved, false, `${callsite}: ${scenario}`); + assert.equal(readOnlyFallbackObserved, false, `${callsite}: ${scenario}`); + } + } + } finally { + setNativeDirectoryOpenTestHook(); + rmSync(fixture.parent, { recursive: true, force: true }); + } + } + }); +} + +test('trusted-home absence is translated only after the authority helper returns final ENOENT', { + skip: process.platform !== 'linux' ? 'requires Linux descriptor-relative child opens' : false, +}, async () => { + const parent = temporaryRoot('propr-connect-child-authority-absent-'); + const home = join(parent, 'home'); + const target = join(home, '.propr'); + privateDirectory(home); + let strictFailures = 0; + try { + setNativeDirectoryOpenTestHook((phase, directory) => { + if (directory === target && phase === 'before-primary-open') { + strictFailures += 1; + throw Object.assign(new Error('injected strict child-open failure'), { code: 'EINVAL' }); + } + }, true); + assert.equal(await readTrustedConnectTunnelOverride('/trusted/stack', { trustedHome: home }), undefined); + assert.equal(strictFailures, 1); + assert.equal(existsSync(target), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Connect data replacement before identity access never reads the replacement winner', async () => { + const parent = temporaryRoot('propr-connect-data-race-'); + const root = connectRoot(parent); + const data = join(root, 'data'); + const detachedData = join(root, 'data-detached'); + let observedIdentity = ''; + try { + await assert.rejects(withOwnedConnectRootSnapshot(root, async (snapshot) => { + observedIdentity = await getOrCreateSnapshotPublicInstanceIdentity(snapshot.identityDirectory, () => IDS.first); + }, { + parseEnvFile: () => ({}), + onBoundary: (boundary) => { + if (boundary !== 'env-read') return; + renameSync(data, detachedData); + privateDirectory(data); + writeFileSync(identityPath(data), `${JSON.stringify({ + schemaVersion: 1, + publicInstanceIdentity: IDS.second, + })}\n`, { mode: PUBLIC_IDENTITY_FILE_MODE }); + }, + }), ConnectRootError); + assert.equal(observedIdentity, IDS.first); + assert.notEqual(observedIdentity, IDS.second); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Connect root authority rejects symlinks, unsafe modes, and Windows pathname simulation', async () => { + const parent = temporaryRoot('propr-connect-root-validation-'); + const root = connectRoot(parent); + const alias = join(parent, 'stack-alias'); + symlinkSync(root, alias, 'dir'); + const parseEnvFile = () => ({}); + try { + await assert.rejects(withOwnedConnectRootSnapshot(undefined, () => undefined, { parseEnvFile }), ConnectRootError); + await assert.rejects(withOwnedConnectRootSnapshot(alias, () => undefined, { parseEnvFile }), ConnectRootError); + await assert.rejects( + withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile, platform: 'win32' }), + ConnectRootError, + ); + chmodSync(join(root, 'data'), 0o777); + await assert.rejects(withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile }), ConnectRootError); + chmodSync(join(root, 'data'), 0o700); + chmodSync(parent, 0o777); + await assert.rejects(withOwnedConnectRootSnapshot(root, () => undefined, { parseEnvFile }), ConnectRootError); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +const WINDOWS_USER_SID = 'S-1-5-21-1000-1000-1000-1001'; +const safeWindowsAuthority = ( + identity = { device: '1', file: '1' }, + kind: 'ancestor' | 'home' | 'root' | 'data' | 'env' = 'root', + index = 0, +): WindowsAuthorityInspection => ({ + index, + kind: kind === 'env' ? 'file' : 'directory', + authorityKind: kind, + currentUserSid: WINDOWS_USER_SID, + ownerSid: WINDOWS_USER_SID, + daclProtected: true, + reparsePoint: false, + volumeSerialNumber: identity.device, + fileId: identity.file, + verifiedVolumeSerialNumber: identity.device, + verifiedFileId: identity.file, + rules: [ + { identitySid: WINDOWS_USER_SID, inherited: false, accessType: 'allow', appliesToSelf: true, rights: '2032127' }, + { identitySid: 'S-1-5-18', inherited: false, accessType: 'allow', appliesToSelf: true, rights: '2032127' }, + { identitySid: 'S-1-5-32-544', inherited: false, accessType: 'allow', appliesToSelf: true, rights: '2032127' }, + { identitySid: 'S-1-1-0', inherited: true, accessType: 'allow', appliesToSelf: true, rights: '1179785' }, + ], +}); + +test('Windows DACL policy accepts only explicit narrow mutators and rejects inherited or broad writes', () => { + assert.doesNotThrow(() => assertSafeWindowsAuthority(safeWindowsAuthority(), 'root')); + for (const rule of [ + { identitySid: WINDOWS_USER_SID, inherited: true, accessType: 'allow' as const, appliesToSelf: true, rights: '2' }, + { identitySid: 'S-1-1-0', inherited: false, accessType: 'allow' as const, appliesToSelf: true, rights: '2' }, + { identitySid: 'S-1-5-11', inherited: false, accessType: 'allow' as const, appliesToSelf: true, rights: '268435456' }, + ]) { + assert.throws(() => assertSafeWindowsAuthority({ + ...safeWindowsAuthority(), + rules: [rule], + }, 'root'), /authority|grant/); + } + assert.throws(() => assertSafeWindowsAuthority({ + ...safeWindowsAuthority(), + ownerSid: 'S-1-5-18', + }, 'root'), /authority/); + assert.throws(() => assertSafeWindowsAuthority({ + ...safeWindowsAuthority(), + daclProtected: false, + }, 'data'), /authority/); + assert.throws(() => assertSafeWindowsAuthority({ + ...safeWindowsAuthority(), + reparsePoint: true, + }, 'root'), /authority/); +}); + +test('Windows batch binding keeps descriptor identities, indexes, and types exact', async () => { + const parent = temporaryRoot('propr-windows-full-identity-'); + const firstPath = join(parent, 'first'); + const secondPath = join(parent, 'second'); + writeFileSync(firstPath, 'A', { mode: 0o600 }); + writeFileSync(secondPath, 'B', { mode: 0o600 }); + const firstFd = openSync(firstPath, constants.O_RDONLY); + const secondFd = openSync(secondPath, constants.O_RDONLY); + const entries = [ + { path: firstPath, kind: 'env' as const, pinnedFd: firstFd }, + { path: secondPath, kind: 'env' as const, pinnedFd: secondFd }, + ]; + const firstIdentity = stableAuthorityIdentity(firstFd); + const secondIdentity = stableAuthorityIdentity(secondFd); + const exactInspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: (_path, _fd, identity) => ({ version: 1, ...identity, acl: '!#acl 1\n' }), + inspectWindowsAcl: async (_path, identity, _fd, kind = 'env') => safeWindowsAuthority(identity, kind), + inspectWindowsAcls: async () => [ + safeWindowsAuthority(firstIdentity, 'env', 0), + safeWindowsAuthority(secondIdentity, 'env', 1), + ], + }; + try { + await assert.doesNotReject(assertNativeWindowsEntriesAuthority(exactInspector, entries)); + await assert.rejects(assertNativeWindowsEntriesAuthority({ + ...exactInspector, + inspectWindowsAcls: async () => [ + safeWindowsAuthority(secondIdentity, 'env', 1), + safeWindowsAuthority(firstIdentity, 'env', 0), + ], + }, entries), /unavailable/); + await assert.rejects(assertNativeWindowsEntriesAuthority({ + ...exactInspector, + inspectWindowsAcls: async () => [{ + ...safeWindowsAuthority(firstIdentity, 'env', 0), + verifiedFileId: (BigInt(firstIdentity.file) + 1n).toString(), + }, safeWindowsAuthority(secondIdentity, 'env', 1)], + }, entries), /unavailable/); + await assert.rejects(assertNativeWindowsEntriesAuthority({ + ...exactInspector, + inspectWindowsAcls: async () => [{ + ...safeWindowsAuthority(firstIdentity, 'env', 0), + unexpected: 'unbounded-schema-extension', + } as WindowsAuthorityInspection, safeWindowsAuthority(secondIdentity, 'env', 1)], + }, entries), /unavailable/); + } finally { + closeSync(secondFd); + closeSync(firstFd); + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('Darwin ACL parser accepts absent/read-only ACLs and rejects write or unknown authority', () => { + const uuid = 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'; + assert.doesNotThrow(() => assertSafeDarwinAclOutput('!#acl 1\n')); + assert.doesNotThrow(() => assertSafeDarwinAclOutput([ + '!#acl 1', + `group:${uuid}:everyone:12:deny:delete`, + `user:${uuid}:auditor:501:allow:read,readattr,readextattr,readsecurity`, + '', + ].join('\n'))); + assert.throws(() => assertSafeDarwinAclOutput([ + '!#acl 1', + `group:${uuid}:staff:20:allow:write,append`, + ].join('\n')), /write authority/); + assert.throws(() => assertSafeDarwinAclOutput('!#acl 1\nunparseable acl'), /malformed/); +}); + +test('injected Windows and Darwin inspectors exercise the real root policy path', async () => { + const parent = temporaryRoot('propr-connect-platform-authority-'); + const root = connectRoot(parent); + const calls: string[] = []; + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: (path, _fd, expectedIdentity) => { + calls.push(`darwin:${path}`); + return { version: 1, ...expectedIdentity, acl: '!#acl 1\n' }; + }, + inspectWindowsAcl: async (path, expectedIdentity, _fd, kind = 'env') => { + calls.push(`win32:${path}`); + return safeWindowsAuthority(expectedIdentity, kind); + }, + }; + try { + assert.equal(await withOwnedConnectRootSnapshot(root, (snapshot) => ( + getOrCreateSnapshotPublicInstanceIdentity(snapshot.identityDirectory, () => IDS.first) + ), { platform: 'win32', authorityInspector: inspector, parseEnvFile: () => ({}) }), IDS.first); + assert.ok(calls.some((entry) => entry.endsWith('/stack'))); + calls.length = 0; + assert.equal((await withOwnedConnectRootSnapshot(root, (snapshot) => snapshot.envFileValues, { + platform: 'darwin', + authorityInspector: inspector, + parseEnvFile: () => ({ safe: 'yes' }), + })).safe, 'yes'); + assert.ok(calls.some((entry) => entry.startsWith('darwin:'))); + + const rejecting: ConnectRootAuthorityInspector = { + ...inspector, + inspectWindowsAcl: async (_path, expectedIdentity, _fd, kind = 'env') => ({ + ...safeWindowsAuthority(expectedIdentity, kind), + rules: [{ identitySid: 'S-1-1-0', inherited: true, accessType: 'allow', appliesToSelf: true, rights: '2' }], + }), + }; + await assert.rejects(withOwnedConnectRootSnapshot(root, () => undefined, { + platform: 'win32', authorityInspector: rejecting, parseEnvFile: () => ({}), + }), ConnectRootError); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('read-only Windows snapshot fails closed when native inspection cannot complete', async () => { + const parent = temporaryRoot('propr-connect-windows-read-only-'); + const root = connectRoot(parent, 'PROPR_STACK=readonly\n'); + const data = join(root, 'data'); + writeFileSync(identityPath(data), `${JSON.stringify({ + schemaVersion: 1, + publicInstanceIdentity: IDS.first, + })}\n`, { mode: PUBLIC_IDENTITY_FILE_MODE }); + let nativeCalls = 0; + const forbiddenInspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: () => { nativeCalls += 1; throw new Error('native inspector executed'); }, + inspectWindowsAcl: async () => { nativeCalls += 1; throw new Error('native inspector executed'); }, + inspectWindowsAcls: async () => { nativeCalls += 1; throw new Error('native inspector executed'); }, + }; + try { + await assert.rejects(withOwnedConnectRootSnapshot(root, async (snapshot) => ({ + diagnostic: snapshot.authorityDiagnostic, + identity: await readSnapshotPublicInstanceIdentity(snapshot.identityDirectory), + stack: snapshot.envFileValues.PROPR_STACK, + }), { + platform: 'win32', + authorityInspector: forbiddenInspector, + parseEnvFile: () => ({ PROPR_STACK: 'readonly' }), + }), ConnectRootError); + assert.ok(nativeCalls > 0); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('trusted Connect config read is bounded, root-specific, replacement-safe, and Windows-case distinct', async () => { + const parent = temporaryRoot('propr-connect-trusted-config-'); + const home = join(parent, 'os-home'); + const configDir = join(home, '.propr'); + privateDirectory(configDir); + const configPath = join(configDir, 'config.json'); + const root = '/trusted/stack'; + const writeConfig = (value: unknown) => { + writeFileSync(configPath, JSON.stringify(value), { mode: 0o600 }); + chmodSync(configPath, 0o600); + }; + try { + writeConfig({ + githubToken: 'must-never-cross', + tunnelEnabledByRoot: { [root]: false, '/other/stack': true }, + }); + assert.equal(await readTrustedConnectTunnelOverride(root, { trustedHome: home }), false); + assert.equal(await readTrustedConnectTunnelOverride('/other/stack', { trustedHome: home }), true); + assert.equal(await readTrustedConnectTunnelOverride('/unset/stack', { trustedHome: home }), undefined); + writeConfig({ githubToken: 'must-never-cross', tunnelEnabledByRoot: { [root]: true } }); + assert.equal(await readTrustedConnectTunnelOverride(root, { trustedHome: home }), true); + + writeConfig({ tunnelEnabledByRoot: { 'C:\\Work\\Stack': false, 'c:\\work\\stack': true } }); + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: (_path, _fd, identity) => ({ version: 1, ...identity, acl: '!#acl 1\n' }), + inspectWindowsAcl: async (_path, identity, _fd, kind = 'env') => safeWindowsAuthority(identity, kind), + }; + assert.equal(await readTrustedConnectTunnelOverride('C:\\Work\\Stack', { + platform: 'win32', trustedHome: home, authorityInspector: inspector, + }), false); + assert.equal(await readTrustedConnectTunnelOverride('c:\\work\\stack', { + platform: 'win32', trustedHome: home, authorityInspector: inspector, + }), true); + assert.equal(await readTrustedConnectTunnelOverride('c:\\WORK\\STACK', { + platform: 'win32', trustedHome: home, authorityInspector: inspector, + }), undefined); + + writeConfig({ tunnelEnabledByRoot: { [root]: false } }); + let swapped = false; + await assert.rejects(readTrustedConnectTunnelOverride(root, { + trustedHome: home, + onBoundary: (boundary) => { + if (boundary !== 'config-opened' || swapped) return; + swapped = true; + renameSync(configPath, `${configPath}.detached`); + writeConfig({ tunnelEnabledByRoot: { [root]: true } }); + }, + }), TrustedConnectConfigError); + + writeFileSync(configPath, '{malformed', { mode: 0o600 }); + await assert.rejects(readTrustedConnectTunnelOverride(root, { trustedHome: home }), TrustedConnectConfigError); + writeConfig({ tunnelEnabledByRoot: { [root]: false } }); + chmodSync(configPath, 0o666); + await assert.rejects(readTrustedConnectTunnelOverride(root, { trustedHome: home }), TrustedConnectConfigError); + chmodSync(configPath, 0o000); + await assert.rejects(readTrustedConnectTunnelOverride(root, { trustedHome: home }), TrustedConnectConfigError); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('trusted config authenticates absence only at the exact config child open', async () => { + const root = '/trusted/stack'; + const boundaries = [ + 'home-before-open', + 'home-opened', + 'config-directory-before-open', + 'config-directory-opened', + 'config-before-open', + 'config-opened', + ] as const; + for (const boundary of boundaries) { + const parent = temporaryRoot(`propr-config-barrier-${boundary}-`); + const home = join(parent, 'home'); + const configDir = join(home, '.propr'); + const configPath = join(configDir, 'config.json'); + privateDirectory(configDir); + writeFileSync(configPath, JSON.stringify({ tunnelEnabledByRoot: { [root]: false } }), { mode: 0o600 }); + chmodSync(configPath, 0o600); + let replaced = false; + try { + await assert.rejects(readTrustedConnectTunnelOverride(root, { + trustedHome: home, + onBoundary: (current) => { + if (current !== boundary || replaced) return; + replaced = true; + if (current.startsWith('home-')) { + renameSync(home, `${home}.detached`); + privateDirectory(join(home, '.propr')); + writeFileSync(configPath, JSON.stringify({ tunnelEnabledByRoot: { [root]: true } }), { mode: 0o600 }); + } else if (current.startsWith('config-directory-')) { + renameSync(configDir, `${configDir}.detached`); + privateDirectory(configDir); + writeFileSync(configPath, JSON.stringify({ tunnelEnabledByRoot: { [root]: true } }), { mode: 0o600 }); + } else { + renameSync(configPath, `${configPath}.detached`); + writeFileSync(configPath, JSON.stringify({ tunnelEnabledByRoot: { [root]: true } }), { mode: 0o600 }); + chmodSync(configPath, 0o600); + } + }, + }), TrustedConnectConfigError, boundary); + assert.equal(replaced, true, boundary); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + } + + const parent = temporaryRoot('propr-config-absence-'); + const home = join(parent, 'home'); + const configDir = join(home, '.propr'); + try { + privateDirectory(home); + assert.equal(lstatSync(home).isDirectory(), true); + assert.equal(existsSync(configDir), false); + assert.equal(await readTrustedConnectTunnelOverride(root, { trustedHome: home }), undefined); + assert.equal(existsSync(configDir), false, 'an absent .propr directory is never created'); + + privateDirectory(configDir); + assert.equal(await readTrustedConnectTunnelOverride(root, { trustedHome: home }), undefined); + rmSync(configDir, { recursive: true }); + assert.equal(await readTrustedConnectTunnelOverride(root, { trustedHome: home }), undefined); + assert.equal(existsSync(configDir), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + + const windowsParent = temporaryRoot('propr-config-absence-windows-'); + const windowsHome = join(windowsParent, 'home'); + privateDirectory(windowsHome); + const inspectedKinds: string[] = []; + const windowsInspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: (_path, _fd, identity) => ({ version: 1, ...identity, acl: '!#acl 1\n' }), + inspectWindowsAcl: async (_path, identity, _fd, kind = 'env') => { + inspectedKinds.push(kind); + return safeWindowsAuthority(identity, kind); + }, + }; + try { + assert.equal(await readTrustedConnectTunnelOverride(root, { + platform: 'win32', + trustedHome: windowsHome, + authorityInspector: windowsInspector, + }), undefined); + assert.ok(inspectedKinds.includes('home')); + assert.equal(existsSync(join(windowsHome, '.propr')), false); + } finally { + rmSync(windowsParent, { recursive: true, force: true }); + } + + for (const race of ['home-aba', 'config-directory-aba', 'config-directory-symlink'] as const) { + const raceParent = temporaryRoot(`propr-config-absence-${race}-`); + const raceHome = join(raceParent, 'home'); + const raceConfigDir = join(raceHome, '.propr'); + const detachedHome = join(raceParent, 'home-detached'); + const detachedConfigDir = join(raceHome, '.propr-detached'); + privateDirectory(raceHome); + if (race !== 'home-aba') privateDirectory(raceConfigDir); + let raced = false; + try { + await assert.rejects(readTrustedConnectTunnelOverride(root, { + trustedHome: raceHome, + onBoundary: (current) => { + if (current !== 'config-directory-before-open' || raced) return; + raced = true; + if (race === 'home-aba') { + renameSync(raceHome, detachedHome); + privateDirectory(raceHome); + } else { + renameSync(raceConfigDir, detachedConfigDir); + if (race === 'config-directory-aba') privateDirectory(raceConfigDir); + else symlinkSync(detachedConfigDir, raceConfigDir, 'dir'); + } + }, + }), TrustedConnectConfigError, race); + assert.equal(raced, true, race); + } finally { + rmSync(raceParent, { recursive: true, force: true }); + } + } +}); diff --git a/test/testSuiteRunner.test.mjs b/test/testSuiteRunner.test.mjs index 7b0c02581..d66569c62 100644 --- a/test/testSuiteRunner.test.mjs +++ b/test/testSuiteRunner.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; @@ -15,6 +15,36 @@ import { } from '../scripts/run-test-suite.mjs'; describe('release test-suite runner', () => { + test('prepares desktop runtime dependencies before clean desktop and full-suite tests', () => { + const rootPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + const desktopPackageUrl = new URL('../apps/desktop/package.json', import.meta.url); + const desktopPackage = JSON.parse(readFileSync(desktopPackageUrl, 'utf8')); + const workflow = readFileSync(new URL('../.github/workflows/pr-test-on-label.yml', import.meta.url), 'utf8'); + const sharedBuild = 'npm run build --workspace=packages/shared'; + const clientBuild = 'npm run build --workspace=packages/client'; + const fullSuitePreparation = rootPackage.scripts['test:prepare']; + const desktopPreparation = desktopPackage.scripts['prepare:renderer']; + + assert.ok(fullSuitePreparation.indexOf(sharedBuild) >= 0); + assert.ok(fullSuitePreparation.indexOf(clientBuild) > fullSuitePreparation.indexOf(sharedBuild)); + assert.equal(desktopPackage.scripts.pretest, 'npm run prepare:renderer'); + assert.ok(desktopPreparation.indexOf('npm run build -w @propr/client') + > desktopPreparation.indexOf('npm run build -w @propr/shared')); + + const cleanSharedDist = workflow.indexOf('test ! -e packages/shared/dist'); + const cleanClientDist = workflow.indexOf('test ! -e packages/client/dist'); + const prepareFullSuite = workflow.indexOf('npm run test:prepare', cleanClientDist); + const assertSharedBuilt = workflow.indexOf('test -f packages/shared/dist/index.js', prepareFullSuite); + const assertClientBuilt = workflow.indexOf('test -f packages/client/dist/index.js', prepareFullSuite); + const runFullSuite = workflow.indexOf('npm run test:full:prepared', assertClientBuilt); + assert.ok(cleanSharedDist >= 0); + assert.ok(cleanClientDist > cleanSharedDist); + assert.ok(prepareFullSuite > cleanClientDist); + assert.ok(assertSharedBuilt > prepareFullSuite); + assert.ok(assertClientBuilt > prepareFullSuite); + assert.ok(runFullSuite > assertClientBuilt); + }); + test('selects supported test files deterministically and excludes live E2E', () => { assert.deepEqual(selectTestFiles([ '/repo/test/z.test.ts', diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts new file mode 100644 index 000000000..856705bde --- /dev/null +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -0,0 +1,579 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { test } from 'node:test'; +import { + parseWindowsNativeProbeOutput, + WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + WINDOWS_INSPECTION_TIMEOUT_MS, + WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, + windowsInspectionTimeoutForElapsed, + WindowsNativeStageError, + windowsNativeTimingBucket, +} from '../packages/cli/src/connectWindowsAuthority.js'; + +const harness = readFileSync('scripts/verify-windows-standard-user-connect.mjs', 'utf8'); +const processMock = readFileSync('test/fixtures/windowsConnectProcessMock.mjs', 'utf8'); +const windowsAuthority = readFileSync('packages/cli/src/connectWindowsAuthority.ts', 'utf8'); + +function diagnosticDefinitions(): { + scenarioAllowlist: string[]; + assertionStageAllowlist: string[]; + statusKindAllowlist: string[]; + reasonCodeAllowlist: string[]; + nativeStageAllowlist: string[]; + probeMilestoneAllowlist: string[]; + probeTimingAllowlist: string[]; + createFailureDiagnostic: ( + scenario: string, + stage: string, + failureStatus: { status?: unknown; reasonCodes?: unknown } | null, + nativeStage: string | null, + probe: { milestone: string | null; timing: string | null }, + ) => Record; +} { + const start = harness.indexOf('const scenarioAllowlist ='); + const end = harness.indexOf('const cases = [', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + return runInNewContext(`${harness.slice(start, end)}\n({ + scenarioAllowlist, + assertionStageAllowlist, + statusKindAllowlist, + reasonCodeAllowlist, + nativeStageAllowlist, + probeMilestoneAllowlist, + probeTimingAllowlist, + createFailureDiagnostic, + })`) as ReturnType; +} + +type FixtureScenario = { name: string; enabled: boolean; authorityMode?: string }; +type SystemRootMode = 'missing' | 'mismatched' | 'untrusted' | undefined; + +function tunnelFixtureEnvLines(scenario: FixtureScenario): string[] { + const start = harness.indexOf('function tunnelFixtureEnvLines('); + const end = harness.indexOf('\n\nconst scenarioAllowlist =', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const definitions = runInNewContext(`${harness.slice(start, end)}\n({ tunnelFixtureEnvLines })`) as { + tunnelFixtureEnvLines: (value: FixtureScenario) => string[]; + }; + return [...definitions.tunnelFixtureEnvLines(scenario)]; +} + +function windowsRootEnvironment(systemRootMode: SystemRootMode): Record { + const start = harness.indexOf('function windowsRootEnvironment('); + const end = harness.indexOf('\n\nconst scenarioAllowlist =', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const definitions = runInNewContext(`${harness.slice(start, end)}\n({ windowsRootEnvironment })`) as { + windowsRootEnvironment: ( + mode: SystemRootMode, + systemRoot: string, + windir: string, + untrustedRoot: string, + ) => Record; + }; + return { ...definitions.windowsRootEnvironment( + systemRootMode, + 'C:\\canonical-system-root', + 'C:\\canonical-windir', + 'D:\\untrusted-fixture', + ) }; +} + +function missingWindowsRootFixtureEnvironment(systemRootMode: SystemRootMode): Record { + const start = harness.indexOf('const WINDOWS_ROOT_MISSING_MARKER ='); + const end = harness.indexOf('\n\nconst scenarioAllowlist =', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const definitions = runInNewContext(`${harness.slice(start, end)}\n({ missingWindowsRootFixtureEnvironment })`) as { + missingWindowsRootFixtureEnvironment: (mode: SystemRootMode) => Record; + }; + return { ...definitions.missingWindowsRootFixtureEnvironment(systemRootMode) }; +} + +function untrustedWindowsRootFixtureEnvironment(systemRootMode: SystemRootMode): Record { + const start = harness.indexOf('const WINDOWS_ROOT_MISSING_MARKER ='); + const end = harness.indexOf('\n\nconst scenarioAllowlist =', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const definitions = runInNewContext(`${harness.slice(start, end)}\n({ untrustedWindowsRootFixtureEnvironment })`) as { + untrustedWindowsRootFixtureEnvironment: ( + mode: SystemRootMode, + root: string, + ) => Record; + }; + return { ...definitions.untrustedWindowsRootFixtureEnvironment(systemRootMode, '/fixture-root') }; +} + +function consumeWindowsRootFixtureEnvironment( + environment: Record, + fixtureRoot = '/fixture-root', +): Record { + const start = processMock.indexOf('const WINDOWS_ROOT_MISSING_MARKER ='); + const end = processMock.indexOf('\n\nconst originalSpawnSync =', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const context = { process: { env: { ...environment }, cwd: () => fixtureRoot }, resolve }; + return runInNewContext( + `${processMock.slice(start, end)}\nprocess.env`, + context, + ) as Record; +} + +function fixtureScenarios(): FixtureScenario[] { + const start = harness.indexOf('const cases = ['); + const end = harness.indexOf('\n];', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + return runInNewContext(`${harness.slice(start, end + 3)}\ncases`) as FixtureScenario[]; +} + +test('the disabled Windows scenario omits its token while enabled scenarios retain the sentinel', () => { + const scenarios = fixtureScenarios(); + const disabled = scenarios.find((scenario) => scenario.name === 'disabled'); + assert.ok(disabled); + assert.equal(disabled.enabled, false); + assert.deepEqual(tunnelFixtureEnvLines(disabled), [ + 'PROPR_UI_TUNNEL_ENABLED=false', + ]); + for (const scenario of scenarios.filter(({ name }) => name !== 'disabled')) { + assert.equal(scenario.enabled, true, scenario.name); + assert.deepEqual(tunnelFixtureEnvLines(scenario), [ + 'PROPR_UI_TUNNEL_ENABLED=true', + 'PROPR_UI_TUNNEL_TOKEN=root-token-SENTINEL', + ], scenario.name); + } +}); + +test('the Windows authority fixtures isolate pre-import root injection markers', () => { + const missing = windowsRootEnvironment('missing'); + assert.deepEqual(missing, {}); + assert.equal(Object.hasOwn(missing, 'SYSTEMROOT'), false); + assert.equal(Object.hasOwn(missing, 'WINDIR'), false); + assert.deepEqual(windowsRootEnvironment('mismatched'), { + SYSTEMROOT: 'C:\\canonical-system-root', + WINDIR: 'D:\\untrusted-fixture', + }); + assert.deepEqual(windowsRootEnvironment('untrusted'), { + SYSTEMROOT: 'C:\\canonical-system-root', + WINDIR: 'C:\\canonical-windir', + }); + assert.deepEqual(windowsRootEnvironment(undefined), { + SYSTEMROOT: 'C:\\canonical-system-root', + WINDIR: 'C:\\canonical-windir', + }); + assert.match( + harness, + /\.\.\.windowsRootEnvironment\(\s*scenario\.systemRootMode,\s*process\.env\.SystemRoot,\s*process\.env\.WINDIR,\s*fixture,\s*\),/, + ); + assert.deepEqual(missingWindowsRootFixtureEnvironment('missing'), { + PROPR_TEST_WINDOWS_ROOT_MISSING: 'windows-root-missing-v1', + }); + for (const mode of ['mismatched', 'untrusted', undefined] as const) { + assert.deepEqual(missingWindowsRootFixtureEnvironment(mode), {}, String(mode)); + } + assert.match( + harness, + /\.\.\.missingWindowsRootFixtureEnvironment\(scenario\.systemRootMode\),/, + ); + assert.deepEqual(untrustedWindowsRootFixtureEnvironment('untrusted'), { + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'windows-root-untrusted-v1', + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH: '/fixture-root', + }); + for (const mode of ['missing', 'mismatched', undefined] as const) { + assert.deepEqual(untrustedWindowsRootFixtureEnvironment(mode), {}, String(mode)); + } + assert.match( + harness, + /\.\.\.untrustedWindowsRootFixtureEnvironment\(scenario\.systemRootMode, fixture\),/, + ); + + const consumed = consumeWindowsRootFixtureEnvironment({ + PROPR_TEST_WINDOWS_ROOT_MISSING: 'windows-root-missing-v1', + SystemRoot: 'C:\\Windows', + SYSTEMROOT: 'D:\\Windows', + windir: 'C:\\Windows', + WiNdIr: 'D:\\Windows', + SAFE_FIXTURE_VALUE: 'retained', + }); + assert.deepEqual({ ...consumed }, { SAFE_FIXTURE_VALUE: 'retained' }); + + for (const mode of ['mismatched', 'untrusted', undefined] as const) { + const untouched = windowsRootEnvironment(mode); + assert.deepEqual( + { ...consumeWindowsRootFixtureEnvironment(untouched) }, + untouched, + String(mode), + ); + } + for (const untouchedMarker of [ + { + PROPR_TEST_WINDOWS_ROOT_MISSING: 'not-the-fixed-marker', + SYSTEMROOT: 'C:\\Windows', + WINDIR: 'D:\\untrusted-fixture', + }, + { + propr_test_windows_root_missing: 'windows-root-missing-v1', + SYSTEMROOT: 'D:\\untrusted-fixture', + WINDIR: 'D:\\untrusted-fixture', + }, + ]) { + assert.deepEqual( + { ...consumeWindowsRootFixtureEnvironment(untouchedMarker) }, + untouchedMarker, + ); + } + + const untrusted = consumeWindowsRootFixtureEnvironment({ + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'windows-root-untrusted-v1', + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH: '/fixture-root', + SystemRoot: 'C:\\Windows', + SYSTEMROOT: 'D:\\Windows', + systemroot: 'E:\\Windows', + windir: 'C:\\Windows', + WiNdIr: 'D:\\Windows', + SAFE_FIXTURE_VALUE: 'retained', + }); + assert.deepEqual({ ...untrusted }, { + SAFE_FIXTURE_VALUE: 'retained', + SystemRoot: '/fixture-root', + WINDIR: '/fixture-root', + }); + assert.equal(Object.hasOwn(untrusted, 'PROPR_TEST_WINDOWS_ROOT_UNTRUSTED'), false); + assert.equal(Object.hasOwn(untrusted, 'PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH'), false); + + for (const untouchedMarker of [ + { + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'not-the-fixed-marker', + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH: '/fixture-root', + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + }, + { + propr_test_windows_root_untrusted: 'windows-root-untrusted-v1', + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH: '/fixture-root', + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + }, + { + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'windows-root-untrusted-v1', + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + }, + { + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'windows-root-untrusted-v1', + propr_test_windows_root_untrusted_path: '/fixture-root', + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + }, + { + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED: 'windows-root-untrusted-v1', + PROPR_TEST_WINDOWS_ROOT_UNTRUSTED_PATH: '/outside-fixture', + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + }, + ]) { + assert.deepEqual( + { ...consumeWindowsRootFixtureEnvironment(untouchedMarker) }, + untouchedMarker, + ); + } + + const missingFixtureConsumer = processMock.indexOf('consumeMissingWindowsRootFixtureMarker();'); + const untrustedFixtureConsumer = processMock.indexOf('consumeUntrustedWindowsRootFixtureMarker();'); + const fixtureMockInstall = processMock.indexOf('const originalSpawnSync ='); + const processFixtureImport = harness.indexOf('"--import", processFixture'); + const fetchFixtureImport = harness.indexOf('"--import", fetchFixture'); + assert.ok(missingFixtureConsumer !== -1 && missingFixtureConsumer < fixtureMockInstall); + assert.ok(untrustedFixtureConsumer !== -1 && untrustedFixtureConsumer < fixtureMockInstall); + assert.ok(processFixtureImport !== -1 && processFixtureImport < fetchFixtureImport); + assert.match(harness, /spawnSync\(process\.execPath, \[\s*\.\.\.fixtureNodeArgs,\s*cli,/); + + const productionSource = readdirSync('packages/cli/src', { recursive: true }) + .filter((entry): entry is string => typeof entry === 'string' && entry.endsWith('.ts')) + .map((entry) => readFileSync(`packages/cli/src/${entry}`, 'utf8')) + .join('\n'); + assert.doesNotMatch( + productionSource, + /PROPR_TEST_WINDOWS_ROOT_(?:MISSING|UNTRUSTED)|windows-root-(?:missing|untrusted)-v1/, + ); + + assert.match(harness, /const configDirectory = join\(fixture, "config"\);/); + assert.equal(harness.match(/new ConfigManager\(/g)?.length, 1); + assert.doesNotMatch(harness, /userInfo\(\)\.homedir|(?:writeFileSync|new ConfigManager)\([^\n]*(?:USERPROFILE|\.propr)/); +}); + +test('the ordinary-user Windows proof retains native security paths and bounds result-matrix reuse', () => { + assert.match(harness, /await scaffoldStack\(/); + assert.match(harness, /await manager\.save\(\)/); + assert.match(harness, /public-instance-identity\.json/); + assert.match(harness, /config\.json/); + const scenarios = fixtureScenarios(); + const ready = scenarios.find((scenario) => scenario.name === 'ready'); + assert.ok(ready); + assert.equal(ready.authorityMode, undefined); + for (const scenario of scenarios.filter(({ name }) => name !== 'ready')) { + assert.equal(scenario.authorityMode, 'valid-authority', scenario.name); + } + assert.match( + processMock, + /if \(mode === "valid-authority"\) return result\(0, authorityDocument\(args, options, mode\)\);/, + ); + assert.match(harness, /\{ name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" \}/); + assert.match(harness, /\{ name: "authority-missing-system-root", systemRootMode: "missing", nativeStage: "resolver:env" \}/); + assert.match(harness, /\{ name: "authority-untrusted-system-root", systemRootMode: "untrusted", nativeStage: "resolver:global-id" \}/); +}); + +test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all other values', () => { + const definitions = diagnosticDefinitions(); + assert.deepEqual([...definitions.scenarioAllowlist], [ + 'ready', 'down', 'disabled', 'restart-required', 'malformed', 'oversized', 'timeout', + 'identity-mismatch', 'secret-sentinel', 'api', 'path-aba', 'authority-malformed', 'authority-oversized', + 'authority-extra-key', 'authority-duplicate', 'authority-entry-count', 'authority-entry-shape', + 'authority-stderr', 'authority-nonzero', + 'authority-timeout', 'authority-descriptor-mismatch', 'authority-index-mismatch', + 'authority-kind-mismatch', 'authority-authority-kind-mismatch', 'authority-identity-mismatch', + 'authority-sid-mismatch', 'authority-broad-write', 'authority-inherited-write', + 'authority-unprotected', 'authority-owner-mismatch', 'authority-reparse', + 'authority-missing-system-root', 'authority-mismatched-system-root', 'authority-untrusted-system-root', + ]); + assert.deepEqual([...definitions.assertionStageAllowlist], [ + 'native-timing', 'authority-probe', 'scaffold', 'identity-assertion', 'config-init', 'config-save', + 'config-assertion', + 'write-env', 'spawn', 'signal', 'exit', 'bounds', 'schema', 'status', 'endpoint', + 'identity', 'reasons', 'api-ready', 'restart', 'stderr', 'sentinel', 'api-spawn', + 'api-exit', 'api-count', + ]); + assert.deepEqual([...definitions.statusKindAllowlist], [ + 'ready', 'internalFailure', 'notReady', 'incompatible', 'invalidConfig', 'timeout', + ]); + assert.deepEqual([...definitions.reasonCodeAllowlist], [ + 'NOT_CONFIGURED', 'TUNNEL_DISABLED', 'SIDECAR_NOT_RUNNING', 'API_UNREACHABLE', 'API_TIMEOUT', + 'DISCOVERY_UNSUPPORTED', 'DISCOVERY_INVALID', 'DISCOVERY_TOO_LARGE', 'API_INCOMPATIBLE', + 'DESKTOP_AUTHENTICATION_UNSUPPORTED', + 'IDENTITY_MISMATCH', 'ENDPOINT_MISMATCH', 'RESTART_REQUIRED', 'INVALID_ROOT', 'INVALID_ENDPOINT', + 'IDENTITY_UNAVAILABLE', 'INTERNAL_FAILURE', 'ACL_DIAGNOSTIC_UNAVAILABLE', + ]); + assert.deepEqual([...definitions.nativeStageAllowlist], [ + 'resolver:env', 'resolver:canonical', 'resolver:global-open', 'resolver:global-id', + 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:cumulative-timeout', 'spawn:status', 'spawn:stderr', + 'probe:entry', 'probe:baseline', 'probe:reflection-emit', 'probe:win32', 'probe:standard-handle', 'probe:output', + 'broker:ps-version', 'broker:job', 'broker:fd', 'broker:fd-duplicate', 'broker:index-info-initial', + 'broker:security-info', 'broker:acl', 'broker:json', 'broker:current-user-sid', + 'broker:index-info-revalidation', 'broker:index-info-decode', 'broker:index-info-compose', 'broker:entry-format', + 'broker:entry-flags', 'broker:entry-rules', 'broker:entry-build', + 'parent:utf8', 'parent:json-parse', 'parent:json-canonical', 'parent:document-shape', + 'parent:entry-count', 'parent:entry-shape', 'parent:json-shape', 'parent:descriptor-bind', 'parent:post-bind', + ]); + assert.deepEqual([...definitions.probeMilestoneAllowlist], [ + 'none', 'entry-ps51-desktop-x64', 'constant-json', 'reflection-emit', 'harmless-win32', + 'standard-handle-identity', + ]); + assert.deepEqual([...definitions.probeTimingAllowlist], [ + 'under-5s', '5-to-15s', '15-to-30s', '30-to-45s', '45-to-60s', 'at-least-60s', + ]); + const assignedStages = [...harness.matchAll(/currentStage = "([^"]+)";/g)] + .map((match) => match[1]); + assert.deepEqual(new Set(assignedStages), new Set(definitions.assertionStageAllowlist)); + + const diagnostic = definitions.createFailureDiagnostic('ready', 'stderr', { + status: 'ready', + reasonCodes: ['ACL_DIAGNOSTIC_UNAVAILABLE'], + path: 'private-path-SENTINEL', + argv: 'argv-SENTINEL', + stdout: 'raw-stdout-SENTINEL', + stderr: 'raw-stderr-SENTINEL', + message: 'assertion-message-SENTINEL', + environment: 'environment-SENTINEL', + config: 'config-SENTINEL', + identity: 'identity-SENTINEL', + endpoint: 'endpoint-SENTINEL', + secret: 'secret-SENTINEL', + } as { status: string; reasonCodes: string[] }, 'broker:fd', { + milestone: 'standard-handle-identity', timing: '15-to-30s', + }); + assert.deepEqual(Object.keys(diagnostic), [ + 'scenario', 'stage', 'nativeStage', 'status', 'reasonCodes', + 'probeMilestone', 'probeTiming', + ]); + assert.deepEqual(JSON.parse(JSON.stringify(diagnostic)), { + scenario: 'ready', + stage: 'stderr', + nativeStage: 'broker:fd', + status: 'ready', + reasonCodes: ['ACL_DIAGNOSTIC_UNAVAILABLE'], + probeMilestone: 'standard-handle-identity', + probeTiming: '15-to-30s', + }); + assert.equal(JSON.stringify(diagnostic).includes('SENTINEL'), false); + + assert.deepEqual(JSON.parse(JSON.stringify(definitions.createFailureDiagnostic( + 'ready', 'native-timing', null, 'spawn:timeout', + { milestone: 'reflection-emit', timing: 'at-least-60s' }, + ))), { + scenario: 'ready', + stage: 'native-timing', + nativeStage: 'spawn:timeout', + status: null, + reasonCodes: [], + probeMilestone: 'reflection-emit', + probeTiming: 'at-least-60s', + }); + + const rejected = definitions.createFailureDiagnostic( + 'private-scenario-SENTINEL', + 'raw-output-SENTINEL', + { status: 'secret-status-SENTINEL', reasonCodes: ['secret-reason-SENTINEL'] }, + 'raw-native-stage-SENTINEL', + { milestone: 'secret-SENTINEL', timing: '12345ms-SENTINEL' }, + ); + assert.deepEqual(JSON.parse(JSON.stringify(rejected)), { + scenario: 'ready', + stage: 'write-env', + nativeStage: null, + status: null, + reasonCodes: [], + probeMilestone: null, + probeTiming: null, + }); + + const catchStart = harness.lastIndexOf('} catch {'); + const catchEnd = harness.indexOf('} finally {', catchStart); + const catchBody = harness.slice(catchStart, catchEnd); + assert.match(catchBody, /createFailureDiagnostic\(\s*currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe,/); + assert.match(catchBody, /JSON\.stringify\(\s*diagnostic,\s*\)/); + assert.doesNotMatch(catchBody, /(?:result|api|error)\.(?:stdout|stderr|message|path|argv|env|config)/i); +}); + +test('the staged hosted probe and production inspector both use the inherited standard handle', () => { + assert.doesNotMatch(windowsAuthority, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject/); + assert.match(harness, /runWindowsNativeTimingProbe\(probeFd\)/); + assert.match(harness, /openSync\(\s*fixture,\s*constants\.O_RDONLY \| constants\.O_DIRECTORY \| constants\.O_NOFOLLOW,\s*\)/); + assert.match(harness, /native-timing=\$\{nativeProbe\.evidence\}/); + assert.match(harness, /;total:\$\{nativeProbe\.timing\}/); + assert.match(harness, /ready=standard-handle-passed/); + + const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); + const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); + const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); + assert.match(productionSource, /GetStdHandle\(-10\)/); + assert.doesNotMatch(productionSource, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); + assert.match(windowsAuthority, /stdio: \[stdin, "pipe", "pipe"\]/); + assert.match(windowsAuthority, /WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false/); + assert.match(windowsAuthority, /WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false/); +}); + +test('the production inspector duplicates its standard handle before the split native operations', () => { + const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); + const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); + const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); + assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); + assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}/); + assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{exit \$stage\}\s+\$currentSid=\$current\.Value/); + assert.match(productionSource, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); + assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}/); + assert.equal(productionSource.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); + assert.match(productionSource, /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.doesNotMatch(productionSource, /CloseHandle\(\$originalHandle\)/); + assert.doesNotMatch(windowsAuthority, /"broker:index-info"/); + assert.match(windowsAuthority, /74: "broker:index-info-initial"/); + assert.match(windowsAuthority, /78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate"/); +}); + +test('the staged probe accepts only ordered milestone tokens and coarse timing buckets', () => { + const prefix = [ + 'PROPR_NATIVE_PROBE_V1|entry-ps51-desktop-x64|under-5s', + 'PROPR_NATIVE_PROBE_V1|constant-json|5-to-15s', + 'PROPR_NATIVE_PROBE_V1|reflection-emit|15-to-30s', + '', + ].join('\r\n'); + assert.deepEqual(parseWindowsNativeProbeOutput(prefix).map(({ milestone }) => milestone), [ + 'entry-ps51-desktop-x64', 'constant-json', 'reflection-emit', + ]); + assert.deepEqual([4_999, 5_000, 15_000, 30_000, 45_000, 60_000].map(windowsNativeTimingBucket), [ + 'under-5s', '5-to-15s', '15-to-30s', '30-to-45s', '45-to-60s', 'at-least-60s', + ]); + assert.throws( + () => parseWindowsNativeProbeOutput('private-path-SENTINEL raw-exception-SENTINEL\r\n'), + (error) => error instanceof WindowsNativeStageError + && error.stage === 'probe:output' + && !error.message.includes('SENTINEL'), + ); + assert.match(windowsAuthority, /\$baseline='\{"version":1,"baseline":"constant"\}'/); + assert.match(windowsAuthority, /DefineDynamicAssembly/); + assert.match(windowsAuthority, /GetCurrentProcessId/); + assert.match(windowsAuthority, /GetStdHandle\(-10\)/); + assert.match(windowsAuthority, /GetFileInformationByHandle/); +}); + +test('the diagnostic allowance precedes a cumulatively bounded production standard-handle proof', () => { + assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); + assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); + assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.match( + windowsAuthority, + /export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000;/, + ); + assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2;/); + assert.match(harness, /const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000;/); + assert.match( + harness, + /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT\s*\* nativeAuthority\.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, + ); + const windowsProductScenarioTimeoutMs = ( + 2 * WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS + ) + 15_000; + assert.equal(windowsProductScenarioTimeoutMs, 495_000); + assert.equal(Number.isFinite(windowsProductScenarioTimeoutMs), true); + assert.equal(Number.isSafeInteger(windowsProductScenarioTimeoutMs), true); + assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); + assert.notEqual( + WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, + 32, + ); + assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); + assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); + assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); + assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); + assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); + assert.throws( + () => windowsInspectionTimeoutForElapsed(240_000), + (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', + ); + assert.throws( + () => windowsInspectionTimeoutForElapsed(240_001), + (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', + ); + const probeCall = harness.indexOf('runWindowsNativeTimingProbe(probeFd)'); + const productionMatrix = harness.indexOf('for (const scenario of cases)', probeCall); + const productionSpawn = harness.indexOf('const result = spawnSync(process.execPath', productionMatrix); + assert.ok(probeCall < productionMatrix && productionMatrix < productionSpawn); + const probeStart = windowsAuthority.indexOf('export function runWindowsNativeTimingProbe'); + const probeEnd = windowsAuthority.indexOf('\n}\n\nexport function windowsInspectionEntryKind', probeStart); + const probe = windowsAuthority.slice(probeStart, probeEnd); + assert.match(probe, /const executable = resolveWindowsPowerShell\(\);/); + assert.doesNotMatch(probe, /catch\s*\{/); + assert.doesNotMatch(harness, /extraStdio|alreadyContained|nestedJob|runWindowsHostedAssumptionProbe/); +}); + +test('the hostile path ABA remains replaced through validation and is rejected as INVALID_ROOT', () => { + assert.match(harness, /\{ name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" \}/); + assert.doesNotMatch(harness, /name: "path-aba"[^\n]+status: "ready"/); + assert.match(processMock, /attacker-replacement-SENTINEL/); + assert.match(processMock, /process\.once\("exit", \(\) => \{/); + const replacement = processMock.indexOf('writeFileSync(envPath'); + const exitHook = processMock.indexOf('process.once("exit"', replacement); + const spawn = processMock.indexOf('return originalSpawnSync(command, args, options);', replacement); + const restore = processMock.indexOf('renameSync(detached, envPath);', replacement); + assert.ok( + replacement < exitHook && exitHook < restore && restore < spawn, + 'restoration must be registered only for process exit before the CLI resumes', + ); +});