diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892bd6a..8cb0b63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: shared: name: Shared checks (${{ matrix.name }}) runs-on: ${{ matrix.runner }} - timeout-minutes: 60 + timeout-minutes: 120 strategy: fail-fast: false matrix: @@ -52,11 +52,13 @@ jobs: run: >- uv run --project backend --frozen --only-group ci ruff format --check scripts/release_artifact.py scripts/tests/test_release_artifact.py + scripts/tests/test_windows_packaging.py - name: Lint release tooling run: >- uv run --project backend --frozen --only-group ci ruff check scripts/release_artifact.py scripts/tests/test_release_artifact.py + scripts/tests/test_windows_packaging.py - name: Check portable Python formatting working-directory: backend @@ -152,3 +154,45 @@ jobs: run: >- cargo clippy --locked --workspace --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings + + - name: Install pinned Tauri packaging CLI + if: runner.os == 'Windows' + run: cargo install tauri-cli --version '=2.11.2' --locked + + - name: Build older unsigned Windows installer + if: runner.os == 'Windows' + id: windows_older + shell: pwsh + run: ./scripts/build-windows-installer.ps1 -ReleaseTag v2026.08.1 -UnsignedDevelopment + + - name: Build newer unsigned Windows installer + if: runner.os == 'Windows' + id: windows_newer + shell: pwsh + run: ./scripts/build-windows-installer.ps1 -ReleaseTag v2026.08.2 -UnsignedDevelopment + + - name: Prove release verification rejects unsigned artifacts + if: runner.os == 'Windows' + shell: pwsh + run: >- + ./scripts/assert-windows-release-rejects-unsigned.ps1 + -Path '${{ steps.windows_newer.outputs.installer }}' + + - name: Test Windows installer lifecycle + if: runner.os == 'Windows' + shell: pwsh + run: >- + ./scripts/test-windows-installer.ps1 + -OlderInstaller '${{ steps.windows_older.outputs.installer }}' + -NewerInstaller '${{ steps.windows_newer.outputs.installer }}' + -OlderVersion '${{ steps.windows_older.outputs.version }}' + -NewerVersion '${{ steps.windows_newer.outputs.version }}' + + - name: Upload unsigned Windows development installer + if: runner.os == 'Windows' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-x64-unsigned-development + path: ${{ steps.windows_newer.outputs.installer }} + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 7cdb514..56469b0 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -3,7 +3,7 @@ name: Release run-name: Release ${{ github.ref_name }} by @${{ github.actor }} # A protected release tag starts validation. Signing credentials remain behind -# the macos-release Environment's separate human approval gate. +# each platform release Environment's separate human approval gate. on: push: tags: @@ -13,7 +13,7 @@ permissions: contents: read concurrency: - group: macos-release + group: release cancel-in-progress: false jobs: @@ -248,14 +248,108 @@ jobs: "${LSDJ_CERTIFICATE_PATH:-}" \ "${LSDJ_API_KEY_PATH:-}" + produce_windows: + name: Produce Windows x64 artifact + needs: validate + if: >- + needs.validate.result == 'success' && + github.repository == 'protocol-works/lsdj' && + startsWith(github.ref, 'refs/tags/v') + runs-on: windows-2025 + timeout-minutes: 180 + + # This protected Environment must require a separate human approval. The + # provider-specific identity provisioning step cannot be selected until the + # project chooses an Authenticode provider. These public identity values and + # the credentialed wrapper path stay unavailable before approval. + environment: + name: windows-release + + env: + LSDJ_WINDOWS_SIGN_COMMAND_PATH: ${{ vars.WINDOWS_SIGN_COMMAND_PATH }} + LSDJ_WINDOWS_EXPECTED_CERTIFICATE_SHA1: ${{ vars.WINDOWS_EXPECTED_CERTIFICATE_SHA1 }} + LSDJ_WINDOWS_EXPECTED_SUBJECT: ${{ vars.WINDOWS_EXPECTED_SUBJECT }} + + steps: + - name: Check out the approved release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Set up Rust + shell: pwsh + run: | + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + + - name: Install build tools and frontend dependencies + shell: pwsh + run: | + cargo install tauri-cli --version '=2.11.2' --locked + npm ci --prefix frontend + npm run build --prefix frontend + + # The selected provider must insert a protected provisioning step before + # this preflight (for example, federated identity or an HSM-backed client). + # Until that reviewed decision exists, this job intentionally fails here. + - name: Require protected Authenticode identity + shell: pwsh + run: ./scripts/sign-windows.ps1 -Preflight + + - name: Build, sign, timestamp, and verify NSIS installer + id: windows_build + shell: pwsh + run: ./scripts/build-windows-installer.ps1 -ReleaseTag $env:GITHUB_REF_NAME -Release + + - name: Verify installed executable payloads and uninstall behavior + shell: pwsh + run: >- + ./scripts/verify-windows-release-install.ps1 + -Installer '${{ steps.windows_build.outputs.installer }}' + -ExpectedVersion '${{ steps.windows_build.outputs.version }}' + + - name: Package verified release artifact + shell: pwsh + env: + LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + run: >- + python scripts/release_artifact.py create + --producer windows-x64 + --release-tag "$env:GITHUB_REF_NAME" + --revision "$env:LSDJ_RELEASE_REVISION" + --asset '${{ steps.windows_build.outputs.installer }}' + --output-dir release-artifacts/windows-x64 + + - name: Upload verified producer bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-windows-x64 + path: release-artifacts/windows-x64 + if-no-files-found: error + retention-days: 14 + publish: name: Verify and publish complete release needs: - validate - produce_macos + - produce_windows if: >- needs.validate.result == 'success' && needs.produce_macos.result == 'success' && + needs.produce_windows.result == 'success' && github.repository == 'protocol-works/lsdj' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest @@ -280,6 +374,12 @@ jobs: name: release-macos-arm64 path: release-input/macos-arm64 + - name: Download Windows producer bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-windows-x64 + path: release-input/windows-x64 + - name: Verify complete required producer set env: LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} @@ -289,6 +389,7 @@ jobs: python scripts/release_artifact.py verify \ --input-root release-input \ --required-producer macos-arm64 \ + --required-producer windows-x64 \ --release-tag "$GITHUB_REF_NAME" \ --revision "$LSDJ_RELEASE_REVISION" \ --output-dir verified-release diff --git a/docs/cross-platform-ci-and-release.md b/docs/cross-platform-ci-and-release.md index 48b8173..772227d 100644 --- a/docs/cross-platform-ci-and-release.md +++ b/docs/cross-platform-ci-and-release.md @@ -54,8 +54,8 @@ contract. A fake result must not be reported as hardware qualification. ## Release producer/publisher boundary -The tag workflow keeps macOS as the only required release artifact initially. -It has three stages: +The tag workflow requires macOS arm64 and Windows x64 release artifacts. It has +four stages: 1. `validate` accepts only a calendar-version `v*` tag whose commit is contained in `main`. @@ -64,7 +64,13 @@ It has three stages: notarizes, staples, and verifies the app and DMG. It then uploads one Actions artifact containing the DMG, `SHA256SUMS.txt`, and metadata binding the producer to the tag and exact source revision. -3. `publish` is the only job with `contents: write`. It downloads every required +3. `produce_windows` waits behind the protected `windows-release` Environment, + requires the selected provider's protected one-file signing interface, builds + the per-user NSIS installer, verifies the exact Authenticode identity and + timestamp on the installer and installed executable payloads, and exercises + preservation/removal before uploading its producer bundle. Until a provider + and CI identity are selected, this job intentionally fails at preflight. +4. `publish` is the only job with `contents: write`. It downloads every required producer bundle, requires the producer set to match exactly, recomputes all sizes and SHA-256 digests, and verifies tag/revision/platform metadata before it creates a GitHub Release. @@ -82,9 +88,9 @@ replacement draft. A failure before publication keeps the release private and attempts to remove only the draft created by that run. An existing release is never overwritten. -Signing and notarization secrets exist only in the macOS producer. The -publisher receives no signing credentials, and producers never receive -`contents: write`. +Signing and notarization secrets exist only in their protected platform +producer. The publisher receives no signing credentials, and producers never +receive `contents: write`. ## Adding a release platform diff --git a/docs/windows-release-checklist.md b/docs/windows-release-checklist.md new file mode 100644 index 0000000..3fe6095 --- /dev/null +++ b/docs/windows-release-checklist.md @@ -0,0 +1,79 @@ +# Issue #113 — Windows 11 x64 release qualification + +This checklist is intentionally unchecked where physical hardware, a selected +signing provider, or current security definitions are required. Hosted CI +evidence must not be substituted for these items. + +## Release identity and installer operations + +- [ ] Select an Authenticode provider/certificate and record the exact subject, + leaf thumbprint, timestamp service, legal owner, and expected Explorer + publisher text. +- [ ] Configure a protected `windows-release` Environment with separate approval, + no administrator bypass, and a least-privilege CI identity. +- [ ] Document provider credential/key storage, access review, rotation, expiry, + compromise, incident, and revocation procedures; perform one revocation drill. +- [ ] Confirm the protected provider exposes the reviewed one-file signing wrapper + contract used by `scripts/sign-windows.ps1`. +- [ ] On a clean Windows 11 x64 account, verify the final NSIS installer, installed + app, uninstaller, and every executable payload have the exact signer and a + trusted timestamp. +- [ ] Install, upgrade, attempt a downgrade, uninstall with preservation, and + uninstall with explicit data removal. Record screenshots of the disclosed + `%LOCALAPPDATA%\LSDJ` path and measured size. +- [ ] Repeat installation under a profile containing spaces and non-ASCII text, + with Windows long-path support disabled. +- [ ] Confirm Start menu behavior, window/titlebar, file/folder dialogs, + notifications, opener/trash behavior, packaged resources, update/restart, and + WebView2 present/missing/offline failure cases. + +## Hardware record + +Record exact Windows build, CPU, RAM, GPU, VRAM, NVIDIA driver, PyTorch/CUDA +runtime, audio device/driver, WASAPI rate/format/buffer, MIDI devices, FLX4 +firmware/driver, security state, LSDJ revision, and model/runtime revisions. + +- [ ] Establish and document the minimum NVIDIA GPU, VRAM, driver, CPU, RAM, and + free-disk floor from measured results. +- [ ] Run both MRT2 decks for at least ten minutes at 25 frames / approximately + one second. Require zero engine-reported underruns and capture p50/p95/p99 + generation latency, queue depth, temperature, and throttling. +- [ ] Run both armed decks for at least ten minutes at 5 frames / approximately + 200 ms with the same zero-underrun and telemetry gate. +- [ ] Validate default-device selection/change, WASAPI shared-mode 48 kHz and + non-48 kHz devices, stereo output, FLX4 four-channel master/cue, removal, + renegotiation, and sleep/resume. +- [ ] Validate FLX4 WinMM naming, transport, mixer, jog wheels, performance pads, + LEDs, required SysEx, hotplug/reconnect, and actionable device-contention errors. +- [ ] Run Stable Audio music, SFX, audio-to-audio, continuation, inpainting, + Small/Medium, LoRA, cancellation, and long-duration validation while both decks + remain active. Record CPU/RAM impact and deck telemetry. +- [ ] Confirm normal quit, forced host exit, worker crash, update, and uninstall + leave no Python, model, or GPU worker descendants. + +## Security and release response + +- [ ] Test the release candidate against current Microsoft Defender definitions; + record platform, engine, intelligence versions, detection result, and submission + ID/disposition for any false-positive report. +- [ ] Exercise the documented signing-key compromise and bad-signature release + stop path without publishing a release. +- [ ] Confirm all model services bind to `127.0.0.1`, the installer creates no + firewall exception, and no public listener appears during first run or playback. +- [ ] Confirm a clean machine installs verified MRT2 and Stable Audio + runtimes/models without system Python, Git, CUDA toolkit, WSL, compiler, or shell. +- [ ] Interrupt and corrupt each runtime/model download and promotion; the prior + verified version must remain usable and diagnostics must identify recovery. +- [ ] Confirm the single publisher refuses missing, unsigned, invalid, + untimestamped, duplicate, or unexpected Windows artifacts. + +## External blockers + +- Authenticode provider/certificate, exact publisher subject, timestamp service, + protected CI identity, and credential lifecycle decisions. +- Physical Windows 11 x64 + supported NVIDIA host with current drivers. +- Pioneer/AlphaTheta DDJ-FLX4 plus representative WASAPI devices. +- Current Defender/SmartScreen observation on the signed release candidate. +- #110 production runtime installation and NVIDIA qualification. +- #111 TFLite Stable Audio runtime installation and parity qualification. +- #108 final notices/acknowledgement and repository licensing decisions. diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 0000000..fbfc361 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,205 @@ +# Windows 11 x64 packaging and operations + +Windows is a release candidate, not yet a supported LSDJ platform. The software +packaging and hosted-CI contracts in this document do not replace the unchecked +NVIDIA, WASAPI, MIDI, FLX4, sleep/resume, and Defender qualification in +[`windows-release-checklist.md`](windows-release-checklist.md). + +## Installer contract + +LSDJ produces one x64 NSIS `-setup.exe` for Windows 11. It installs for the +current user and does not request administrator privileges or add a firewall +rule. The installer creates an LSDJ Start menu entry, records calendar-version +metadata derived from the protected `vYYYY.MM.N` release tag, permits upgrades, +and refuses downgrades. MSI, Microsoft Store, portable ZIP, Windows 10, Windows +on ARM, and per-machine installation are outside this release. + +Application files and app-managed files use a deliberately shallow +`%LOCALAPPDATA%\LSDJ` tree so Python environments and model paths continue to +work when Windows long-path support is disabled: + +- `config` — LSDJ settings; +- `data` — generated songs, samples, and user registries; +- `cache` — reproducible cache data; +- `assets` — verified model weights and managed runtimes; +- `staging` — interrupted candidates on the same filesystem as `assets`; +- `backend\current\lsdj_backend.exe` — the stable launcher atomically promoted + by the #110/#111 runtime work. + +The packaged shell enables the `managed-backend` feature. If the verified +launcher is absent, decks and generation report that the managed runtime is not +installed. They never fall through to a system Python, `uv`, Git, a CUDA toolkit, +WSL, or a shell command. The launcher is a narrow packaging seam; #110 and #111 +remain responsible for installing and selecting the PyTorch MRT2 and TFLite +Stable Audio implementations behind it. + +## Upgrade and uninstall + +An upgrade replaces application payloads in place and preserves the entire +app-managed tree. A normal uninstall removes the app binary, declared packaged +resources, registry entry, and shortcuts, while preserving downloaded models, +runtimes, settings, and user data. + +The graphical uninstaller offers an unchecked option to remove the preserved +data. If selected, it calculates the tree size and presents a second confirmation +showing `%LOCALAPPDATA%\LSDJ` and the measured KiB before deletion. Removal is +allowed only while the installer-owned `.lsdj-data-root` marker is a plain file +containing LSDJ's exact application identifier; a same-named, empty, linked, or +reparse-point entry is not sufficient. Marker creation uses exclusive Windows +file creation, and marker validation opens the reparse entry itself while +denying write/delete sharing, so neither operation follows or overwrites a link +raced into the marker path. + +Before installation, a hidden, always-selected NSIS section runs before Tauri's +path-creating `SetOutPath`. It canonicalizes the target, requires it to be +exactly `%LOCALAPPDATA%\LSDJ`, and records whether the root was absent or already +owned. The pre-install hook then accepts or creates a new root only when that +early probe saw it absent and the result is a plain empty directory. Creation in +the hook is required when `/D` puts application binaries somewhere other than +the fixed LocalAppData data root. A pre-existing empty root is rejected rather +than guessed to be LSDJ-owned. A pre-existing markerless root is adopted only +when its top level is exactly the five plain directories created by LSDJ's path +contract (`config`, `data`, `cache`, `assets`, and `staging`); foreign, partial, +file-bearing, and recursively reparse-bearing layouts are rejected. Root +junctions, symlinks, and other reparse points are always rejected. + +Explicit purge scans the tree without traversing reparse points before measuring +it, repeats the canonical-root, ownership-marker, and tree checks immediately +before deletion, and uses a custom recursive walk that never follows links or +uses a broad `RMDir /r`. A reparse point or marker replacement fails the purge +before ordinary uninstall payload deletion when detected during the initial +check, and always preserves the remaining tree. The equivalent explicit +automation switch is `/PURGE-LSDJ-DATA`; `/S` alone always preserves data. There +is no caller-supplied recursive target. + +NSIS installed uninstallers are self-copy launchers. A direct invocation such +as `uninstall.exe /S /PURGE-LSDJ-DATA` reports whether the temporary worker +started, not the worker's final script status. Automation that must distinguish +a completed uninstall (`0`) from a fail-closed refusal (`2`) must copy the +uninstaller to a unique temporary executable, run that copy while waiting with +final unquoted `_?=`, and then remove the copy. This is the +NSIS-documented worker form; the `_?=` argument must remain last so paths with +spaces are preserved. + +## WebView2 + +Windows 11 normally receives the evergreen WebView2 Runtime with Windows. LSDJ's +NSIS installer still uses Tauri's `downloadBootstrapper` mode when the runtime is +missing. This keeps the installer small and lets Microsoft's evergreen runtime +receive security updates independently. + +If WebView2 is already present, installation works offline. If it is absent, the +bootstrapper needs a network connection; download or installation failure aborts +with an actionable message instead of installing an app that cannot open. Users +may install Microsoft's WebView2 Evergreen Runtime separately and retry. Moving +to Tauri's roughly 127 MB `offlineInstaller` mode is a future reviewed release +policy change, not an automatic fallback. + +## Local services and firewall + +The Rust host allocates ephemeral ports and every model service binds only to +`127.0.0.1`. The installer opens no inbound port, adds no public-network binding, +and creates no Windows Firewall exception. Child processes live in a Windows Job +Object and are terminated as a tree on quit or host failure. Hosted CI exercises +the process contract without model hardware; abnormal exit with real CUDA work +remains a physical-machine gate. + +## Authenticode release gate + +Unsigned development installers are produced only in pull-request CI and are +labelled `windows-x64-unsigned-development`. They are not release inputs. CI runs +the release verifier against them and requires rejection. + +A protected `windows-release` Environment gates the release producer. The repo +defines a provider-neutral, non-shell interface: + +- `WINDOWS_SIGN_COMMAND_PATH` maps to + `LSDJ_WINDOWS_SIGN_COMMAND_PATH`, an absolute protected path to a reviewed + wrapper that accepts exactly one file path; +- `WINDOWS_EXPECTED_CERTIFICATE_SHA1` maps to the exact approved leaf + certificate thumbprint; and +- `WINDOWS_EXPECTED_SUBJECT` maps to the exact approved certificate subject and + expected Windows publisher identity. + +The selected provider must provision its credentialed wrapper after Environment +approval. The wrapper owns key access and timestamp-server configuration. The +repo never accepts a command string, PFX, password, or unverified subject. The +protected preflight also requires the Windows SDK `signtool.exe`. Every sign +operation immediately requires a valid Authenticode chain, exact leaf thumbprint +and subject, a timestamp certificate, and successful `signtool /pa` verification. +The installed app, uninstaller, executable payloads, and final NSIS installer are +verified again before the producer bundle is uploaded. + +No provider, certificate, publisher subject, protected CI identity, key storage, +rotation process, or revocation process has been selected yet. Consequently the +release job intentionally fails at signing preflight today and no output from +this branch is represented as signed. The owner decisions and operational drill +are explicit gates in the release checklist. + +The single publisher has no signing credentials. It requires the exact +`macos-arm64` and `windows-x64` producer set, recomputes sizes and SHA-256 hashes, +and refuses to create a public release if Windows production, signature +verification, or artifact verification is missing. + +## Defender and SmartScreen response + +Authenticode establishes publisher and file integrity; it does not guarantee +SmartScreen reputation or that Microsoft Defender and third-party products will +never flag a new build. + +For a report: + +1. Do not advise bypassing or disabling protection. Quarantine the artifact and + record the LSDJ version, download URL, SHA-256, signature status, Windows + build, Defender platform/engine/security-intelligence versions, and detection + name. +2. Compare the file with the release index and verify the expected Authenticode + subject, thumbprint, and timestamp. Treat any mismatch as a security incident; + keep the release private or withdraw it and begin the provider's revocation + procedure. +3. If identity and hashes match, reproduce on a clean Windows 11 system with + current definitions and submit the exact artifact to Microsoft's malware + analysis portal as a suspected false positive. Preserve the submission ID in + the linked GitHub issue. +4. Publish the vendor disposition. Rebuild only from the protected tag workflow; + never re-sign or replace a published asset by hand. + +The final expected publisher text, support contact, and certificate incident +owner must be filled in after the provider decision and before Windows support is +announced. + +## Diagnostics and known limitations + +- Confirm the installer hash against `SHA256SUMS.txt` and `release-index.json`. +- In Explorer, open **Properties → Digital Signatures** and require the publisher + documented for the release. Do not install if the signature is absent or + invalid. +- Model/runtime data and partial-download staging live under + `%LOCALAPPDATA%\LSDJ`; include sizes and runtime/model revisions in a report, + but never attach model weights or credentials. +- Local service logs are bounded and credential-redacted. There is no remote + service or firewall troubleshooting step because the supported binding is + loopback only. +- The minimum NVIDIA GPU, VRAM, driver, PyTorch/CUDA runtime, CPU, RAM, and free + disk are deliberately unspecified until measured qualification completes. +- WASAPI formats and device recovery, WinMM MIDI, FLX4 routing and LEDs, + sleep/resume, and model performance are not qualified by hosted CI. + +## Build and CI + +`scripts/build-windows-installer.ps1` accepts an exact release-shaped tag and +derives Windows/Tauri version metadata. `-UnsignedDevelopment` passes Tauri's +`--no-sign` and is the only pull-request build mode. `-Release` loads the +sign-command configuration and fails before packaging when protected identity +configuration is unavailable. + +Hosted `windows-2025` CI builds two unsigned versions, installs and upgrades +them, rejects a downgrade, verifies default preservation and explicit purge, +checks version metadata and the Start menu shortcut, and exercises a conservative +sub-`MAX_PATH` install location containing spaces and Unicode. It also attacks +ownership with pre-existing empty and foreign roots, install-time and purge-time +root junctions, marker junctions, a marker replacement between confirmation and +deletion, and a nested junction; every outside target must remain untouched. The +fresh-install success case plus empty-root rejection also guards the required +ordering of the early ownership probe relative to Tauri's `SetOutPath`. These +checks do not claim hardware or antivirus qualification. diff --git a/scripts/assert-windows-release-rejects-unsigned.ps1 b/scripts/assert-windows-release-rejects-unsigned.ps1 new file mode 100644 index 0000000..ce7b1a7 --- /dev/null +++ b/scripts/assert-windows-release-rejects-unsigned.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Path +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Hosted pull-request CI deliberately creates unsigned development installers. +# Prove the release verifier refuses one with an otherwise syntactically valid +# expected identity. The exact NotSigned failure is required so a missing SDK +# tool or unrelated script error cannot masquerade as successful rejection. +$powerShell = (Get-Process -Id $PID).Path +$PSNativeCommandUseErrorActionPreference = $false +$output = & $powerShell -NoLogo -NoProfile -NonInteractive -File ` + "$PSScriptRoot/verify-windows-signatures.ps1" ` + -Path $Path ` + -ExpectedCertificateSha1 ('0' * 40) ` + -ExpectedSubject 'CN=Unsigned CI Sentinel' 2>&1 +$exitCode = $LASTEXITCODE +$rendered = $output | Out-String +if ($exitCode -eq 0) { + throw 'Release signature verification accepted an unsigned development installer.' +} +if ($rendered -notmatch 'Authenticode signature status is NotSigned') { + throw "Release verification failed for an unexpected reason instead of rejecting an unsigned artifact:`n$rendered" +} +Write-Host 'Release signature verification correctly rejected the unsigned development installer.' +exit 0 diff --git a/scripts/build-windows-installer.ps1 b/scripts/build-windows-installer.ps1 new file mode 100644 index 0000000..6fef73a --- /dev/null +++ b/scripts/build-windows-installer.ps1 @@ -0,0 +1,111 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^v[0-9]{4}\.(0[1-9]|1[0-2])\.[1-9][0-9]*$')] + [string] $ReleaseTag, + + [switch] $Release, + + [switch] $UnsignedDevelopment +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $IsWindows -or -not [Environment]::Is64BitOperatingSystem -or -not [Environment]::Is64BitProcess) { + throw 'LSDJ Windows installers must be built by a 64-bit process on Windows x64.' +} +if ($Release -eq $UnsignedDevelopment) { + throw 'Choose exactly one of -Release or -UnsignedDevelopment.' +} + +$match = [regex]::Match($ReleaseTag, '^v(?[0-9]{4})\.(?[0-9]{2})\.(?[1-9][0-9]*)$') +$version = '{0}.{1}.{2}' -f ` + [int] $match.Groups['year'].Value, ` + [int] $match.Groups['month'].Value, ` + [int] $match.Groups['build'].Value + +$repoRoot = Split-Path -Parent $PSScriptRoot +$tauriRoot = Join-Path $repoRoot 'src-tauri' +$frontendDist = Join-Path $repoRoot 'frontend/dist' +if (-not (Test-Path -LiteralPath $frontendDist -PathType Container)) { + throw 'frontend/dist is missing; build the frontend before packaging.' +} + +$tempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() +} else { + $env:RUNNER_TEMP +} +$versionConfig = Join-Path $tempRoot "lsdj-windows-version-$([guid]::NewGuid().ToString('N')).json" +$ciInstallerHooks = $null +$versionConfiguration = @{ version = $version } +if ($UnsignedDevelopment) { + # Hosted installer tests need one synchronization point after purge + # confirmation and before the destructive revalidation. Compile that test + # branch only into explicitly unsigned development installers; the release + # config always uses the reviewed production hook directly. + $ciInstallerHooks = Join-Path $tempRoot "lsdj-windows-hooks-$([guid]::NewGuid().ToString('N')).nsh" + $productionHooks = Join-Path $tauriRoot 'windows/installer-hooks.nsh' + $ciHookText = "!define LSDJ_CI_ADVERSARIAL_TESTS`r`n" + + [System.IO.File]::ReadAllText($productionHooks) + [System.IO.File]::WriteAllText( + $ciInstallerHooks, + $ciHookText, + [System.Text.UTF8Encoding]::new($false) + ) + $versionConfiguration['bundle'] = @{ + windows = @{ + nsis = @{ installerHooks = $ciInstallerHooks } + } + } +} +[System.IO.File]::WriteAllText( + $versionConfig, + ($versionConfiguration | ConvertTo-Json -Depth 8 -Compress), + [System.Text.UTF8Encoding]::new($false) +) + +try { + $arguments = @( + 'tauri', 'build', '--ci', '--bundles', 'nsis', + '--features', 'managed-backend', '--config', $versionConfig + ) + if ($UnsignedDevelopment) { + $arguments += '--no-sign' + } else { + & "$PSScriptRoot/sign-windows.ps1" -Preflight + $arguments += @('--config', 'tauri.windows.release.conf.json') + } + + Push-Location $tauriRoot + try { + & cargo @arguments + if ($LASTEXITCODE -ne 0) { + throw "Tauri Windows packaging failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } +} finally { + Remove-Item -LiteralPath $versionConfig -Force -ErrorAction SilentlyContinue + if ($null -ne $ciInstallerHooks) { + Remove-Item -LiteralPath $ciInstallerHooks -Force -ErrorAction SilentlyContinue + } +} + +$bundleRoot = Join-Path $tauriRoot 'target/release/bundle/nsis' +$matchingInstallers = @( + Get-ChildItem -LiteralPath $bundleRoot -Filter '*-setup.exe' -File | + Where-Object { $_.VersionInfo.ProductVersion -eq $version } +) +if ($matchingInstallers.Count -ne 1) { + throw "Expected one Windows NSIS installer for version $version; found $($matchingInstallers.Count)." +} + +$installer = $matchingInstallers[0].FullName +Write-Host "Built Windows installer: $installer" +if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "installer=$installer" + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "version=$version" +} diff --git a/scripts/release_artifact.py b/scripts/release_artifact.py index 30db2d4..fcdc619 100644 --- a/scripts/release_artifact.py +++ b/scripts/release_artifact.py @@ -51,9 +51,9 @@ class ProducerPolicy: asset_count: int -# macOS is the sole required release producer initially. Adding a platform is -# an explicit policy change: add its producer here and to the publisher's -# --required-producer list in the workflow in the same reviewed change. +# Every policy entry is mandatory. Keep this set identical to the publisher's +# --required-producer arguments; omission, duplication, or an unexpected bundle +# fails before a GitHub Release is created. PRODUCER_POLICIES = { "macos-arm64": ProducerPolicy( platform="macos", @@ -61,6 +61,12 @@ class ProducerPolicy: asset_suffix=".dmg", asset_count=1, ), + "windows-x64": ProducerPolicy( + platform="windows", + architecture="x86_64", + asset_suffix=".exe", + asset_count=1, + ), } diff --git a/scripts/sign-windows.ps1 b/scripts/sign-windows.ps1 new file mode 100644 index 0000000..fea127c --- /dev/null +++ b/scripts/sign-windows.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [string] $Path, + + [switch] $Preflight +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Require-ProtectedValue { + param( + [Parameter(Mandatory = $true)] + [string] $Name + ) + + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Protected Windows release configuration '$Name' is missing." + } + return $value.Trim() +} + +$providerCommand = Require-ProtectedValue 'LSDJ_WINDOWS_SIGN_COMMAND_PATH' +$expectedThumbprint = (Require-ProtectedValue 'LSDJ_WINDOWS_EXPECTED_CERTIFICATE_SHA1') -replace '\s', '' +$expectedSubject = Require-ProtectedValue 'LSDJ_WINDOWS_EXPECTED_SUBJECT' + +if (-not [System.IO.Path]::IsPathFullyQualified($providerCommand)) { + throw 'LSDJ_WINDOWS_SIGN_COMMAND_PATH must be an absolute executable path.' +} +if ($expectedThumbprint -notmatch '^[0-9A-Fa-f]{40}$') { + throw 'LSDJ_WINDOWS_EXPECTED_CERTIFICATE_SHA1 must contain exactly 40 hexadecimal characters.' +} +$provider = Get-Item -LiteralPath $providerCommand -ErrorAction Stop +if (($provider.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $provider.PSIsContainer) { + throw 'The Windows signing provider command must be a plain executable file, not a link or directory.' +} +(Get-Command 'signtool.exe' -ErrorAction Stop) | Out-Null + +if ($Preflight) { + Write-Host "Windows signing interface is configured for subject '$expectedSubject' and certificate $($expectedThumbprint.ToUpperInvariant())." + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($Path)) { + throw 'Path is required when signing an executable payload.' +} + +$target = Get-Item -LiteralPath $Path -ErrorAction Stop +if ($target.PSIsContainer -or ($target.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Signing target must be a plain file: $Path" +} +if ($target.Extension -notin @('.exe', '.dll')) { + throw "Refusing to sign a non-executable payload: $($target.FullName)" +} + +# The selected provider owns key access and timestamp configuration. Its wrapper +# receives exactly one literal path, never a shell command string. This keeps the +# repo compatible with certificate-store, HSM, or managed/keyless providers +# without pretending one has been selected. +$global:LASTEXITCODE = 0 +& $provider.FullName $target.FullName +if ($LASTEXITCODE -ne 0) { + throw "Windows signing provider failed with exit code $LASTEXITCODE for $($target.FullName)." +} + +& "$PSScriptRoot/verify-windows-signatures.ps1" ` + -Path $target.FullName ` + -ExpectedCertificateSha1 $expectedThumbprint ` + -ExpectedSubject $expectedSubject +if ($LASTEXITCODE -ne 0) { + throw "Signature verification failed for $($target.FullName)." +} diff --git a/scripts/test-windows-installer.ps1 b/scripts/test-windows-installer.ps1 new file mode 100644 index 0000000..61117d4 --- /dev/null +++ b/scripts/test-windows-installer.ps1 @@ -0,0 +1,943 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $OlderInstaller, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $NewerInstaller, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string] $OlderVersion, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string] $NewerVersion, + + [switch] $RequireSigned +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $IsWindows -or $env:GITHUB_ACTIONS -ne 'true') { + throw 'The destructive installer lifecycle smoke test may run only on an isolated GitHub Actions Windows runner.' +} +if (-not [Environment]::Is64BitOperatingSystem -or -not [Environment]::Is64BitProcess) { + throw 'The Windows shipping smoke test requires an x64 OS and process.' +} + +$older = Get-Item -LiteralPath $OlderInstaller -ErrorAction Stop +$newer = Get-Item -LiteralPath $NewerInstaller -ErrorAction Stop +foreach ($installer in @($older, $newer)) { + if ($installer.PSIsContainer -or ($installer.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Installer must be a plain file: $($installer.FullName)" + } + if ($installer.Extension -ne '.exe' -or $installer.VersionInfo.ProductName -ne 'LSDJ') { + throw "Installer has unexpected Windows version metadata: $($installer.FullName)" + } +} +if ($older.VersionInfo.ProductVersion -ne $OlderVersion) { + throw "Older installer metadata is $($older.VersionInfo.ProductVersion), expected $OlderVersion." +} +if ($newer.VersionInfo.ProductVersion -ne $NewerVersion) { + throw "Newer installer metadata is $($newer.VersionInfo.ProductVersion), expected $NewerVersion." +} + +$dataRoot = Join-Path $env:LOCALAPPDATA 'LSDJ' +$expectedRoot = [System.IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'LSDJ')) +if ([System.IO.Path]::GetFullPath($dataRoot) -cne $expectedRoot -or (Split-Path -Leaf $dataRoot) -cne 'LSDJ') { + throw "Refusing to test an unexpected data root: $dataRoot" +} +if (Test-Path -LiteralPath $dataRoot) { + throw "The isolated runner is not clean; refusing to overwrite existing LSDJ data at $dataRoot." +} + +$app = Join-Path $dataRoot 'lsdj-app.exe' +$uninstaller = Join-Path $dataRoot 'uninstall.exe' +$marker = Join-Path $dataRoot '.lsdj-data-root' +$startMenuShortcut = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\LSDJ\LSDJ.lnk' +$registryKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\LSDJ' +$ciPurgeReady = Join-Path $env:TEMP 'lsdj-ci-before-purge.ready' +$ciInstallerTrace = Join-Path $env:TEMP 'lsdj-ci-installer.trace' +$ownerMarkerWithNul = [byte[]]::new(20) +[Text.Encoding]::ASCII.GetBytes('works.protocol.lsdj').CopyTo($ownerMarkerWithNul, 0) +$ownerMarkerWithNulHex = [Convert]::ToHexString($ownerMarkerWithNul) + +function Get-CiInstallerTrace { + if (Test-Path -LiteralPath $ciInstallerTrace -PathType Leaf) { + return [System.IO.File]::ReadAllText($ciInstallerTrace) + } + return '' +} + +function Write-CiInstallerTrace { + Write-Host 'CI installer trace:' + if (Test-Path -LiteralPath $ciInstallerTrace -PathType Leaf) { + Get-Content -LiteralPath $ciInstallerTrace | ForEach-Object { + Write-Host " $_" + } + } else { + Write-Host ' ' + } +} + +function Assert-CiInstallerTraceContract { + param( + [string[]] $Required = @(), + + [string[]] $Forbidden = @() + ) + + # Signed release installers compile every trace macro to no instructions. + if ($RequireSigned) { + return + } + + $trace = Get-CiInstallerTrace + $lastRequiredIndex = -1 + foreach ($needle in $Required) { + $requiredIndex = $trace.IndexOf( + $needle, + $lastRequiredIndex + 1, + [StringComparison]::Ordinal + ) + if ($requiredIndex -lt 0) { + Write-CiInstallerTrace + throw "CI installer trace is missing or misorders required checkpoint: $needle" + } + $lastRequiredIndex = $requiredIndex + } + foreach ($needle in $Forbidden) { + if ($trace.IndexOf($needle, [StringComparison]::Ordinal) -ge 0) { + Write-CiInstallerTrace + throw "CI installer trace reached forbidden checkpoint: $needle" + } + } +} + +function Invoke-CheckedProcess { + param( + [Parameter(Mandatory = $true)] + [string] $FilePath, + + [string[]] $ArgumentList = @(), + + [int[]] $ExpectedExitCodes = @(0) + ) + + Remove-Item -LiteralPath $ciInstallerTrace -Force -ErrorAction SilentlyContinue + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru + if ($process.ExitCode -notin $ExpectedExitCodes) { + $trace = Get-CiInstallerTrace + Write-CiInstallerTrace + throw "Process exited $($process.ExitCode), expected $($ExpectedExitCodes -join ', '): $FilePath $($ArgumentList -join ' ')`nCI installer trace:`n$trace" + } + return $process.ExitCode +} + +function Invoke-ExpectedFailure { + param( + [Parameter(Mandatory = $true)] + [string] $FilePath, + + [string[]] $ArgumentList = @() + ) + + Remove-Item -LiteralPath $ciInstallerTrace -Force -ErrorAction SilentlyContinue + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru + if ($process.ExitCode -eq 0) { + $trace = Get-CiInstallerTrace + Write-CiInstallerTrace + throw "Process unexpectedly succeeded: $FilePath $($ArgumentList -join ' ')`nCI installer trace:`n$trace" + } + return $process.ExitCode +} + +function New-UninstallerWorkerCopy { + param( + [Parameter(Mandatory = $true)] + [string] $FilePath + ) + + $workerName = "lsdj-uninstall-worker-$([Guid]::NewGuid().ToString('N')).exe" + $workerPath = Join-Path $env:RUNNER_TEMP $workerName + try { + Copy-Item -LiteralPath $FilePath -Destination $workerPath + return $workerPath + } catch { + Remove-Item -LiteralPath $workerPath -Force -ErrorAction SilentlyContinue + throw + } +} + +function Stop-UninstallerWorker { + param( + [System.Diagnostics.Process] $Process, + + [int] $TimeoutMilliseconds = 10000 + ) + + if ($null -eq $Process -or $Process.HasExited) { + return + } + + $Process.Kill($true) + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { + throw "Timed out terminating uninstaller worker process $($Process.Id)." + } +} + +function Invoke-ExpectedUninstallFailure { + param( + [Parameter(Mandatory = $true)] + [string] $FilePath, + + [Parameter(Mandatory = $true)] + [string] $InstallDirectory, + + [string[]] $ArgumentList = @() + ) + + # NSIS's installed uninstaller is only a self-copy launcher: its exit code + # reports whether the temporary worker started, not the worker's result. + # Exercise the documented worker form so CI observes the script exit code. + # https://nsis.sourceforge.io/Docs/AppendixD.html + # `_?=` must remain the final, unquoted command-line argument. + $workerPath = $null + $worker = $null + try { + $workerPath = New-UninstallerWorkerCopy -FilePath $FilePath + Remove-Item -LiteralPath $ciInstallerTrace -Force -ErrorAction SilentlyContinue + $worker = Start-Process ` + -FilePath $workerPath ` + -ArgumentList (@($ArgumentList) + "_?=$InstallDirectory") ` + -PassThru + if (-not $worker.WaitForExit(30000)) { + throw "Timed out waiting for uninstaller worker: $workerPath" + } + if ($worker.ExitCode -ne 2) { + $trace = Get-CiInstallerTrace + Write-CiInstallerTrace + throw "Uninstaller worker exited $($worker.ExitCode), expected 2: $workerPath$([Environment]::NewLine)CI installer trace:$([Environment]::NewLine)$trace" + } + return $worker.ExitCode + } finally { + try { + Stop-UninstallerWorker -Process $worker + } finally { + if ($null -ne $workerPath) { + Remove-Item -LiteralPath $workerPath -Force -ErrorAction SilentlyContinue + } + } + } +} + +function Require-InstalledVersion { + param([string] $Version) + + if (-not (Test-Path -LiteralPath $app -PathType Leaf)) { + throw "Installed app is missing: $app" + } + $actual = (Get-Item -LiteralPath $app).VersionInfo.ProductVersion + if ($actual -ne $Version) { + throw "Installed app version is $actual, expected $Version." + } + $registered = (Get-ItemProperty -LiteralPath $registryKey -Name DisplayVersion).DisplayVersion + if ($registered -ne $Version) { + throw "Registered app version is $registered, expected $Version." + } +} + +function Set-DisplayVersionEvidence { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Value + ) + + New-ItemProperty -LiteralPath $registryKey -Name DisplayVersion -Value $Value ` + -PropertyType String -Force | Out-Null + $actual = (Get-ItemProperty -LiteralPath $registryKey -Name DisplayVersion).DisplayVersion + $rawKey = Get-Item -LiteralPath $registryKey -ErrorAction Stop + $rawKind = $rawKey.GetValueKind('DisplayVersion') + $rawValue = $rawKey.GetValue( + 'DisplayVersion', + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ($actual -cne $Value -or + $rawKind -ne [Microsoft.Win32.RegistryValueKind]::String -or + $rawValue -cne $Value) { + throw "Could not establish exact DisplayVersion test evidence: $Value" + } +} + +function Require-No-Workers { + $remaining = @(Get-Process -Name 'lsdj-app', 'lsdj_backend' -ErrorAction SilentlyContinue) + if ($remaining.Count -ne 0) { + throw "Installer lifecycle left LSDJ processes running: $($remaining.Name -join ', ')" + } +} + +function Get-UninstallRegistrySnapshot { + $key = Get-Item -LiteralPath $registryKey -ErrorAction Stop + $values = foreach ($name in @($key.GetValueNames() | Sort-Object)) { + $value = $key.GetValue( + $name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ($value -is [byte[]]) { + $value = [Convert]::ToBase64String($value) + } elseif ($value -is [string[]]) { + $value = $value -join "`0" + } else { + $value = [string] $value + } + [ordered]@{ + Name = $name + Kind = [string] ($key.GetValueKind($name)) + Value = $value + } + } + return (ConvertTo-Json -InputObject @($values) -Compress) +} + +function Get-InstalledStateSnapshot { + foreach ($path in @($app, $uninstaller, $marker, $settingsSentinel, $modelSentinel)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Cannot snapshot missing installed state: $path" + } + } + + $markerEntry = Get-Item -LiteralPath $marker -Force -ErrorAction Stop + if (($markerEntry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Cannot snapshot a reparse-point ownership marker: $marker" + } + $markerLinkTypeProperty = $markerEntry.PSObject.Properties['LinkType'] + $markerLinkType = if ($null -eq $markerLinkTypeProperty) { + '' + } else { + [string] $markerLinkTypeProperty.Value + } + $markerTargetProperty = $markerEntry.PSObject.Properties['Target'] + $markerTarget = if ($null -eq $markerTargetProperty -or $null -eq $markerTargetProperty.Value) { + '' + } elseif ($markerTargetProperty.Value -is [string[]]) { + $markerTargetProperty.Value -join "`0" + } else { + [string] $markerTargetProperty.Value + } + + return [ordered]@{ + App = (Get-FileHash -LiteralPath $app -Algorithm SHA256).Hash + Uninstaller = (Get-FileHash -LiteralPath $uninstaller -Algorithm SHA256).Hash + Marker = (Get-FileHash -LiteralPath $marker -Algorithm SHA256).Hash + MarkerAttributes = [int64] $markerEntry.Attributes + MarkerLinkType = $markerLinkType + MarkerTarget = $markerTarget + Settings = (Get-FileHash -LiteralPath $settingsSentinel -Algorithm SHA256).Hash + Model = (Get-FileHash -LiteralPath $modelSentinel -Algorithm SHA256).Hash + Registry = (Get-UninstallRegistrySnapshot) + } +} + +function Assert-InstalledStateSnapshotUnchanged { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary] $Before + ) + + $after = Get-InstalledStateSnapshot + foreach ($name in $Before.Keys) { + if ($after[$name] -cne $Before[$name]) { + throw "Rejected installer changed installed state: $name" + } + } +} + +function Remove-ReparseDirectoryEntry { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $entry = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or -not $entry.PSIsContainer) { + throw "Refusing to unlink an entry that is not a directory reparse point: $Path" + } + [System.IO.Directory]::Delete($entry.FullName) +} + +function New-RecognizedLsdjLayout { + New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null + foreach ($name in @('config', 'data', 'cache', 'assets', 'staging')) { + New-Item -ItemType Directory -Path (Join-Path $dataRoot $name) -Force | Out-Null + } +} + +function Start-LifecycleScenario { + param( + [Parameter(Mandatory = $true)] + [string] $Name + ) + + Write-Host "[Windows installer lifecycle] $Name" +} + +# An empty foreign root is still not evidence of ownership. This also proves +# the hook's hidden root probe runs before Tauri's path-creating SetOutPath: +# otherwise fresh and pre-existing empty roots would be indistinguishable. +Start-LifecycleScenario 'reject pre-existing empty data root' +New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +if ((Test-Path -LiteralPath $marker) -or (Test-Path -LiteralPath $app)) { + throw 'Installer claimed a pre-existing empty LocalAppData root.' +} +Remove-Item -LiteralPath $dataRoot -Recurse -Force + +# A foreign pre-existing directory must never be claimed just because it has the +# expected basename. The failed installer must not add a marker or payload. +Start-LifecycleScenario 'reject foreign pre-existing data root' +New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null +$foreignSentinel = Join-Path $dataRoot 'foreign-owner.txt' +[System.IO.File]::WriteAllText($foreignSentinel, 'not LSDJ') +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +if (-not (Test-Path -LiteralPath $foreignSentinel -PathType Leaf) -or + (Test-Path -LiteralPath $marker) -or + (Test-Path -LiteralPath $app)) { + throw 'Installer claimed or modified a pre-existing foreign LocalAppData root.' +} +Remove-Item -LiteralPath $dataRoot -Recurse -Force + +# A root junction must be rejected before either its target or an ownership +# marker is touched. +Start-LifecycleScenario 'reject install-time data-root junction' +$rootJunctionTarget = Join-Path $env:RUNNER_TEMP 'lsdj-root-junction-target' +New-Item -ItemType Directory -Path $rootJunctionTarget -Force | Out-Null +$rootJunctionSentinel = Join-Path $rootJunctionTarget 'outside.txt' +[System.IO.File]::WriteAllText($rootJunctionSentinel, 'outside root') +New-Item -ItemType Junction -Path $dataRoot -Target $rootJunctionTarget | Out-Null +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +if (-not (Test-Path -LiteralPath $rootJunctionSentinel -PathType Leaf) -or + (Test-Path -LiteralPath (Join-Path $rootJunctionTarget '.lsdj-data-root'))) { + throw 'Installer followed or marked a LocalAppData root junction.' +} +Remove-ReparseDirectoryEntry $dataRoot +Remove-Item -LiteralPath $rootJunctionTarget -Recurse -Force + +# Even an otherwise recognizable legacy layout is unsafe when its marker entry +# is a junction/reparse point. +Start-LifecycleScenario 'reject install-time ownership-marker junction' +New-RecognizedLsdjLayout +$installMarkerTarget = Join-Path $env:RUNNER_TEMP 'lsdj-install-marker-target' +New-Item -ItemType Directory -Path $installMarkerTarget -Force | Out-Null +$installMarkerSentinel = Join-Path $installMarkerTarget 'outside.txt' +[System.IO.File]::WriteAllText($installMarkerSentinel, 'outside marker') +New-Item -ItemType Junction -Path $marker -Target $installMarkerTarget | Out-Null +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +if (-not (Test-Path -LiteralPath $installMarkerSentinel -PathType Leaf) -or + (Test-Path -LiteralPath $app)) { + throw 'Installer followed or accepted a marker reparse point.' +} +Remove-ReparseDirectoryEntry $marker +Remove-Item -LiteralPath $dataRoot -Recurse -Force +Remove-Item -LiteralPath $installMarkerTarget -Recurse -Force + +# A recognizable five-root shell is not safe to adopt if anything nested below +# it is a junction. Installation must not mark or write through the link. +Start-LifecycleScenario 'reject nested junction during legacy adoption' +New-RecognizedLsdjLayout +$installNestedTarget = Join-Path $env:RUNNER_TEMP 'lsdj-install-nested-target' +New-Item -ItemType Directory -Path $installNestedTarget -Force | Out-Null +$installNestedSentinel = Join-Path $installNestedTarget 'outside.txt' +[System.IO.File]::WriteAllText($installNestedSentinel, 'outside nested install') +$installNestedJunction = Join-Path $dataRoot 'assets\linked-outside' +New-Item -ItemType Junction -Path $installNestedJunction -Target $installNestedTarget | Out-Null +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +if (-not (Test-Path -LiteralPath $installNestedSentinel -PathType Leaf) -or + (Test-Path -LiteralPath $marker) -or + (Test-Path -LiteralPath $app)) { + throw 'Installer adopted or followed a nested directory reparse point.' +} +Remove-ReparseDirectoryEntry $installNestedJunction +Remove-Item -LiteralPath $dataRoot -Recurse -Force +Remove-Item -LiteralPath $installNestedTarget -Recurse -Force + +# The native validator reads one byte beyond the exact owner identifier. This +# rejects an embedded trailing NUL even though string conversion alone could +# make that 20-byte file compare equal to the expected 19-character text. +Start-LifecycleScenario 'reject install with NUL-extended ownership marker' +New-RecognizedLsdjLayout +$nulInstallSentinel = Join-Path $dataRoot 'data\nul-marker-install.txt' +[System.IO.File]::WriteAllText($nulInstallSentinel, 'preserve NUL marker root') +[System.IO.File]::WriteAllBytes($marker, $ownerMarkerWithNul) +Invoke-ExpectedFailure $older.FullName @('/S') | Out-Null +$actualNulMarkerHex = [Convert]::ToHexString([System.IO.File]::ReadAllBytes($marker)) +if ($actualNulMarkerHex -cne $ownerMarkerWithNulHex -or + -not (Test-Path -LiteralPath $nulInstallSentinel -PathType Leaf) -or + (Test-Path -LiteralPath $app)) { + throw 'Installer accepted or modified a NUL-extended ownership marker root.' +} +Remove-Item -LiteralPath $dataRoot -Recurse -Force + +# The one markerless migration case is the complete five-root layout created by +# platform_paths.rs. It may be adopted, upgraded, and preserved normally. +Start-LifecycleScenario 'adopt recognized markerless legacy layout' +New-RecognizedLsdjLayout +$legacySentinel = Join-Path $dataRoot 'data\recognized-layout.txt' +[System.IO.File]::WriteAllText($legacySentinel, 'recognized LSDJ layout') +Invoke-CheckedProcess $older.FullName @('/S') +if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + throw 'Installer did not establish ownership for a recognized LSDJ layout.' +} +Invoke-CheckedProcess $uninstaller @('/S') +if (-not (Test-Path -LiteralPath $legacySentinel -PathType Leaf)) { + throw 'Default uninstall did not preserve an adopted LSDJ layout.' +} +Remove-Item -LiteralPath $dataRoot -Recurse -Force + +# Initial per-user install: version metadata, Start menu integration, and the +# marker that scopes the optional destructive uninstall. +Start-LifecycleScenario 'fresh per-user install' +Invoke-CheckedProcess $older.FullName @('/S') +Require-InstalledVersion $OlderVersion +if (-not (Test-Path -LiteralPath $startMenuShortcut -PathType Leaf)) { + throw "Start menu shortcut is missing: $startMenuShortcut" +} +if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + throw 'Installer did not create the data-root ownership marker.' +} + +$settingsSentinel = Join-Path $dataRoot 'data\用户 settings\preserve.txt' +$modelSentinel = Join-Path $dataRoot 'assets\models with spaces\模型.bin' +New-Item -ItemType Directory -Path (Split-Path -Parent $settingsSentinel) -Force | Out-Null +New-Item -ItemType Directory -Path (Split-Path -Parent $modelSentinel) -Force | Out-Null +[System.IO.File]::WriteAllText($settingsSentinel, 'preserve settings') +[System.IO.File]::WriteAllText($modelSentinel, 'preserve model') + +# Upgrade in place preserves app-managed data; the old signed/unsigned binary is +# replaced and registry metadata follows the newer calendar version. +Start-LifecycleScenario 'upgrade in place and preserve app-managed data' +Invoke-CheckedProcess $newer.FullName @('/S', '/UPDATE') +Assert-CiInstallerTraceContract -Required @( + "preinstall: installed version=$OlderVersion validity=1", + 'preinstall: version compare=1', + 'preinstall: ready', + 'postinstall: begin' +) -Forbidden @('abort:') +Require-InstalledVersion $NewerVersion +foreach ($sentinel in @($settingsSentinel, $modelSentinel)) { + if (-not (Test-Path -LiteralPath $sentinel -PathType Leaf)) { + throw "Upgrade removed preserved data: $sentinel" + } +} +Require-No-Workers + +$registrySentinelName = 'LsdjCiPreserve' +$registrySentinelValue = 'preserve registry metadata' +New-ItemProperty -LiteralPath $registryKey -Name $registrySentinelName ` + -Value $registrySentinelValue -PropertyType String -Force | Out-Null +$registryBaseline = Get-ItemProperty -LiteralPath $registryKey + +function Require-RejectedInstallPreservedState { + param( + [AllowEmptyString()] + [string] $ExpectedDisplayVersion = $NewerVersion, + + [switch] $DisplayVersionMissing + ) + + if (-not (Test-Path -LiteralPath $app -PathType Leaf)) { + throw "Rejected installer removed the installed app: $app" + } + $actualAppVersion = (Get-Item -LiteralPath $app).VersionInfo.ProductVersion + if ($actualAppVersion -ne $NewerVersion) { + throw "Rejected installer changed app version to $actualAppVersion." + } + + $registered = Get-ItemProperty -LiteralPath $registryKey -ErrorAction Stop + $displayVersionProperty = $registered.PSObject.Properties['DisplayVersion'] + if ($DisplayVersionMissing) { + if ($null -ne $displayVersionProperty) { + throw 'Rejected installer recreated missing DisplayVersion metadata.' + } + } elseif ($null -eq $displayVersionProperty -or + $displayVersionProperty.Value -cne $ExpectedDisplayVersion) { + throw "Rejected installer changed DisplayVersion metadata." + } + + foreach ($name in @('DisplayName', 'InstallLocation', 'UninstallString', $registrySentinelName)) { + if ($registered.$name -cne $registryBaseline.$name) { + throw "Rejected installer changed registry metadata: $name" + } + } + foreach ($sentinel in @($settingsSentinel, $modelSentinel)) { + if (-not (Test-Path -LiteralPath $sentinel -PathType Leaf)) { + throw "Rejected installer modified app-managed data: $sentinel" + } + } + Require-No-Workers +} + +$forbiddenPostVersionGuardTrace = @( + 'preinstall: begin', + 'preinstall: canonical', + 'preinstall: ready', + 'postinstall:' +) + +# A same-version unattended reinstall remains supported. Besides exercising the +# success path, its trace directly proves the pinned plugin reports valid=>1. +Start-LifecycleScenario 'same-version silent reinstall' +Invoke-CheckedProcess $newer.FullName @('/S') +Assert-CiInstallerTraceContract -Required @( + "preinstall: installed version=$NewerVersion validity=1", + 'preinstall: version compare=0', + 'preinstall: ready', + 'postinstall: begin' +) -Forbidden @('abort:') +Require-InstalledVersion $NewerVersion +foreach ($sentinel in @($settingsSentinel, $modelSentinel)) { + if (-not (Test-Path -LiteralPath $sentinel -PathType Leaf)) { + throw "Same-version reinstall modified app-managed data: $sentinel" + } +} +Require-No-Workers + +# allowDowngrades=false must reject unattended rollback and leave the newer app. +Start-LifecycleScenario 'reject unattended downgrade' +$rejectedStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S') -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + "preinstall: installed version=$NewerVersion validity=1", + 'preinstall: version compare=-1', + 'abort: unattended downgrade' +) -Forbidden $forbiddenPostVersionGuardTrace +Require-RejectedInstallPreservedState +Assert-InstalledStateSnapshotUnchanged -Before $rejectedStateBefore + +# Explicit update mode must not bypass the unattended downgrade guard. +Start-LifecycleScenario 'reject unattended downgrade in update mode' +$rejectedStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S', '/UPDATE') ` + -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + "preinstall: installed version=$NewerVersion validity=1", + 'preinstall: version compare=-1', + 'abort: unattended downgrade' +) -Forbidden $forbiddenPostVersionGuardTrace +Require-RejectedInstallPreservedState +Assert-InstalledStateSnapshotUnchanged -Before $rejectedStateBefore + +# Passive mode is unattended too, despite not setting NSIS's silent flag. +Start-LifecycleScenario 'reject passive downgrade' +$passiveStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/P') -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + 'abort: passive install unsupported' +) -Forbidden @('probe:', 'preinstall:', 'postinstall:') +Require-RejectedInstallPreservedState +Assert-InstalledStateSnapshotUnchanged -Before $passiveStateBefore + +# NSIS silent mode suppresses GUI initialization, so a combined /S /P reaches +# the repeated PREINSTALL policy check only after the read-only ownership probe. +Start-LifecycleScenario 'reject combined silent and passive mode' +$combinedPassiveStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S', '/P') ` + -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + 'abort: passive install unsupported' +) -Forbidden @('preinstall: begin', 'preinstall: ready', 'postinstall:') +Require-RejectedInstallPreservedState +Assert-InstalledStateSnapshotUnchanged -Before $combinedPassiveStateBefore + +# Existing install evidence with empty, malformed, or missing version metadata +# is unsafe. Each refusal must preserve the exact damaged evidence for repair. +Start-LifecycleScenario 'reject existing install with empty version metadata' +Set-DisplayVersionEvidence -Value '' +$rejectedStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S') -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + 'abort: installed version missing' +) -Forbidden $forbiddenPostVersionGuardTrace +Require-RejectedInstallPreservedState -ExpectedDisplayVersion '' +Assert-InstalledStateSnapshotUnchanged -Before $rejectedStateBefore + +Start-LifecycleScenario 'reject existing install with corrupt version metadata' +$corruptDisplayVersion = 'not-a-semver' +Set-DisplayVersionEvidence -Value $corruptDisplayVersion +$rejectedStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S') -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + "preinstall: version evidence installed=$corruptDisplayVersion present=1 existing=1", + "preinstall: installed version=$corruptDisplayVersion validity=0", + 'abort: installed version invalid' +) -Forbidden @( + 'preinstall: version compare=', + 'preinstall: begin', + 'preinstall: canonical', + 'preinstall: ready', + 'postinstall:' +) +Require-RejectedInstallPreservedState -ExpectedDisplayVersion $corruptDisplayVersion +Assert-InstalledStateSnapshotUnchanged -Before $rejectedStateBefore + +Start-LifecycleScenario 'reject existing install with missing version metadata' +Remove-ItemProperty -LiteralPath $registryKey -Name DisplayVersion +$rejectedStateBefore = Get-InstalledStateSnapshot +Invoke-CheckedProcess -FilePath $older.FullName -ArgumentList @('/S') -ExpectedExitCodes @(2) | Out-Null +Assert-CiInstallerTraceContract -Required @( + 'abort: installed version missing' +) -Forbidden $forbiddenPostVersionGuardTrace +Require-RejectedInstallPreservedState -DisplayVersionMissing +Assert-InstalledStateSnapshotUnchanged -Before $rejectedStateBefore +Set-DisplayVersionEvidence -Value $NewerVersion +Require-InstalledVersion $NewerVersion + +if ($RequireSigned) { + $installedPayloads = @( + Get-ChildItem -LiteralPath $dataRoot -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll') } | + ForEach-Object FullName + ) + if ($installedPayloads.Count -eq 0) { + throw 'The installed release contains no executable payloads to verify.' + } + & "$PSScriptRoot/verify-windows-signatures.ps1" -Path $installedPayloads +} + +# The default uninstall removes application binaries and shortcuts but preserves +# every app-owned runtime, model, setting, and user-data file. +Start-LifecycleScenario 'default uninstall preserves app-managed data' +Invoke-CheckedProcess $uninstaller @('/S') +Start-Sleep -Milliseconds 500 +Require-No-Workers +if (Test-Path -LiteralPath $app) { + throw 'Default uninstall left the application binary behind.' +} +if (Test-Path -LiteralPath $startMenuShortcut) { + throw 'Default uninstall left the Start menu shortcut behind.' +} +foreach ($sentinel in @($settingsSentinel, $modelSentinel)) { + if (-not (Test-Path -LiteralPath $sentinel -PathType Leaf)) { + throw "Default uninstall removed user data: $sentinel" + } +} + +# An invalid marker must make explicit automation fail closed while preserving +# the exact root. Restore the installer-owned marker only after proving refusal. +Start-LifecycleScenario 'reject purge with invalid ownership marker' +Invoke-CheckedProcess $newer.FullName @('/S') +[System.IO.File]::WriteAllText($marker, 'foreign-owner') +Invoke-ExpectedUninstallFailure ` + -FilePath $uninstaller ` + -InstallDirectory $dataRoot ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +if (-not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw 'Invalid ownership marker allowed explicit data removal.' +} +[System.IO.File]::WriteAllText($marker, 'works.protocol.lsdj') + +# A trailing raw NUL must also invalidate destructive ownership validation. +# The failed purge must stop before ordinary app removal and preserve the exact +# marker bytes and data root. +Start-LifecycleScenario 'reject purge with NUL-extended ownership marker' +Invoke-CheckedProcess $newer.FullName @('/S') +[System.IO.File]::WriteAllBytes($marker, $ownerMarkerWithNul) +Invoke-ExpectedUninstallFailure ` + -FilePath $uninstaller ` + -InstallDirectory $dataRoot ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +$actualNulMarkerHex = [Convert]::ToHexString([System.IO.File]::ReadAllBytes($marker)) +if ($actualNulMarkerHex -cne $ownerMarkerWithNulHex -or + -not (Test-Path -LiteralPath $dataRoot -PathType Container) -or + -not (Test-Path -LiteralPath $app -PathType Leaf)) { + throw 'NUL-extended ownership marker allowed uninstall or data removal.' +} +[System.IO.File]::WriteAllText($marker, 'works.protocol.lsdj') + +# Replace the entire owned root with a junction immediately before explicit +# purge. The purge-time root junction check must stop before even Tauri's narrow +# payload deletion, and +# the outside target must remain byte-for-byte untouched. +Start-LifecycleScenario 'reject purge after data-root junction replacement' +Invoke-CheckedProcess $newer.FullName @('/S') +$parkedRoot = Join-Path $env:RUNNER_TEMP 'lsdj-owned-root-parked' +Move-Item -LiteralPath $dataRoot -Destination $parkedRoot +$purgeRootTarget = Join-Path $env:RUNNER_TEMP 'lsdj-purge-root-target' +New-Item -ItemType Directory -Path $purgeRootTarget -Force | Out-Null +$purgeRootMarker = Join-Path $purgeRootTarget '.lsdj-data-root' +$purgeRootSentinel = Join-Path $purgeRootTarget 'lsdj-app.exe' +[System.IO.File]::WriteAllText($purgeRootMarker, 'works.protocol.lsdj') +[System.IO.File]::WriteAllText($purgeRootSentinel, 'outside root payload') +New-Item -ItemType Junction -Path $dataRoot -Target $purgeRootTarget | Out-Null +$parkedUninstaller = Join-Path $parkedRoot 'uninstall.exe' +Invoke-ExpectedUninstallFailure ` + -FilePath $parkedUninstaller ` + -InstallDirectory $parkedRoot ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +if (([System.IO.File]::ReadAllText($purgeRootSentinel)) -ne 'outside root payload' -or + -not (Test-Path -LiteralPath $parkedUninstaller -PathType Leaf)) { + throw 'Purge-time root junction was followed or ordinary uninstall continued after refusal.' +} +Remove-ReparseDirectoryEntry $dataRoot +Remove-Item -LiteralPath $purgeRootTarget -Recurse -Force +Move-Item -LiteralPath $parkedRoot -Destination $dataRoot + +# A marker junction is rejected both as ownership evidence and as a tree entry; +# its outside target must remain untouched. +Start-LifecycleScenario 'reject purge with ownership-marker junction' +Invoke-CheckedProcess $newer.FullName @('/S') +[System.IO.File]::Delete($marker) +$purgeMarkerTarget = Join-Path $env:RUNNER_TEMP 'lsdj-purge-marker-target' +New-Item -ItemType Directory -Path $purgeMarkerTarget -Force | Out-Null +$purgeMarkerSentinel = Join-Path $purgeMarkerTarget 'outside.txt' +[System.IO.File]::WriteAllText($purgeMarkerSentinel, 'outside purge marker') +New-Item -ItemType Junction -Path $marker -Target $purgeMarkerTarget | Out-Null +Invoke-ExpectedUninstallFailure ` + -FilePath $uninstaller ` + -InstallDirectory $dataRoot ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +if (-not (Test-Path -LiteralPath $purgeMarkerSentinel -PathType Leaf) -or + -not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw 'Explicit purge followed or removed a marker reparse point.' +} +Remove-ReparseDirectoryEntry $marker +[System.IO.File]::WriteAllText($marker, 'works.protocol.lsdj') +Remove-Item -LiteralPath $purgeMarkerTarget -Recurse -Force + +# Unsigned CI installers pause after the initial ownership/size decision and +# core binary removal. Replace the marker during that window; the immediate +# destructive revalidation must detect the change and preserve the root. +Start-LifecycleScenario 'reject ownership-marker replacement after purge confirmation' +Invoke-CheckedProcess $newer.FullName @('/S') +Remove-Item -LiteralPath $ciPurgeReady -Force -ErrorAction SilentlyContinue +$racedPurgeWorker = $null +$racedPurge = $null +try { + $racedPurgeWorker = New-UninstallerWorkerCopy -FilePath $uninstaller + $racedPurge = Start-Process -FilePath $racedPurgeWorker ` + -ArgumentList @( + '/S', + '/PURGE-LSDJ-DATA', + '/LSDJ-CI-PAUSE-BEFORE-PURGE', + "_?=$dataRoot" + ) ` + -PassThru + $raceDeadline = [DateTime]::UtcNow.AddSeconds(20) + while (-not (Test-Path -LiteralPath $ciPurgeReady -PathType Leaf) -and + -not $racedPurge.HasExited) { + if ([DateTime]::UtcNow -ge $raceDeadline) { + throw 'Timed out waiting for the CI purge synchronization point.' + } + Start-Sleep -Milliseconds 100 + } + if ($racedPurge.HasExited) { + throw "Purge exited before the marker-replacement test (exit $($racedPurge.ExitCode))." + } + [System.IO.File]::WriteAllText($marker, 'replaced-after-confirmation') + if (-not $racedPurge.WaitForExit(30000)) { + throw 'Timed out waiting for the refused marker-replacement purge to exit.' + } + if ($racedPurge.ExitCode -ne 2 -or + -not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw 'Marker replacement after confirmation did not fail closed.' + } +} finally { + try { + Stop-UninstallerWorker -Process $racedPurge + } finally { + try { + if ($null -ne $racedPurgeWorker) { + Remove-Item -LiteralPath $racedPurgeWorker -Force -ErrorAction SilentlyContinue + } + } finally { + Remove-Item -LiteralPath $ciPurgeReady -Force -ErrorAction SilentlyContinue + } + } +} +[System.IO.File]::WriteAllText($marker, 'works.protocol.lsdj') + +# Nested junctions are never traversed for size or removal. Purge refuses the +# tree and leaves both the root and outside target intact. +Start-LifecycleScenario 'reject purge with nested junction' +Invoke-CheckedProcess $newer.FullName @('/S') +$nestedTarget = Join-Path $env:RUNNER_TEMP 'lsdj-nested-junction-target' +New-Item -ItemType Directory -Path $nestedTarget -Force | Out-Null +$nestedSentinel = Join-Path $nestedTarget 'outside.txt' +[System.IO.File]::WriteAllText($nestedSentinel, 'outside nested junction') +$nestedJunction = Join-Path $dataRoot 'data\linked-outside' +New-Item -ItemType Junction -Path $nestedJunction -Target $nestedTarget | Out-Null +Invoke-ExpectedUninstallFailure ` + -FilePath $uninstaller ` + -InstallDirectory $dataRoot ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +if (-not (Test-Path -LiteralPath $nestedSentinel -PathType Leaf) -or + -not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw 'Explicit purge traversed a nested directory reparse point.' +} +Remove-ReparseDirectoryEntry $nestedJunction +Remove-Item -LiteralPath $nestedTarget -Recurse -Force + +# Explicit automation opt-in mirrors the GUI checkbox + path/size confirmation. +Start-LifecycleScenario 'explicit purge removes owned data root' +Invoke-CheckedProcess $newer.FullName @('/S') +Invoke-CheckedProcess $uninstaller @('/S', '/PURGE-LSDJ-DATA') +Start-Sleep -Milliseconds 500 +if (Test-Path -LiteralPath $dataRoot) { + throw "Explicit data removal did not remove $dataRoot." +} +Require-No-Workers + +# A non-default install location with spaces, Unicode, and a long (but pre-MAX_PATH) +# directory proves package resources do not depend on Windows long-path support. +Start-LifecycleScenario 'custom install path with spaces Unicode and long leaf' +$longLeaf = ('path segment ' * 10).Trim() +$unicodeInstall = Join-Path $env:RUNNER_TEMP "LSDJ installer 路径 $longLeaf" +if ($unicodeInstall.Length -ge 240) { + throw "The long-path-disabled smoke target exceeded its conservative budget: $($unicodeInstall.Length)" +} +Invoke-CheckedProcess $newer.FullName @('/S', "/D=$unicodeInstall") +$unicodeApp = Join-Path $unicodeInstall 'lsdj-app.exe' +$unicodeUninstaller = Join-Path $unicodeInstall 'uninstall.exe' +if (-not (Test-Path -LiteralPath $unicodeApp -PathType Leaf)) { + throw "Unicode/space install did not produce the app at $unicodeApp." +} + +# Exercise NSIS's documented worker form with a final, unquoted `_?=` value +# whose remainder contains spaces and Unicode. The unsafe purge must expose +# exact worker exit 2 and preserve both the custom app and owned data root. +Start-LifecycleScenario 'reject purge worker with spaces and Unicode install path' +[System.IO.File]::WriteAllText($marker, 'foreign-owner') +Invoke-ExpectedUninstallFailure ` + -FilePath $unicodeUninstaller ` + -InstallDirectory $unicodeInstall ` + -ArgumentList @('/S', '/PURGE-LSDJ-DATA') | Out-Null +if (-not (Test-Path -LiteralPath $unicodeApp -PathType Leaf) -or + -not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw 'Unicode/space worker invocation allowed uninstall or data removal.' +} +[System.IO.File]::WriteAllText($marker, 'works.protocol.lsdj') + +Invoke-CheckedProcess $unicodeUninstaller @('/S') +Start-Sleep -Milliseconds 500 +if (Test-Path -LiteralPath $unicodeApp) { + throw 'Unicode/space uninstall left the app binary behind.' +} + +# The custom-location uninstall intentionally retains its remembered location. +# Override it explicitly so the final purge cleans the isolated runner's normal +# application/data root as well as the remembered-location registry state. +Start-LifecycleScenario 'final explicit cleanup after custom install location' +Invoke-CheckedProcess $newer.FullName @('/S', "/D=$dataRoot") +Invoke-CheckedProcess $uninstaller @('/S', '/PURGE-LSDJ-DATA') +Start-Sleep -Milliseconds 500 +if (Test-Path -LiteralPath $dataRoot) { + throw 'Final explicit cleanup did not remove the LSDJ data root.' +} +Require-No-Workers +Write-Host 'Windows NSIS install/upgrade/downgrade/uninstall lifecycle passed.' diff --git a/scripts/tests/test_release_artifact.py b/scripts/tests/test_release_artifact.py index 98d438e..ce254a2 100644 --- a/scripts/tests/test_release_artifact.py +++ b/scripts/tests/test_release_artifact.py @@ -4,7 +4,6 @@ import sys import tempfile import unittest -from unittest import mock from pathlib import Path @@ -26,21 +25,32 @@ def setUp(self): self.root = Path(self.temporary.name) self.asset = self.root / "LSDJ_2026.08.7_aarch64.dmg" self.asset.write_bytes(b"verified dmg bytes") + self.windows_asset = self.root / "LSDJ_2026.08.7_x64-setup.exe" + self.windows_asset.write_bytes(b"verified signed nsis bytes") def tearDown(self): self.temporary.cleanup() - def create_bundle(self): - bundle = self.root / "incoming" / "macos-arm64" + def create_bundle(self, producer="macos-arm64"): + asset = { + "macos-arm64": self.asset, + "windows-x64": self.windows_asset, + }[producer] + bundle = self.root / "incoming" / producer release_artifact.create_bundle( - producer="macos-arm64", + producer=producer, release_tag=TAG, revision=REVISION, - assets=[self.asset], + assets=[asset], output_dir=bundle, ) return bundle + def create_all_bundles(self): + self.create_bundle("macos-arm64") + self.create_bundle("windows-x64") + return self.root / "incoming" + def draft_release(self, assets, **updates): data = { "id": 12345, @@ -54,12 +64,12 @@ def draft_release(self, assets, **updates): return data def test_create_and_verify_bundle(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() output = self.root / "verified" release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "windows-x64"], release_tag=TAG, revision=REVISION, output_dir=output, @@ -69,24 +79,28 @@ def test_create_and_verify_bundle(self): {path.name for path in output.iterdir()}, { self.asset.name, + self.windows_asset.name, "macos-arm64-release-metadata.json", "macos-arm64-SHA256SUMS.txt", + "windows-x64-release-metadata.json", + "windows-x64-SHA256SUMS.txt", "release-index.json", }, ) index = json.loads((output / "release-index.json").read_text()) self.assertEqual(index["release_tag"], TAG) self.assertEqual(index["revision"], REVISION) - self.assertEqual(index["producers"], ["macos-arm64"]) + self.assertEqual(index["producers"], ["macos-arm64", "windows-x64"]) def test_tampered_asset_fails_closed(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() + bundle = incoming / "macos-arm64" (bundle / self.asset.name).write_bytes(b"tampered") with self.assertRaisesRegex(release_artifact.ArtifactError, "size|checksum"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "windows-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", @@ -115,60 +129,49 @@ def test_missing_required_producer_fails_closed(self): with self.assertRaisesRegex(release_artifact.ArtifactError, "producer set"): release_artifact.verify_bundles( input_root=incoming, - required_producers=["macos-arm64"], + required_producers=["macos-arm64", "windows-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", ) def test_unexpected_bundle_file_fails_closed(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() + bundle = incoming / "macos-arm64" (bundle / "surprise.txt").write_text("not declared") with self.assertRaisesRegex(release_artifact.ArtifactError, "unexpected"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "windows-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", ) def test_wrong_release_identity_fails_closed(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() with self.assertRaisesRegex(release_artifact.ArtifactError, "release_tag"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "windows-x64"], release_tag="v2026.08.8", revision=REVISION, output_dir=self.root / "verified", ) def test_required_producer_arguments_must_exactly_match_policy(self): - bundle = self.create_bundle() - windows_policy = release_artifact.ProducerPolicy( - platform="windows", - architecture="x86_64", - asset_suffix=".exe", - asset_count=1, - ) + incoming = self.create_all_bundles() - with mock.patch.dict( - release_artifact.PRODUCER_POLICIES, - {"windows-x64": windows_policy}, - ): - with self.assertRaisesRegex( - release_artifact.ArtifactError, "release policy" - ): - release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], - release_tag=TAG, - revision=REVISION, - output_dir=self.root / "verified", - ) + with self.assertRaisesRegex(release_artifact.ArtifactError, "release policy"): + release_artifact.verify_bundles( + input_root=incoming, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=self.root / "verified", + ) def test_draft_release_assets_must_match_exactly(self): verified = self.root / "verified" @@ -329,7 +332,10 @@ def test_release_workflow_keeps_one_least_privilege_publisher(self): self.assertEqual(workflow.count("contents: write"), 1) self.assertEqual(len(re.findall(r"^ publish:$", workflow, re.MULTILINE)), 1) self.assertIn("needs.produce_macos.result == 'success'", workflow) + self.assertIn("needs.produce_windows.result == 'success'", workflow) self.assertIn("--required-producer macos-arm64", workflow) + self.assertIn("--required-producer windows-x64", workflow) + self.assertEqual(workflow.count("environment:\n name: windows-release"), 1) self.assertRegex(workflow, r"(?m)^on:\n push:\n tags:$") self.assertNotIn("pull_request:", workflow) self.assertNotIn("workflow_dispatch:", workflow) diff --git a/scripts/tests/test_windows_packaging.py b/scripts/tests/test_windows_packaging.py new file mode 100644 index 0000000..febd482 --- /dev/null +++ b/scripts/tests/test_windows_packaging.py @@ -0,0 +1,382 @@ +import json +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[2] +TAURI_ROOT = REPO_ROOT / "src-tauri" + + +class WindowsPackagingContractTest(unittest.TestCase): + def test_nsis_is_current_user_and_blocks_downgrades(self): + config = json.loads((TAURI_ROOT / "tauri.windows.conf.json").read_text()) + bundle = config["bundle"] + windows = bundle["windows"] + nsis = windows["nsis"] + + self.assertEqual(bundle["targets"], ["nsis"]) + self.assertEqual(nsis["installMode"], "currentUser") + self.assertEqual(nsis["startMenuFolder"], "LSDJ") + self.assertFalse(windows["allowDowngrades"]) + self.assertEqual( + windows["webviewInstallMode"], + {"type": "downloadBootstrapper", "silent": True}, + ) + + def test_uninstall_data_removal_is_explicit_disclosed_and_scoped(self): + hooks = (TAURI_ROOT / "windows/installer-hooks.nsh").read_text() + + self.assertIn('!define LSDJ_DATA_ROOT "$LOCALAPPDATA\\LSDJ"', hooks) + self.assertIn(".lsdj-data-root", hooks) + self.assertIn('!define LSDJ_OWNER_ID "works.protocol.lsdj"', hooks) + self.assertIn("NSIS_HOOK_PREINSTALL", hooks) + self.assertIn("GetFullPathNameW", hooks) + self.assertIn("CreateFileW", hooks) + self.assertIn("LSDJ_FILE_FLAG_OPEN_REPARSE_POINT", hooks) + self.assertIn("!define LSDJ_OWNER_ID_BYTES 19", hooks) + self.assertIn("!define LSDJ_OWNER_ID_READ_BYTES 20", hooks) + self.assertEqual(len("works.protocol.lsdj".encode("ascii")), 19) + self.assertIn("System::Alloc 52", hooks) + self.assertIn( + "GetFileInformationByHandle(p R6, p R7)", + hooks, + ) + self.assertIn("System::Call '*$R7(&i4 .R8)'", hooks) + self.assertIn("System::Free $R7", hooks) + self.assertIn( + "ReadFile(p R6, m .R8, i ${LSDJ_OWNER_ID_READ_BYTES}, *i .R7, p 0)", + hooks, + ) + self.assertNotIn("GetFileInformationByHandle(p R6, *(", hooks) + self.assertNotIn('FileOpen $R5 "${LSDJ_DATA_MARKER}" r', hooks) + marker_validator_start = hooks.index("!macro LSDJ_DEFINE_MARKER_VALIDATOR") + marker_validator_end = hooks.index("!macroend", marker_validator_start) + marker_validator = hooks[marker_validator_start:marker_validator_end] + self.assertIn( + "${OrIf} $R7 != ${LSDJ_OWNER_ID_BYTES}", + marker_validator, + ) + self.assertIn("LSDJ_FILE_ATTRIBUTE_REPARSE_POINT", hooks) + self.assertIn("LsdjExistingLayoutIsRecognized", hooks) + self.assertIn("Section -LsdjProbeDataRootBeforeTauri", hooks) + self.assertIn("StrCpy $LsdjInstallRootState 1", hooks) + self.assertIn("LsdjDataRootIsEmpty", hooks) + self.assertIn("LsdjInstallTreeIsLinkFree", hooks) + self.assertIn('CreateDirectory "${LSDJ_DATA_ROOT}"', hooks) + self.assertIn("LsdjOwnedDataRootIsSafe", hooks) + self.assertIn("LsdjTreeIsLinkFree", hooks) + self.assertIn("LsdjDeleteTreeWithoutLinks", hooks) + self.assertIn("IfErrors lsdj_install_tree_empty_candidate", hooks) + self.assertIn("IfErrors lsdj_empty_recheck", hooks) + self.assertIn("IfErrors lsdj_tree_empty_candidate", hooks) + self.assertIn("IfErrors lsdj_delete_empty_candidate", hooks) + for empty_recheck in ( + "lsdj_install_tree_empty_candidate:", + "lsdj_empty_recheck:", + "lsdj_tree_empty_candidate:", + "lsdj_delete_empty_candidate:", + ): + recheck_start = hooks.index(empty_recheck) + recheck = hooks[recheck_start : recheck_start + 700] + self.assertIn("GetFileAttributesW", recheck) + self.assertIn("LSDJ_FILE_ATTRIBUTE_REPARSE_POINT", recheck) + self.assertIn("LSDJ_FILE_ATTRIBUTE_DIRECTORY", recheck) + self.assertIn("/PURGE-LSDJ-DATA", hooks) + self.assertIn("${GetSize}", hooks) + self.assertIn("Location: ${LSDJ_DATA_ROOT}", hooks) + self.assertIn("Size: $R8 KiB", hooks) + self.assertIn("StrCpy $LsdjDeleteData $DeleteAppDataCheckboxState", hooks) + self.assertIn("StrCpy $DeleteAppDataCheckboxState 0", hooks) + self.assertIn("${If} $LsdjDeleteData = 1", hooks) + preuninstall_start = hooks.index("!macro NSIS_HOOK_PREUNINSTALL") + preuninstall_end = hooks.index("!macroend", preuninstall_start) + preuninstall = hooks[preuninstall_start:preuninstall_end] + self.assertIn( + "preuninstall: owned root safe=$LsdjOwnedRootSafe tree safe=$LsdjTreeSafe", + preuninstall, + ) + self.assertIn("abort: unsafe data removal", preuninstall) + self.assertIn("SetErrorLevel 2\n Quit", preuninstall) + self.assertNotIn('Abort "Refusing unsafe LSDJ data removal."', preuninstall) + self.assertIn('DeleteRegKey SHCTX "${MANUPRODUCTKEY}"', hooks) + self.assertNotIn("RMDir /r", hooks) + self.assertNotIn('RMDir /r "$LOCALAPPDATA"', hooks) + + probe_start = hooks.index("Section -LsdjProbeDataRootBeforeTauri") + probe_end = hooks.index("SectionEnd", probe_start) + probe = hooks[probe_start:probe_end] + preinstall_start = hooks.index("!macro NSIS_HOOK_PREINSTALL") + create_start = hooks.index('CreateDirectory "${LSDJ_DATA_ROOT}"') + self.assertLess(probe_start, probe_end) + self.assertLess(probe_end, preinstall_start) + self.assertLess(preinstall_start, create_start) + self.assertNotIn("CreateDirectory", probe) + preinstall_end = hooks.index("!macroend", preinstall_start) + preinstall = hooks[preinstall_start:preinstall_end] + self.assertIn("!define MUI_CUSTOMFUNCTION_GUIINIT LsdjRejectPassiveMode", hooks) + passive_start = hooks.index("Function LsdjRejectPassiveMode") + passive_end = hooks.index("FunctionEnd", passive_start) + passive_callback = hooks[passive_start:passive_end] + self.assertIn("!insertmacro LSDJ_REJECT_PASSIVE_MODE", passive_callback) + passive_macro_start = hooks.index("!macro LSDJ_REJECT_PASSIVE_MODE") + passive_macro_end = hooks.index("!macroend", passive_macro_start) + passive_macro = hooks[passive_macro_start:passive_macro_end] + self.assertIn( + '${GetOptions} $CMDLINE "/P" $LsdjPassiveRequested', passive_macro + ) + self.assertIn("SetErrorLevel 2", passive_macro) + self.assertIn("Quit", passive_macro) + self.assertLess(passive_end, preinstall_start) + self.assertTrue( + preinstall.lstrip().startswith( + "!macro NSIS_HOOK_PREINSTALL\n !insertmacro LSDJ_REJECT_PASSIVE_MODE" + ) + ) + self.assertIn("${If} ${Silent}", preinstall) + self.assertNotIn("$PassiveMode", preinstall) + self.assertNotIn("${AndIf} $UpdateMode != 1", preinstall) + self.assertIn( + 'ReadRegStr $LsdjInstalledVersion SHCTX "${UNINSTKEY}" "DisplayVersion"', + preinstall, + ) + self.assertIn( + 'ReadRegStr $LsdjRegistryEvidence SHCTX "${UNINSTKEY}" "UninstallString"', + preinstall, + ) + self.assertIn( + '${FileExists} "$INSTDIR\\${MAINBINARYNAME}.exe"', + preinstall, + ) + self.assertIn( + 'nsis_tauri_utils::SemverCompare "$LsdjInstalledVersion" "lsdj-invalid-semver"', + preinstall, + ) + self.assertIn('${If} "$LsdjInstalledVersion" == ""', preinstall) + self.assertNotIn('${If} $LsdjInstalledVersion = ""', preinstall) + self.assertIn( + 'nsis_tauri_utils::SemverCompare "${VERSION}" "$LsdjInstalledVersion"', + preinstall, + ) + self.assertIn("${If} $LsdjVersionCompare = -1", preinstall) + self.assertIn("${ElseIf} $LsdjVersionCompare != 0", preinstall) + self.assertIn("${AndIf} $LsdjVersionCompare != 1", preinstall) + self.assertIn("abort: invalid version comparison", preinstall) + version_guard = preinstall[ + : preinstall.index("Call LsdjCanonicalDataRootIsValid") + ] + self.assertNotIn("$R6", version_guard) + self.assertNotIn("$R7", version_guard) + self.assertIn("SetErrorLevel 2", preinstall) + self.assertLess( + preinstall.index("SetErrorLevel 2"), + preinstall.index("Call LsdjCanonicalDataRootIsValid"), + ) + + language = (TAURI_ROOT / "windows/English.nsh").read_text() + self.assertIn("path and size will be confirmed", language) + + def test_release_signing_uses_only_the_protected_provider_interface(self): + release = json.loads( + (TAURI_ROOT / "tauri.windows.release.conf.json").read_text() + ) + sign = release["bundle"]["windows"]["signCommand"] + self.assertEqual(sign["command"], "pwsh.exe") + self.assertIn("../scripts/sign-windows.ps1", sign["args"]) + self.assertIn("%1", sign["args"]) + + signer = (REPO_ROOT / "scripts/sign-windows.ps1").read_text() + verifier = (REPO_ROOT / "scripts/verify-windows-signatures.ps1").read_text() + for name in ( + "LSDJ_WINDOWS_SIGN_COMMAND_PATH", + "LSDJ_WINDOWS_EXPECTED_CERTIFICATE_SHA1", + "LSDJ_WINDOWS_EXPECTED_SUBJECT", + ): + self.assertIn(name, signer) + self.assertIn("TimeStamperCertificate", verifier) + self.assertIn("signtool.exe", verifier) + self.assertIn("Get-Command 'signtool.exe'", signer) + self.assertNotRegex( + signer, r"(?i)certificate_base64|pfx_password|azure|digicert" + ) + + def test_hosted_ci_builds_unsigned_but_exercises_release_rejection(self): + workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text() + build = (REPO_ROOT / "scripts/build-windows-installer.ps1").read_text() + lifecycle = (REPO_ROOT / "scripts/test-windows-installer.ps1").read_text() + windows_doc = (REPO_ROOT / "docs/windows.md").read_text() + hooks = (TAURI_ROOT / "windows/installer-hooks.nsh").read_text() + + self.assertIn("-UnsignedDevelopment", workflow) + self.assertIn("assert-windows-release-rejects-unsigned.ps1", workflow) + self.assertIn("test-windows-installer.ps1", workflow) + self.assertIn("windows-x64-unsigned-development", workflow) + self.assertIn("cargo install tauri-cli --version '=2.11.2' --locked", workflow) + self.assertIn("LSDJ_CI_ADVERSARIAL_TESTS", build) + self.assertIn("if ($UnsignedDevelopment)", build) + self.assertIn('FileOpen $R5 "$TEMP\\lsdj-ci-installer.trace" a', hooks) + self.assertIn("FileSeek $R5 0 END", hooks) + self.assertIn("Var LsdjCiTraceHadErrors", hooks) + self.assertIn("Var LsdjCiTraceMessage", hooks) + self.assertIn('StrCpy $LsdjCiTraceMessage "${MESSAGE}"', hooks) + trace_else = hooks.index("!else", hooks.index("!macro LSDJ_CI_TRACE MESSAGE")) + trace_end = hooks.index("!endif", trace_else) + self.assertEqual( + hooks[trace_else:trace_end].count("FileOpen"), + 0, + "Production trace macro must expand to no file operations.", + ) + self.assertIn("Get-CiInstallerTrace", lifecycle) + self.assertIn("Write-CiInstallerTrace", lifecycle) + self.assertIn("CI installer trace:", lifecycle) + self.assertIn("function Get-InstalledStateSnapshot", lifecycle) + self.assertIn("function Get-UninstallRegistrySnapshot", lifecycle) + self.assertIn("function Assert-CiInstallerTraceContract", lifecycle) + self.assertIn("function Set-DisplayVersionEvidence", lifecycle) + self.assertIn("function New-UninstallerWorkerCopy", lifecycle) + self.assertIn("function Stop-UninstallerWorker", lifecycle) + self.assertIn("function Invoke-ExpectedUninstallFailure", lifecycle) + self.assertNotIn("Invoke-ExpectedFailure $uninstaller", lifecycle) + copy_helper_start = lifecycle.index("function New-UninstallerWorkerCopy") + stop_helper_start = lifecycle.index( + "function Stop-UninstallerWorker", copy_helper_start + ) + worker_helper_start = lifecycle.index( + "function Invoke-ExpectedUninstallFailure" + ) + copy_helper = lifecycle[copy_helper_start:stop_helper_start] + stop_helper = lifecycle[stop_helper_start:worker_helper_start] + worker_helper_end = lifecycle.index( + "function Require-InstalledVersion", worker_helper_start + ) + worker_helper = lifecycle[worker_helper_start:worker_helper_end] + self.assertIn("} catch {", copy_helper) + self.assertIn("Remove-Item -LiteralPath $workerPath", copy_helper) + self.assertIn("$Process.Kill($true)", stop_helper) + self.assertIn("$Process.WaitForExit($TimeoutMilliseconds)", stop_helper) + self.assertIn( + '-ArgumentList (@($ArgumentList) + "_?=$InstallDirectory")', + worker_helper, + ) + self.assertIn("$worker.WaitForExit(30000)", worker_helper) + self.assertIn("$worker.ExitCode -ne 2", worker_helper) + self.assertIn("} finally {", worker_helper) + self.assertIn("Stop-UninstallerWorker -Process $worker", worker_helper) + self.assertIn( + "Remove-Item -LiteralPath $workerPath", + worker_helper, + ) + race_start = lifecycle.index( + "Start-LifecycleScenario 'reject ownership-marker replacement" + ) + race_end = lifecycle.index( + "Start-LifecycleScenario 'reject purge with nested junction'", + race_start, + ) + race = lifecycle[race_start:race_end] + self.assertIn('"_?=$dataRoot"\n )', race) + self.assertIn("$racedPurge.ExitCode -ne 2", race) + self.assertIn("$racedPurge.WaitForExit(30000)", race) + self.assertIn("} finally {", race) + self.assertIn("Stop-UninstallerWorker -Process $racedPurge", race) + self.assertGreaterEqual(race.count("} finally {"), 2) + self.assertIn( + "Remove-Item -LiteralPath $racedPurgeWorker", + race, + ) + unicode_worker_start = lifecycle.index( + "Start-LifecycleScenario 'reject purge worker with spaces and Unicode" + ) + unicode_worker_end = lifecycle.index( + "Invoke-CheckedProcess $unicodeUninstaller @('/S')", unicode_worker_start + ) + unicode_worker = lifecycle[unicode_worker_start:unicode_worker_end] + self.assertIn("-FilePath $unicodeUninstaller", unicode_worker) + self.assertIn("-InstallDirectory $unicodeInstall", unicode_worker) + self.assertIn("Invoke-ExpectedUninstallFailure", unicode_worker) + self.assertNotIn(".WaitForExit()", race) + self.assertIn( + "NSIS installed uninstallers are self-copy launchers", windows_doc + ) + self.assertIn("final unquoted `_?=`", windows_doc) + self.assertIn("fail-closed refusal (`2`)", windows_doc) + self.assertIn("$lastRequiredIndex = -1", lifecycle) + self.assertIn("[StringComparison]::Ordinal", lifecycle) + self.assertIn( + "Get-FileHash -LiteralPath $uninstaller -Algorithm SHA256", lifecycle + ) + self.assertIn("Assert-InstalledStateSnapshotUnchanged", lifecycle) + self.assertIn("MarkerAttributes", lifecycle) + self.assertIn("MarkerLinkType", lifecycle) + self.assertIn("MarkerTarget", lifecycle) + self.assertIn("[IO.FileAttributes]::ReparsePoint", lifecycle) + self.assertIn("-ExpectedExitCodes @(2)", lifecycle) + for contract in ( + "pre-existing empty LocalAppData root", + "foreign LocalAppData root", + "root junction", + "purge-time root junction", + "marker reparse point", + "NUL-extended ownership marker", + "marker-replacement test", + "nested directory reparse point", + "reject unattended downgrade in update mode", + "reject passive downgrade", + "reject combined silent and passive mode", + "same-version silent reinstall", + "reject existing install with empty version metadata", + "reject existing install with corrupt version metadata", + "reject existing install with missing version metadata", + ): + self.assertIn(contract, lifecycle) + self.assertIn("function Start-LifecycleScenario", lifecycle) + self.assertIn("preinstall: version compare=-1", lifecycle) + self.assertIn("preinstall: version compare=0", lifecycle) + self.assertIn("preinstall: version compare=1", lifecycle) + self.assertIn("validity=1", lifecycle) + self.assertIn("validity=0", lifecycle) + self.assertIn("abort: passive install unsupported", lifecycle) + self.assertIn("preinstall: version evidence installed=", hooks) + self.assertIn("Require-No-Workers", lifecycle) + self.assertIn( + "adopt recognized markerless legacy layout", + lifecycle, + ) + + rejection = ( + REPO_ROOT / "scripts/assert-windows-release-rejects-unsigned.ps1" + ).read_text() + self.assertIn("Authenticode signature status is NotSigned", rejection) + success_exit = rejection.rindex("exit 0") + self.assertGreater(success_exit, rejection.index("if ($exitCode -eq 0)")) + self.assertGreater(success_exit, rejection.index("if ($rendered -notmatch")) + + def test_release_producer_is_required_and_has_no_publish_permission(self): + workflow = (REPO_ROOT / ".github/workflows/macos-release.yml").read_text() + producer = workflow[ + workflow.index(" produce_windows:") : workflow.index(" publish:") + ] + + self.assertIn("environment:\n name: windows-release", producer) + self.assertIn("verify-windows-release-install.ps1", producer) + self.assertIn("--producer windows-x64", producer) + self.assertNotIn("contents: write", producer) + self.assertEqual(workflow.count("contents: write"), 1) + self.assertRegex(workflow, r"(?m)^ - produce_windows$") + self.assertIn("--required-producer windows-x64", workflow) + + def test_managed_backend_feature_forbids_system_python_fallback(self): + cargo = (TAURI_ROOT / "Cargo.toml").read_text() + lib = (TAURI_ROOT / "src/lib.rs").read_text() + sidecar = (TAURI_ROOT / "src/sidecar.rs").read_text() + generation = (TAURI_ROOT / "src/generation.rs").read_text() + + self.assertRegex(cargo, r"(?m)^managed-backend = \[\]$") + self.assertIn('.join("backend")', lib) + self.assertIn('.join("current")', lib) + self.assertIn("LSDJ_MANAGED_BACKEND_REQUIRED", sidecar) + self.assertIn("LSDJ_MANAGED_BACKEND_REQUIRED", generation) + self.assertIn("app-managed backend runtime is not installed", sidecar) + self.assertIn("app-managed backend runtime is not installed", generation) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-windows-release-install.ps1 b/scripts/verify-windows-release-install.ps1 new file mode 100644 index 0000000..677ee92 --- /dev/null +++ b/scripts/verify-windows-release-install.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Installer, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string] $ExpectedVersion +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $IsWindows -or $env:GITHUB_ACTIONS -ne 'true') { + throw 'Release installer verification may run only on an isolated GitHub Actions Windows runner.' +} + +$setup = Get-Item -LiteralPath $Installer -ErrorAction Stop +& "$PSScriptRoot/verify-windows-signatures.ps1" -Path $setup.FullName + +$dataRoot = Join-Path $env:LOCALAPPDATA 'LSDJ' +if (Test-Path -LiteralPath $dataRoot) { + throw "The release verification runner is not clean: $dataRoot already exists." +} +$app = Join-Path $dataRoot 'lsdj-app.exe' +$uninstaller = Join-Path $dataRoot 'uninstall.exe' +$sentinel = Join-Path $dataRoot 'data\release-preservation.txt' + +function Invoke-ReleaseProcess { + param([string] $FilePath, [string[]] $ArgumentList) + + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Release lifecycle command exited $($process.ExitCode): $FilePath" + } +} + +Invoke-ReleaseProcess $setup.FullName @('/S') +if ((Get-Item -LiteralPath $app).VersionInfo.ProductVersion -ne $ExpectedVersion) { + throw "Installed release does not report version $ExpectedVersion." +} +$payloads = @( + Get-ChildItem -LiteralPath $dataRoot -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll') } | + ForEach-Object FullName +) +if ($payloads.Count -lt 2) { + throw 'Expected at least the signed app and uninstaller payloads.' +} +& "$PSScriptRoot/verify-windows-signatures.ps1" -Path $payloads + +New-Item -ItemType Directory -Path (Split-Path -Parent $sentinel) -Force | Out-Null +[System.IO.File]::WriteAllText($sentinel, 'preserve') +Invoke-ReleaseProcess $uninstaller @('/S') +Start-Sleep -Milliseconds 500 +if (-not (Test-Path -LiteralPath $sentinel -PathType Leaf)) { + throw 'Default release uninstall did not preserve app-owned data.' +} +if (Test-Path -LiteralPath $app) { + throw 'Default release uninstall left the app binary behind.' +} + +Invoke-ReleaseProcess $setup.FullName @('/S') +Invoke-ReleaseProcess $uninstaller @('/S', '/PURGE-LSDJ-DATA') +Start-Sleep -Milliseconds 500 +if (Test-Path -LiteralPath $dataRoot) { + throw 'Explicit release data removal did not remove the app-owned data root.' +} +$workers = @(Get-Process -Name 'lsdj-app', 'lsdj_backend' -ErrorAction SilentlyContinue) +if ($workers.Count -ne 0) { + throw "Release install/uninstall left worker processes running: $($workers.Name -join ', ')" +} +Write-Host 'Signed Windows release installer and installed payloads verified.' diff --git a/scripts/verify-windows-signatures.ps1 b/scripts/verify-windows-signatures.ps1 new file mode 100644 index 0000000..c722676 --- /dev/null +++ b/scripts/verify-windows-signatures.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]] $Path, + + [string] $ExpectedCertificateSha1 = $env:LSDJ_WINDOWS_EXPECTED_CERTIFICATE_SHA1, + + [string] $ExpectedSubject = $env:LSDJ_WINDOWS_EXPECTED_SUBJECT +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$thumbprint = $ExpectedCertificateSha1 -replace '\s', '' +if ($thumbprint -notmatch '^[0-9A-Fa-f]{40}$') { + throw 'ExpectedCertificateSha1 must contain exactly 40 hexadecimal characters.' +} +if ([string]::IsNullOrWhiteSpace($ExpectedSubject)) { + throw 'ExpectedSubject is required and must exactly match the approved Authenticode subject.' +} + +$signTool = $null +foreach ($entry in $Path) { + $target = Get-Item -LiteralPath $entry -ErrorAction Stop + if ($target.PSIsContainer -or ($target.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Signature target must be a plain file: $entry" + } + if ($target.Extension -notin @('.exe', '.dll')) { + throw "Signature target must be an executable payload: $($target.FullName)" + } + + $signature = Get-AuthenticodeSignature -LiteralPath $target.FullName + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "Authenticode signature status is $($signature.Status) for $($target.FullName): $($signature.StatusMessage)" + } + if ($null -eq $signature.SignerCertificate) { + throw "Authenticode signer certificate is missing for $($target.FullName)." + } + if ($signature.SignerCertificate.Thumbprint -ne $thumbprint.ToUpperInvariant()) { + throw "Unexpected Authenticode certificate for $($target.FullName)." + } + if ($signature.SignerCertificate.Subject -cne $ExpectedSubject) { + throw "Unexpected Authenticode subject for $($target.FullName): $($signature.SignerCertificate.Subject)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Authenticode timestamp is missing for $($target.FullName)." + } + + if ($null -eq $signTool) { + $signTool = (Get-Command 'signtool.exe' -ErrorAction Stop).Source + } + & $signTool verify /pa /all /v $target.FullName + if ($LASTEXITCODE -ne 0) { + throw "signtool trust verification failed with exit code $LASTEXITCODE for $($target.FullName)." + } + Write-Host "Verified Authenticode signer and timestamp: $($target.FullName)" +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c6d51d0..782794f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,12 @@ path = "src/main.rs" # runtime and startup must resolve it from Contents/Resources. Developer builds # omit the feature and retain the uv-based source-tree commands. bundled-backend = [] +# Windows/Linux packages install model runtimes under the host-owned assets root +# after first launch. This feature forbids the developer `uv` fallback and +# resolves only the verified launcher promoted by the native runtime installer. +# It is deliberately backend-neutral: #110 and #111 own what lives behind the +# launcher; packaging owns only the stable executable seam. +managed-backend = [] # The cargo WORKSPACE. The app is the root package; the audio engine is a member # library crate so it stays headless-testable (`cargo test -p lsdj-engine`) diff --git a/src-tauri/src/analysis/live.rs b/src-tauri/src/analysis/live.rs index 22f2ad9..1a361da 100644 --- a/src-tauri/src/analysis/live.rs +++ b/src-tauri/src/analysis/live.rs @@ -133,7 +133,7 @@ pub struct AnalysisFeed { impl AnalysisFeed { /// A feed whose receivers are dropped — every send is a silent no-op. For /// tests that need the tee wiring without analysis threads (no `AppHandle`). - #[cfg(test)] + #[cfg(all(test, unix))] pub fn disconnected(deck_count: usize) -> Self { AnalysisFeed { senders: Arc::new((0..deck_count).map(|_| sync_channel(1).0).collect()), diff --git a/src-tauri/src/child_process.rs b/src-tauri/src/child_process.rs index a138fe0..0116f7e 100644 --- a/src-tauri/src/child_process.rs +++ b/src-tauri/src/child_process.rs @@ -23,6 +23,7 @@ use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(20); const FORCE_WAIT: Duration = Duration::from_secs(2); +#[cfg(unix)] const TREE_REAP_SWEEPS: usize = 100; const DIAGNOSTIC_BYTES: usize = 16 * 1024; const DIAGNOSTIC_LINES: usize = 128; @@ -155,6 +156,7 @@ fn scrub_child_environment(command: &mut Command) { } impl SupervisedChild { + #[cfg(unix)] pub(crate) fn id(&self) -> u32 { self.child.id() } @@ -576,8 +578,10 @@ fn resume_windows_process(process_id: u32) -> io::Result<()> { } // SAFETY: ownership of the snapshot handle transfers here. let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot as _) }; - let mut entry = THREADENTRY32::default(); - entry.dwSize = std::mem::size_of::() as u32; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; // SAFETY: snapshot and entry pointers are valid. let mut has_entry = unsafe { Thread32First(snapshot.as_raw_handle() as _, &mut entry) } != 0; while has_entry { diff --git a/src-tauri/src/generation.rs b/src-tauri/src/generation.rs index b568428..db172e9 100644 --- a/src-tauri/src/generation.rs +++ b/src-tauri/src/generation.rs @@ -120,6 +120,12 @@ pub fn generation_command(port: u16) -> io::Result { cmd.args(["--generation-server", "--port", &port.to_string()]); return Ok(cmd); } + if std::env::var_os("LSDJ_MANAGED_BACKEND_REQUIRED").is_some() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "the verified app-managed backend runtime is not installed", + )); + } let overridden = std::env::var("LSDJ_GENERATION_CMD"); let spec = overridden @@ -162,5 +168,13 @@ mod tests { assert_eq!(server.port(), None); std::env::remove_var("LSDJ_GENERATION_CMD"); + + // Packaged Windows/Linux builds never fall through to the developer + // `uv run` default while the first-run managed runtime is absent. + std::env::set_var("LSDJ_MANAGED_BACKEND_REQUIRED", "1"); + let error = generation_command(5123).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains("app-managed backend runtime")); + std::env::remove_var("LSDJ_MANAGED_BACKEND_REQUIRED"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8766867..65fe478 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,9 @@ mod style; mod style_send; mod watcher; +#[cfg(all(feature = "bundled-backend", feature = "managed-backend"))] +compile_error!("bundled-backend and managed-backend are mutually exclusive"); + /// The default per-deck model the sidecars load (mirrors `controller.py` /// `DEFAULT_MODEL`). const DEFAULT_MODEL: &str = "mrt2_small"; @@ -70,6 +73,22 @@ fn bundled_backend_path(resource_dir: &std::path::Path) -> std::path::PathBuf { resource_dir.join("lsdj_backend").join("lsdj_backend") } +/// Stable launcher seam for app-managed platform runtimes. The launcher is an +/// ordinary native executable promoted atomically by the model/runtime manager; +/// it may host PyTorch MRT2, TFLite SA3, or both without packaging knowing the +/// Python environment's internal layout. +#[cfg(any(feature = "managed-backend", test))] +fn managed_backend_path(assets_dir: &std::path::Path) -> std::path::PathBuf { + assets_dir + .join("backend") + .join("current") + .join(if cfg!(windows) { + "lsdj_backend.exe" + } else { + "lsdj_backend" + }) +} + /// Point every Python-backed service at the signed runtime inside the app. /// Developer builds deliberately omit the feature/resource and retain their /// source-tree `uv run` defaults. A release build fails during setup rather than @@ -88,7 +107,21 @@ fn configure_bundled_backend(app: &tauri::App) -> Result<(), Box Result<(), Box> { + let backend = managed_backend_path(platform_paths::get().assets()); + // A packaged build must never inherit a developer override or fall through + // to a system `uv`/Python. The marker makes the command builders fail with + // an actionable first-run error while #110/#111 install the verified runtime. + std::env::remove_var("LSDJ_BACKEND_BIN"); + std::env::set_var("LSDJ_MANAGED_BACKEND_REQUIRED", "1"); + if backend.is_file() { + std::env::set_var("LSDJ_BACKEND_BIN", backend); + } + Ok(()) +} + +#[cfg(not(any(feature = "bundled-backend", feature = "managed-backend")))] fn configure_bundled_backend(_app: &tauri::App) -> Result<(), Box> { Ok(()) } @@ -529,11 +562,14 @@ pub fn run() { // webview can't download, so songs are written to disk and opened natively. .plugin(tauri_plugin_opener::init()) .setup(|app| { - configure_bundled_backend(app)?; // Resolve every filesystem root once and pass the contract to Python // through inherited environment variables. This also performs the // restart-safe macOS model migration. MUST precede every service. platform_paths::configure(app)?; + // Release backends are resolved only after the host-owned asset root + // exists. macOS points at a bundled executable; Windows/Linux point + // at the atomically promoted managed-runtime launcher. + configure_bundled_backend(app)?; // Start the audio host (engine + render thread + device), then spawn // the per-deck inference sidecars fed by the deck handles. Everything // is held in managed state for the app's lifetime. @@ -895,7 +931,7 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{bundled_backend_path, is_combined}; + use super::{bundled_backend_path, is_combined, managed_backend_path}; #[test] fn bundled_backend_lives_under_the_tauri_resource_dir() { @@ -907,6 +943,21 @@ mod tests { ); } + #[test] + fn managed_backend_has_one_stable_promoted_launcher_path() { + let expected_name = if cfg!(windows) { + "lsdj_backend.exe" + } else { + "lsdj_backend" + }; + assert_eq!( + managed_backend_path(std::path::Path::new("/profile with spaces/资产")), + std::path::Path::new("/profile with spaces/资产") + .join("backend/current") + .join(expected_name) + ); + } + /// The cue rides the main device (combined) when no separate cue device is /// chosen — an empty cue name is the "same as main" sentinel. #[test] diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 19c71f7..b9d6166 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1593,6 +1593,7 @@ mod tests { // ONLY on the child Command (never process-global), so they can't race the // sidecar tests that share this binary's environment. + #[cfg(unix)] fn shared() -> InstallShared { InstallShared { busy: AtomicBool::new(false), diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index b144175..dbb69fa 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -601,6 +601,12 @@ pub fn sidecar_base_command() -> io::Result { if let Some(program) = std::env::var_os("LSDJ_BACKEND_BIN") { return Ok(Command::new(program)); } + if std::env::var_os("LSDJ_MANAGED_BACKEND_REQUIRED").is_some() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "the verified app-managed backend runtime is not installed", + )); + } let overridden = std::env::var("LSDJ_SIDECAR_CMD"); let spec = overridden diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..6de184a --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["nsis"], + "windows": { + "allowDowngrades": false, + "webviewInstallMode": { + "type": "downloadBootstrapper", + "silent": true + }, + "nsis": { + "installMode": "currentUser", + "startMenuFolder": "LSDJ", + "installerHooks": "./windows/installer-hooks.nsh", + "languages": ["English"], + "customLanguageFiles": { + "English": "./windows/English.nsh" + } + } + } + } +} diff --git a/src-tauri/tauri.windows.release.conf.json b/src-tauri/tauri.windows.release.conf.json new file mode 100644 index 0000000..c0ca4fa --- /dev/null +++ b/src-tauri/tauri.windows.release.conf.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "windows": { + "signCommand": { + "command": "pwsh.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "../scripts/sign-windows.ps1", + "-Path", + "%1" + ] + } + } + } +} diff --git a/src-tauri/windows/English.nsh b/src-tauri/windows/English.nsh new file mode 100644 index 0000000..f50a0a0 --- /dev/null +++ b/src-tauri/windows/English.nsh @@ -0,0 +1,27 @@ +LangString addOrReinstall ${LANG_ENGLISH} "Add/Reinstall components" +LangString alreadyInstalled ${LANG_ENGLISH} "Already Installed" +LangString alreadyInstalledLong ${LANG_ENGLISH} "${PRODUCTNAME} ${VERSION} is already installed. Select the operation you want to perform and click Next to continue." +LangString appRunning ${LANG_ENGLISH} "${PRODUCTNAME} is running. Close it, then try again." +LangString appRunningOkKill ${LANG_ENGLISH} "${PRODUCTNAME} is running.$\nClick OK to close it." +LangString chooseMaintenanceOption ${LANG_ENGLISH} "Choose the maintenance option to perform." +LangString choowHowToInstall ${LANG_ENGLISH} "Choose how you want to install ${PRODUCTNAME}." +LangString createDesktop ${LANG_ENGLISH} "Create desktop shortcut" +LangString dontUninstall ${LANG_ENGLISH} "Do not uninstall" +LangString dontUninstallDowngrade ${LANG_ENGLISH} "Do not uninstall (downgrading without uninstall is disabled)" +LangString failedToKillApp ${LANG_ENGLISH} "Failed to close ${PRODUCTNAME}. Close it, then try again." +LangString installingWebview2 ${LANG_ENGLISH} "Installing WebView2..." +LangString newerVersionInstalled ${LANG_ENGLISH} "A newer version of ${PRODUCTNAME} is installed. This installer cannot downgrade it." +LangString older ${LANG_ENGLISH} "older" +LangString olderOrUnknownVersionInstalled ${LANG_ENGLISH} "An $R4 version of ${PRODUCTNAME} is installed. Select how to continue." +LangString silentDowngrades ${LANG_ENGLISH} "Downgrades are disabled for this installer.$\n" +LangString unableToUninstall ${LANG_ENGLISH} "Unable to uninstall ${PRODUCTNAME}." +LangString uninstallApp ${LANG_ENGLISH} "Uninstall ${PRODUCTNAME}" +LangString uninstallBeforeInstalling ${LANG_ENGLISH} "Uninstall before installing" +LangString unknown ${LANG_ENGLISH} "unknown" +LangString webview2AbortError ${LANG_ENGLISH} "WebView2 could not be installed. LSDJ cannot run without it. Check the network connection, or install the Microsoft WebView2 Evergreen Runtime and retry." +LangString webview2DownloadError ${LANG_ENGLISH} "WebView2 download failed: $0" +LangString webview2DownloadSuccess ${LANG_ENGLISH} "WebView2 bootstrapper downloaded successfully" +LangString webview2Downloading ${LANG_ENGLISH} "Downloading the Microsoft WebView2 bootstrapper..." +LangString webview2InstallError ${LANG_ENGLISH} "WebView2 installation failed with exit code $1" +LangString webview2InstallSuccess ${LANG_ENGLISH} "WebView2 installed successfully" +LangString deleteAppData ${LANG_ENGLISH} "Also remove downloaded models, runtimes, settings, and user data (the path and size will be confirmed)" diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 0000000..abd2e6b --- /dev/null +++ b/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,1098 @@ +; LSDJ's app-managed assets share Tauri's current-user install root at +; $LOCALAPPDATA\LSDJ. Ownership must be established before Tauri copies any +; payload there. Uninstall preserves the tree by default and recursively +; removes it only after explicit opt-in, exact-path checks, and reparse-safe +; validation. /PURGE-LSDJ-DATA is the equivalent explicit automation choice. + +!define LSDJ_DATA_ROOT "$LOCALAPPDATA\LSDJ" +!define LSDJ_DATA_MARKER "${LSDJ_DATA_ROOT}\.lsdj-data-root" +!define LSDJ_DATA_MARKER_NEW "${LSDJ_DATA_ROOT}\.lsdj-data-root.new" +!define LSDJ_OWNER_ID "works.protocol.lsdj" +!define LSDJ_OWNER_ID_BYTES 19 +!define LSDJ_OWNER_ID_READ_BYTES 20 +!define LSDJ_FILE_ATTRIBUTE_DIRECTORY 0x10 +!define LSDJ_FILE_ATTRIBUTE_REPARSE_POINT 0x400 +!define LSDJ_FILE_ATTRIBUTE_NORMAL 0x80 +!define LSDJ_FILE_FLAG_OPEN_REPARSE_POINT 0x200000 +!define LSDJ_FILE_SHARE_READ 0x1 +!define LSDJ_GENERIC_READ 0x80000000 +!define LSDJ_GENERIC_WRITE 0x40000000 +!define LSDJ_OPEN_EXISTING 3 +!define LSDJ_CREATE_NEW 1 +!define LSDJ_INVALID_FILE_ATTRIBUTES -1 +!define LSDJ_INVALID_HANDLE_VALUE -1 + +Var LsdjCanonicalRootSafe +Var LsdjDeleteData +Var LsdjDeleteFailure +Var LsdjDataRemovalFailed +Var LsdjInstallRootState +Var LsdjInstalledVersion +Var LsdjInstalledVersionPresent +Var LsdjInstalledVersionValidity +Var LsdjExistingInstall +Var LsdjMarkerSafe +Var LsdjOwnedRootSafe +Var LsdjPassiveRequested +Var LsdjRegistryEvidence +Var LsdjRootEmpty +Var LsdjSafeLayout +Var LsdjTreeSafe +Var LsdjVersionCompare + +; Unsigned hosted-test installers can leave a narrow control-flow trace when a +; silent fail-closed branch returns only NSIS's generic exit code. Production +; builds do not define LSDJ_CI_ADVERSARIAL_TESTS, so every trace call expands +; to no instructions. Preserve both the scratch register and the caller's NSIS +; error flag so diagnostics cannot change installer decisions. +!ifdef LSDJ_CI_ADVERSARIAL_TESTS + Var LsdjCiTraceHadErrors + Var LsdjCiTraceMessage + !macro LSDJ_CI_TRACE MESSAGE + ${If} ${Errors} + StrCpy $LsdjCiTraceHadErrors 1 + ${Else} + StrCpy $LsdjCiTraceHadErrors 0 + ${EndIf} + ; Capture interpolated register values before using R5 for the trace file + ; handle, otherwise messages containing $R5 log that temporary handle. + StrCpy $LsdjCiTraceMessage "${MESSAGE}" + Push $R5 + ClearErrors + FileOpen $R5 "$TEMP\lsdj-ci-installer.trace" a + ${IfNot} ${Errors} + ; NSIS preserves existing contents for mode `a` but still positions the + ; file pointer at byte zero. Seek explicitly so checkpoints cannot + ; overwrite one another. + FileSeek $R5 0 END + FileWrite $R5 "$LsdjCiTraceMessage$\r$\n" + FileClose $R5 + ${EndIf} + Pop $R5 + ${If} $LsdjCiTraceHadErrors = 1 + SetErrors + ${Else} + ClearErrors + ${EndIf} + !macroend +!else + !macro LSDJ_CI_TRACE MESSAGE + !macroend +!endif + +; Passive mode reaches Tauri's maintenance page before any installer section, +; and that page may uninstall an existing NSIS or legacy WiX installation. +; Reject /P at GUI initialization, before PageReinstall can run. Repeat the +; same owned command-line check in PREINSTALL for the combined /S /P case, +; where NSIS does not initialize the GUI. +!macro LSDJ_REJECT_PASSIVE_MODE + StrCpy $LsdjPassiveRequested 0 + ClearErrors + ${GetOptions} $CMDLINE "/P" $LsdjPassiveRequested + ${IfNot} ${Errors} + !insertmacro LSDJ_CI_TRACE "abort: passive install unsupported" + SetErrorLevel 2 + Quit + ${EndIf} + ClearErrors +!macroend + +!define MUI_CUSTOMFUNCTION_GUIINIT LsdjRejectPassiveMode +Function LsdjRejectPassiveMode + !insertmacro LSDJ_REJECT_PASSIVE_MODE +FunctionEnd + +; GetFullPathNameW is lexical and does not traverse the candidate. The exact +; canonical target must equal canonical LOCALAPPDATA + \LSDJ; callers then +; separately reject a root reparse point before reading or changing it. +!macro LSDJ_DEFINE_CANONICAL_ROOT_VALIDATOR FUNCTION_NAME +Function ${FUNCTION_NAME} + Push $R3 + Push $R4 + Push $R5 + Push $R6 + Push $R7 + Push $R8 + StrCpy $LsdjCanonicalRootSafe 0 + + System::Call 'kernel32::GetFullPathNameW(w "$LOCALAPPDATA", i 1024, w .R7, p 0) i .R8' + ${If} $R8 = 0 + ${OrIf} $R8 >= 1024 + Goto lsdj_canonical_done + ${EndIf} + StrCpy $R6 "$R7\LSDJ" + System::Call 'kernel32::GetFullPathNameW(w R6, i 1024, w .R4, p 0) i .R3' + ${If} $R3 = 0 + ${OrIf} $R3 >= 1024 + Goto lsdj_canonical_done + ${EndIf} + System::Call 'kernel32::GetFullPathNameW(w "${LSDJ_DATA_ROOT}", i 1024, w .R5, p 0) i .R8' + ${If} $R8 = 0 + ${OrIf} $R8 >= 1024 + Goto lsdj_canonical_done + ${EndIf} + System::Call 'kernel32::lstrcmpiW(w R5, w R4) i .R8' + ${If} $R8 = 0 + StrCpy $LsdjCanonicalRootSafe 1 + ${EndIf} + + lsdj_canonical_done: + Pop $R8 + Pop $R7 + Pop $R6 + Pop $R5 + Pop $R4 + Pop $R3 +FunctionEnd +!macroend + +!insertmacro LSDJ_DEFINE_CANONICAL_ROOT_VALIDATOR LsdjCanonicalDataRootIsValid +!insertmacro LSDJ_DEFINE_CANONICAL_ROOT_VALIDATOR un.LsdjCanonicalDataRootIsValid + +; Return LsdjMarkerSafe=1 only for a plain, non-reparse marker whose complete +; contents are the exact LSDJ application identifier. CreateFile opens the +; reparse entry itself and denies write/delete sharing. Both metadata and +; content are then read from that same native handle, so validation never +; reopens the marker by path. +!macro LSDJ_DEFINE_MARKER_VALIDATOR FUNCTION_NAME +Function ${FUNCTION_NAME} + Push $R5 + Push $R6 + Push $R7 + Push $R8 + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + Push $R9 + !endif + StrCpy $LsdjMarkerSafe 0 + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + System::Call 'kernel32::CreateFileW(w "${LSDJ_DATA_MARKER}", i ${LSDJ_GENERIC_READ}, i ${LSDJ_FILE_SHARE_READ}, p 0, i ${LSDJ_OPEN_EXISTING}, i ${LSDJ_FILE_FLAG_OPEN_REPARSE_POINT}, p 0) p .R6 ?e' + Pop $R9 + !insertmacro LSDJ_CI_TRACE "marker-validate: create handle=$R6 error=$R9" + !else + System::Call 'kernel32::CreateFileW(w "${LSDJ_DATA_MARKER}", i ${LSDJ_GENERIC_READ}, i ${LSDJ_FILE_SHARE_READ}, p 0, i ${LSDJ_OPEN_EXISTING}, i ${LSDJ_FILE_FLAG_OPEN_REPARSE_POINT}, p 0) p .R6' + !endif + StrCmp $R6 ${LSDJ_INVALID_HANDLE_VALUE} lsdj_marker_done + StrCpy $R7 0 + System::Alloc 52 + Pop $R7 + !insertmacro LSDJ_CI_TRACE "marker-validate: info buffer=$R7" + StrCmp $R7 0 lsdj_marker_close + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + System::Call 'kernel32::GetFileInformationByHandle(p R6, p R7) i .R8 ?e' + Pop $R9 + !insertmacro LSDJ_CI_TRACE "marker-validate: info result=$R8 error=$R9" + !else + System::Call 'kernel32::GetFileInformationByHandle(p R6, p R7) i .R8' + !endif + ${If} $R8 = 0 + Goto lsdj_marker_info_failed + ${EndIf} + System::Call '*$R7(&i4 .R8)' + System::Free $R7 + StrCpy $R7 0 + !insertmacro LSDJ_CI_TRACE "marker-validate: attrs=$R8" + IntOp $R5 $R8 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $R5 <> 0 + Goto lsdj_marker_close + ${EndIf} + IntOp $R5 $R8 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $R5 <> 0 + Goto lsdj_marker_close + ${EndIf} + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + System::Call 'kernel32::ReadFile(p R6, m .R8, i ${LSDJ_OWNER_ID_READ_BYTES}, *i .R7, p 0) i .R5 ?e' + Pop $R9 + !insertmacro LSDJ_CI_TRACE "marker-validate: read result=$R5 bytes=$R7 error=$R9" + !else + System::Call 'kernel32::ReadFile(p R6, m .R8, i ${LSDJ_OWNER_ID_READ_BYTES}, *i .R7, p 0) i .R5' + !endif + ${If} $R5 = 0 + ${OrIf} $R7 != ${LSDJ_OWNER_ID_BYTES} + Goto lsdj_marker_close + ${EndIf} + StrCmp $R8 "${LSDJ_OWNER_ID}" 0 lsdj_marker_close + StrCpy $LsdjMarkerSafe 1 + Goto lsdj_marker_close + + lsdj_marker_info_failed: + System::Free $R7 + StrCpy $R7 0 + + lsdj_marker_close: + System::Call 'kernel32::CloseHandle(p R6)' + lsdj_marker_done: + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + Pop $R9 + !endif + Pop $R8 + Pop $R7 + Pop $R6 + Pop $R5 +FunctionEnd +!macroend + +!insertmacro LSDJ_DEFINE_MARKER_VALIDATOR LsdjDataMarkerIsValid +!insertmacro LSDJ_DEFINE_MARKER_VALIDATOR un.LsdjDataMarkerIsValid + +; Create the marker without following or overwriting an entry raced into the +; temporary path. The fixed byte count is asserted by the packaging contracts. +Function LsdjCreateDataMarker + Push $R6 + Push $R7 + Push $R8 + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + Push $R9 + !endif + StrCpy $LsdjMarkerSafe 0 + StrCpy $R7 0 + !insertmacro LSDJ_CI_TRACE "marker-create: begin" + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + System::Call 'kernel32::CreateFileW(w "${LSDJ_DATA_MARKER_NEW}", i ${LSDJ_GENERIC_WRITE}, i 0, p 0, i ${LSDJ_CREATE_NEW}, i ${LSDJ_FILE_ATTRIBUTE_NORMAL}|${LSDJ_FILE_FLAG_OPEN_REPARSE_POINT}, p 0) p .R6 ?e' + Pop $R9 + !insertmacro LSDJ_CI_TRACE "marker-create: create handle=$R6 error=$R9" + !else + System::Call 'kernel32::CreateFileW(w "${LSDJ_DATA_MARKER_NEW}", i ${LSDJ_GENERIC_WRITE}, i 0, p 0, i ${LSDJ_CREATE_NEW}, i ${LSDJ_FILE_ATTRIBUTE_NORMAL}|${LSDJ_FILE_FLAG_OPEN_REPARSE_POINT}, p 0) p .R6' + !endif + StrCmp $R6 ${LSDJ_INVALID_HANDLE_VALUE} lsdj_marker_create_done + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + System::Call 'kernel32::WriteFile(p R6, m "${LSDJ_OWNER_ID}", i ${LSDJ_OWNER_ID_BYTES}, *i .R8, p 0) i .R7 ?e' + Pop $R9 + !insertmacro LSDJ_CI_TRACE "marker-create: write result=$R7 bytes=$R8 error=$R9" + !else + System::Call 'kernel32::WriteFile(p R6, m "${LSDJ_OWNER_ID}", i ${LSDJ_OWNER_ID_BYTES}, *i .R8, p 0) i .R7' + !endif + System::Call 'kernel32::CloseHandle(p R6)' + ${If} $R7 = 0 + ${OrIf} $R8 != ${LSDJ_OWNER_ID_BYTES} + Goto lsdj_marker_done + ${EndIf} + StrCpy $R7 0 + ClearErrors + Rename "${LSDJ_DATA_MARKER_NEW}" "${LSDJ_DATA_MARKER}" + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + ${If} ${Errors} + StrCpy $R8 1 + ${Else} + StrCpy $R8 0 + ${EndIf} + System::Call 'kernel32::GetLastError() i .R9' + !insertmacro LSDJ_CI_TRACE "marker-create: rename error-flag=$R8 native-error=$R9" + ${If} $R8 = 1 + Goto lsdj_marker_create_done + ${EndIf} + !else + IfErrors lsdj_marker_create_done + !endif + !insertmacro LSDJ_CI_TRACE "marker-create: rename succeeded" + StrCpy $R7 1 + Call LsdjDataMarkerIsValid + !insertmacro LSDJ_CI_TRACE "marker-create: validate safe=$LsdjMarkerSafe" + ${If} $LsdjMarkerSafe = 1 + Goto lsdj_marker_create_done + ${EndIf} + StrCpy $LsdjMarkerSafe 0 + + lsdj_marker_done: + Delete "${LSDJ_DATA_MARKER_NEW}" + lsdj_marker_create_done: + ${If} $LsdjMarkerSafe != 1 + Delete "${LSDJ_DATA_MARKER_NEW}" + ${If} $R7 = 1 + Delete "${LSDJ_DATA_MARKER}" + ${EndIf} + ${EndIf} + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + Pop $R9 + !endif + Pop $R8 + Pop $R7 + Pop $R6 +FunctionEnd + +; A legacy app-created layout without an ownership marker is recognized only +; when its top level is exactly the five roots created by platform_paths.rs. +; Empty, partial, foreign, file-bearing, or reparse-bearing roots are rejected. +Function LsdjExistingLayoutIsRecognized + Push $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 + Push $7 + StrCpy $LsdjSafeLayout 0 + StrCpy $2 0 + StrCpy $3 0 + StrCpy $4 0 + StrCpy $5 0 + StrCpy $6 0 + + ClearErrors + FindFirst $0 $1 "${LSDJ_DATA_ROOT}\*" + IfErrors lsdj_layout_done + lsdj_layout_next: + StrCmp $1 "." lsdj_layout_advance + StrCmp $1 ".." lsdj_layout_advance + StrCmp $1 "config" lsdj_layout_config + StrCmp $1 "data" lsdj_layout_data + StrCmp $1 "cache" lsdj_layout_cache + StrCmp $1 "assets" lsdj_layout_assets + StrCmp $1 "staging" lsdj_layout_staging + Goto lsdj_layout_close + + lsdj_layout_config: + StrCpy $2 1 + Goto lsdj_layout_validate_directory + lsdj_layout_data: + StrCpy $3 1 + Goto lsdj_layout_validate_directory + lsdj_layout_cache: + StrCpy $4 1 + Goto lsdj_layout_validate_directory + lsdj_layout_assets: + StrCpy $5 1 + Goto lsdj_layout_validate_directory + lsdj_layout_staging: + StrCpy $6 1 + + lsdj_layout_validate_directory: + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}\$1") i .r7' + ${If} $7 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_layout_close + ${EndIf} + IntOp $7 $7 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $7 <> 0 + Goto lsdj_layout_close + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}\$1") i .r7' + IntOp $7 $7 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $7 = 0 + Goto lsdj_layout_close + ${EndIf} + + lsdj_layout_advance: + ClearErrors + FindNext $0 $1 + IfErrors lsdj_layout_complete + Goto lsdj_layout_next + + lsdj_layout_complete: + ${If} $2 = 1 + ${AndIf} $3 = 1 + ${AndIf} $4 = 1 + ${AndIf} $5 = 1 + ${AndIf} $6 = 1 + StrCpy $LsdjSafeLayout 1 + ${EndIf} + + lsdj_layout_close: + FindClose $0 + lsdj_layout_done: + Pop $7 + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function LsdjInstallTreeIsLinkFree + Exch $0 + Push $1 + Push $2 + Push $3 + ${If} $LsdjTreeSafe = 0 + Goto lsdj_install_tree_done + ${EndIf} + + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_install_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_install_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_install_tree_unsafe + ${EndIf} + + ClearErrors + FindFirst $1 $2 "$0\*" + IfErrors lsdj_install_tree_empty_candidate + lsdj_install_tree_next: + StrCmp $2 "." lsdj_install_tree_advance + StrCmp $2 ".." lsdj_install_tree_advance + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + ${If} $3 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_install_tree_unsafe_close + ${EndIf} + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_install_tree_unsafe_close + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 <> 0 + Push "$0\$2" + Call LsdjInstallTreeIsLinkFree + ${If} $LsdjTreeSafe = 0 + Goto lsdj_install_tree_close + ${EndIf} + ${EndIf} + lsdj_install_tree_advance: + ClearErrors + FindNext $1 $2 + IfErrors lsdj_install_tree_close + Goto lsdj_install_tree_next + + lsdj_install_tree_unsafe_close: + StrCpy $LsdjTreeSafe 0 + lsdj_install_tree_close: + FindClose $1 + Goto lsdj_install_tree_done + ; FindFirst reports an error for a plain empty directory. Re-read the entry + ; itself before accepting that error as an empty leaf, so disappearance, + ; replacement, and reparse-point races still fail closed. + lsdj_install_tree_empty_candidate: + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_install_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_install_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_install_tree_unsafe + ${EndIf} + Goto lsdj_install_tree_done + lsdj_install_tree_unsafe: + StrCpy $LsdjTreeSafe 0 + lsdj_install_tree_done: + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function LsdjDataRootIsEmpty + Push $0 + Push $1 + StrCpy $LsdjRootEmpty 0 + ClearErrors + FindFirst $0 $1 "${LSDJ_DATA_ROOT}\*" + IfErrors lsdj_empty_recheck + lsdj_empty_next: + StrCmp $1 "." lsdj_empty_advance + StrCmp $1 ".." lsdj_empty_advance + Goto lsdj_empty_close + lsdj_empty_advance: + ClearErrors + FindNext $0 $1 + IfErrors lsdj_empty_confirmed + Goto lsdj_empty_next + lsdj_empty_confirmed: + StrCpy $LsdjRootEmpty 1 + lsdj_empty_close: + FindClose $0 + Goto lsdj_empty_done + ; Tauri's SetOutPath creates a genuinely empty root on first install. Accept + ; the failed enumeration only after the root is still the same plain + ; directory shape required by the ownership checks. + lsdj_empty_recheck: + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .r0' + ${If} $0 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_empty_done + ${EndIf} + IntOp $1 $0 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $1 <> 0 + Goto lsdj_empty_done + ${EndIf} + IntOp $1 $0 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $1 = 0 + Goto lsdj_empty_done + ${EndIf} + StrCpy $LsdjRootEmpty 1 + lsdj_empty_done: + Pop $1 + Pop $0 +FunctionEnd + +; installerHooks is included before Tauri declares any of its sections. NSIS +; executes sections in declaration order, so this hidden, always-selected probe +; observes the root before Tauri's Install section executes SetOutPath. It does +; not create or mark anything: PREINSTALL uses this captured state after +; SetOutPath and immediately revalidates before establishing ownership. +Section -LsdjProbeDataRootBeforeTauri + StrCpy $LsdjInstallRootState 0 + !insertmacro LSDJ_CI_TRACE "probe: begin root=${LSDJ_DATA_ROOT}" + Call LsdjCanonicalDataRootIsValid + !insertmacro LSDJ_CI_TRACE "probe: canonical=$LsdjCanonicalRootSafe" + ${If} $LsdjCanonicalRootSafe != 1 + !insertmacro LSDJ_CI_TRACE "abort: probe canonical root" + Abort "Refusing to install: the LSDJ data root is not the exact LocalAppData target." + ${EndIf} + + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .R8' + !insertmacro LSDJ_CI_TRACE "probe: root attributes=$R8" + ${If} $R8 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_probe_absent + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $R7 <> 0 + !insertmacro LSDJ_CI_TRACE "abort: probe root reparse" + Abort "Refusing to install into a junction, symbolic link, or reparse point at ${LSDJ_DATA_ROOT}." + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $R7 = 0 + !insertmacro LSDJ_CI_TRACE "abort: probe root not directory" + Abort "Refusing to install: ${LSDJ_DATA_ROOT} exists but is not a directory." + ${EndIf} + + ClearErrors + FindFirst $R7 $R8 "${LSDJ_DATA_MARKER}" + IfErrors lsdj_probe_legacy + FindClose $R7 + !insertmacro LSDJ_CI_TRACE "probe: marker entry present" + Call LsdjDataMarkerIsValid + !insertmacro LSDJ_CI_TRACE "probe: marker safe=$LsdjMarkerSafe" + ${If} $LsdjMarkerSafe != 1 + !insertmacro LSDJ_CI_TRACE "abort: probe marker invalid" + Abort "Refusing to install: the LSDJ ownership marker is invalid or is a reparse point." + ${EndIf} + StrCpy $LsdjInstallRootState 3 + Goto lsdj_probe_done + + lsdj_probe_legacy: + !insertmacro LSDJ_CI_TRACE "probe: marker absent, checking legacy layout" + Call LsdjExistingLayoutIsRecognized + !insertmacro LSDJ_CI_TRACE "probe: legacy layout=$LsdjSafeLayout" + StrCpy $LsdjTreeSafe 1 + ${If} $LsdjSafeLayout = 1 + Push "${LSDJ_DATA_ROOT}" + Call LsdjInstallTreeIsLinkFree + ${EndIf} + !insertmacro LSDJ_CI_TRACE "probe: legacy tree=$LsdjTreeSafe" + ${If} $LsdjSafeLayout != 1 + ${OrIf} $LsdjTreeSafe != 1 + !insertmacro LSDJ_CI_TRACE "abort: probe legacy ownership" + Abort "Refusing to claim a pre-existing foreign or unrecognized directory at ${LSDJ_DATA_ROOT}." + ${EndIf} + StrCpy $LsdjInstallRootState 2 + !insertmacro LSDJ_CI_TRACE "probe: classified legacy state=2" + Goto lsdj_probe_done + + lsdj_probe_absent: + StrCpy $LsdjInstallRootState 1 + !insertmacro LSDJ_CI_TRACE "probe: classified absent state=1" + lsdj_probe_done: +SectionEnd + +; Validate the exact root and marker together. GetFileAttributesW reports the +; root entry itself, so directory junctions and symbolic links are rejected. +Function un.LsdjOwnedDataRootIsSafe + Push $R7 + Push $R8 + StrCpy $LsdjOwnedRootSafe 0 + Call un.LsdjCanonicalDataRootIsValid + ${If} $LsdjCanonicalRootSafe != 1 + Goto lsdj_owned_root_done + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .R8' + ${If} $R8 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_owned_root_done + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $R7 <> 0 + Goto lsdj_owned_root_done + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $R7 = 0 + Goto lsdj_owned_root_done + ${EndIf} + Call un.LsdjDataMarkerIsValid + ${If} $LsdjMarkerSafe = 1 + StrCpy $LsdjOwnedRootSafe 1 + ${EndIf} + + lsdj_owned_root_done: + Pop $R8 + Pop $R7 +FunctionEnd + +; Walk the tree without traversing a reparse point. Purge is refused before +; GetSize if any link is present, so the disclosed size covers only the tree +; that the safe deleter is allowed to remove. +Function un.LsdjTreeIsLinkFree + Exch $0 + Push $1 + Push $2 + Push $3 + ${If} $LsdjTreeSafe = 0 + Goto lsdj_tree_done + ${EndIf} + + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_tree_unsafe + ${EndIf} + + ClearErrors + FindFirst $1 $2 "$0\*" + IfErrors lsdj_tree_empty_candidate + lsdj_tree_next: + StrCmp $2 "." lsdj_tree_advance + StrCmp $2 ".." lsdj_tree_advance + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + ${If} $3 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_tree_unsafe_close + ${EndIf} + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_tree_unsafe_close + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 <> 0 + Push "$0\$2" + Call un.LsdjTreeIsLinkFree + ${If} $LsdjTreeSafe = 0 + Goto lsdj_tree_close + ${EndIf} + ${EndIf} + lsdj_tree_advance: + ClearErrors + FindNext $1 $2 + IfErrors lsdj_tree_close + Goto lsdj_tree_next + + lsdj_tree_unsafe_close: + StrCpy $LsdjTreeSafe 0 + lsdj_tree_close: + FindClose $1 + Goto lsdj_tree_done + lsdj_tree_empty_candidate: + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_tree_unsafe + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_tree_unsafe + ${EndIf} + Goto lsdj_tree_done + lsdj_tree_unsafe: + StrCpy $LsdjTreeSafe 0 + lsdj_tree_done: + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +; Recursive deletion mirrors the validation walk and refuses any reparse entry +; observed during deletion. It never invokes NSIS's broad recursive-directory +; removal and never descends through a junction or symbolic link, including one +; introduced after confirmation. +Function un.LsdjDeleteTreeWithoutLinks + Exch $0 + Push $1 + Push $2 + Push $3 + ${If} $LsdjDeleteFailure = 1 + Goto lsdj_delete_done + ${EndIf} + + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_delete_failed + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_delete_failed + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_delete_failed + ${EndIf} + + ClearErrors + FindFirst $1 $2 "$0\*" + IfErrors lsdj_delete_empty_candidate + lsdj_delete_next: + StrCmp $2 "." lsdj_delete_advance + StrCmp $2 ".." lsdj_delete_advance + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + ${If} $3 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_delete_failed_close + ${EndIf} + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_delete_failed_close + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "$0\$2") i .r3' + IntOp $3 $3 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 <> 0 + Push "$0\$2" + Call un.LsdjDeleteTreeWithoutLinks + ${If} $LsdjDeleteFailure = 1 + Goto lsdj_delete_close + ${EndIf} + ${Else} + ClearErrors + Delete "$0\$2" + IfErrors lsdj_delete_failed_close + ${EndIf} + lsdj_delete_advance: + ClearErrors + FindNext $1 $2 + IfErrors lsdj_delete_close + Goto lsdj_delete_next + + lsdj_delete_failed_close: + StrCpy $LsdjDeleteFailure 1 + lsdj_delete_close: + FindClose $1 + ${If} $LsdjDeleteFailure = 0 + ClearErrors + RMDir "$0" + IfErrors lsdj_delete_failed + ${EndIf} + Goto lsdj_delete_done + lsdj_delete_empty_candidate: + System::Call 'kernel32::GetFileAttributesW(w r0) i .r1' + ${If} $1 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_delete_failed + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $3 <> 0 + Goto lsdj_delete_failed + ${EndIf} + IntOp $3 $1 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $3 = 0 + Goto lsdj_delete_failed + ${EndIf} + ClearErrors + RMDir "$0" + IfErrors lsdj_delete_failed + Goto lsdj_delete_done + lsdj_delete_failed: + StrCpy $LsdjDeleteFailure 1 + lsdj_delete_done: + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +!macro NSIS_HOOK_PREINSTALL + !insertmacro LSDJ_REJECT_PASSIVE_MODE + + ; Tauri's native silent downgrade check aborts with exit code 0 and leaves its + ; comparison in a volatile register. Re-establish install evidence and + ; validate DisplayVersion independently before root checks or payload writes. + ${If} ${Silent} + StrCpy $LsdjExistingInstall 0 + StrCpy $LsdjInstalledVersion "" + StrCpy $LsdjInstalledVersionPresent 0 + + ClearErrors + ReadRegStr $LsdjInstalledVersion SHCTX "${UNINSTKEY}" "DisplayVersion" + ${IfNot} ${Errors} + StrCpy $LsdjExistingInstall 1 + StrCpy $LsdjInstalledVersionPresent 1 + ${EndIf} + ClearErrors + ReadRegStr $LsdjRegistryEvidence SHCTX "${UNINSTKEY}" "UninstallString" + ${IfNot} ${Errors} + StrCpy $LsdjExistingInstall 1 + ${EndIf} + ClearErrors + ReadRegStr $LsdjRegistryEvidence SHCTX "${UNINSTKEY}" "DisplayName" + ${IfNot} ${Errors} + StrCpy $LsdjExistingInstall 1 + ${EndIf} + ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $LsdjExistingInstall 1 + ${EndIf} + ClearErrors + !insertmacro LSDJ_CI_TRACE "preinstall: version evidence installed=$LsdjInstalledVersion present=$LsdjInstalledVersionPresent existing=$LsdjExistingInstall" + + ${If} $LsdjExistingInstall = 1 + ${If} "$LsdjInstalledVersion" == "" + !insertmacro LSDJ_CI_TRACE "abort: installed version missing" + SetErrorLevel 2 + Abort "Unable to verify the existing LSDJ version." + ${EndIf} + + ; nsis-tauri-utils orders any valid SemVer above an invalid one. Comparing + ; against a deliberately invalid sentinel distinguishes invalid metadata + ; from a legitimate upgrade, which SemverCompare alone otherwise cannot. + nsis_tauri_utils::SemverCompare "$LsdjInstalledVersion" "lsdj-invalid-semver" + Pop $LsdjInstalledVersionValidity + !insertmacro LSDJ_CI_TRACE "preinstall: installed version=$LsdjInstalledVersion validity=$LsdjInstalledVersionValidity" + ${If} $LsdjInstalledVersionValidity != 1 + !insertmacro LSDJ_CI_TRACE "abort: installed version invalid" + SetErrorLevel 2 + Abort "Unable to verify the existing LSDJ version." + ${EndIf} + + nsis_tauri_utils::SemverCompare "${VERSION}" "$LsdjInstalledVersion" + Pop $LsdjVersionCompare + !insertmacro LSDJ_CI_TRACE "preinstall: version compare=$LsdjVersionCompare" + ${If} $LsdjVersionCompare = -1 + !insertmacro LSDJ_CI_TRACE "abort: unattended downgrade" + SetErrorLevel 2 + Abort "Refusing to downgrade LSDJ from a newer installed version." + ${ElseIf} $LsdjVersionCompare != 0 + ${AndIf} $LsdjVersionCompare != 1 + !insertmacro LSDJ_CI_TRACE "abort: invalid version comparison" + SetErrorLevel 2 + Abort "Unable to compare the installed LSDJ version safely." + ${EndIf} + ${EndIf} + ${EndIf} + + ; SetOutPath has now created a root that the early section proved absent, or + ; selected an existing root whose ownership/layout the early section proved. + ; Revalidate that captured state before writing anything into the directory. + !insertmacro LSDJ_CI_TRACE "preinstall: begin state=$LsdjInstallRootState" + Call LsdjCanonicalDataRootIsValid + !insertmacro LSDJ_CI_TRACE "preinstall: canonical=$LsdjCanonicalRootSafe" + ${If} $LsdjCanonicalRootSafe != 1 + !insertmacro LSDJ_CI_TRACE "abort: preinstall canonical root" + Abort "Refusing to install: the LSDJ data root is not the exact LocalAppData target." + ${EndIf} + + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .R8' + !insertmacro LSDJ_CI_TRACE "preinstall: root attributes=$R8" + ${If} $R8 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + ${If} $LsdjInstallRootState != 1 + Goto lsdj_install_root_missing + ${EndIf} + ; A custom /D install location means Tauri's SetOutPath did not create the + ; separate LocalAppData root. The early section proved it absent; create it + ; now, fail if another entry won the race, and validate the new entry below. + ClearErrors + CreateDirectory "${LSDJ_DATA_ROOT}" + IfErrors lsdj_install_root_create_failed + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .R8' + ${If} $R8 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Goto lsdj_install_root_create_failed + ${EndIf} + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $R7 <> 0 + !insertmacro LSDJ_CI_TRACE "abort: preinstall root reparse" + Abort "Refusing to install into a junction, symbolic link, or reparse point at ${LSDJ_DATA_ROOT}." + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_DIRECTORY} + ${If} $R7 = 0 + !insertmacro LSDJ_CI_TRACE "abort: preinstall root not directory" + Abort "Refusing to install: ${LSDJ_DATA_ROOT} exists but is not a directory." + ${EndIf} + + ${If} $LsdjInstallRootState = 1 + Call LsdjDataRootIsEmpty + !insertmacro LSDJ_CI_TRACE "preinstall: fresh root empty=$LsdjRootEmpty" + ${If} $LsdjRootEmpty != 1 + !insertmacro LSDJ_CI_TRACE "abort: preinstall fresh root changed" + Abort "Refusing to install: the newly created LSDJ root changed before ownership was established." + ${EndIf} + Goto lsdj_write_data_marker + ${ElseIf} $LsdjInstallRootState = 2 + Call LsdjExistingLayoutIsRecognized + !insertmacro LSDJ_CI_TRACE "preinstall: legacy layout=$LsdjSafeLayout" + StrCpy $LsdjTreeSafe 1 + ${If} $LsdjSafeLayout = 1 + Push "${LSDJ_DATA_ROOT}" + Call LsdjInstallTreeIsLinkFree + ${EndIf} + !insertmacro LSDJ_CI_TRACE "preinstall: legacy tree=$LsdjTreeSafe" + ${If} $LsdjSafeLayout != 1 + ${OrIf} $LsdjTreeSafe != 1 + !insertmacro LSDJ_CI_TRACE "abort: preinstall legacy ownership changed" + Abort "Refusing to install: the recognized LSDJ layout changed before ownership was established." + ${EndIf} + Goto lsdj_write_data_marker + ${ElseIf} $LsdjInstallRootState = 3 + Call LsdjDataMarkerIsValid + !insertmacro LSDJ_CI_TRACE "preinstall: owned marker safe=$LsdjMarkerSafe" + ${If} $LsdjMarkerSafe != 1 + Abort "Refusing to install: LSDJ ownership changed after the early root probe." + ${EndIf} + Goto lsdj_install_root_ready + ${Else} + !insertmacro LSDJ_CI_TRACE "abort: preinstall unknown root state" + Abort "Refusing to install: the LSDJ data root was not safely classified before SetOutPath." + ${EndIf} + + lsdj_write_data_marker: + Call LsdjCreateDataMarker + !insertmacro LSDJ_CI_TRACE "preinstall: marker result=$LsdjMarkerSafe" + ${If} $LsdjMarkerSafe != 1 + Goto lsdj_marker_create_failed + ${EndIf} + Goto lsdj_install_root_ready + + lsdj_marker_create_failed: + Delete "${LSDJ_DATA_MARKER_NEW}" + Abort "Refusing to install: a plain LSDJ ownership marker could not be established safely." + lsdj_install_root_missing: + Abort "Refusing to install: Tauri did not create the LSDJ root classified by the early ownership probe." + lsdj_install_root_create_failed: + Abort "Refusing to install: the separately located LSDJ data root could not be created safely." + lsdj_install_root_ready: + !insertmacro LSDJ_CI_TRACE "preinstall: ready" +!macroend + +!macro NSIS_HOOK_POSTINSTALL + ; A second check ensures copying never silently replaced the owned root or + ; marker. Do not rewrite or repair either safety boundary here. + !insertmacro LSDJ_CI_TRACE "postinstall: begin" + Call LsdjCanonicalDataRootIsValid + !insertmacro LSDJ_CI_TRACE "postinstall: canonical=$LsdjCanonicalRootSafe" + ${If} $LsdjCanonicalRootSafe != 1 + Abort "LSDJ installation did not retain the exact LocalAppData root." + ${EndIf} + System::Call 'kernel32::GetFileAttributesW(w "${LSDJ_DATA_ROOT}") i .R8' + !insertmacro LSDJ_CI_TRACE "postinstall: root attributes=$R8" + ${If} $R8 = ${LSDJ_INVALID_FILE_ATTRIBUTES} + Abort "LSDJ installation lost its LocalAppData root." + ${EndIf} + IntOp $R7 $R8 & ${LSDJ_FILE_ATTRIBUTE_REPARSE_POINT} + ${If} $R7 <> 0 + Abort "LSDJ installation encountered an unsafe data-root reparse point." + ${EndIf} + Call LsdjDataMarkerIsValid + !insertmacro LSDJ_CI_TRACE "postinstall: marker safe=$LsdjMarkerSafe" + ${If} $LsdjMarkerSafe != 1 + Abort "LSDJ installation did not retain its plain ownership marker." + ${EndIf} +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + ; The normal checkbox is deliberately unchecked by default. Silent removal + ; must name the destructive option; /S alone always preserves user assets. + StrCpy $LsdjDeleteData 0 + StrCpy $LsdjDataRemovalFailed 0 + ClearErrors + ${GetOptions} $CMDLINE "/PURGE-LSDJ-DATA" $R8 + ${IfNot} ${Errors} + StrCpy $DeleteAppDataCheckboxState 1 + ${EndIf} + + ${If} $DeleteAppDataCheckboxState = 1 + Call un.LsdjOwnedDataRootIsSafe + StrCpy $LsdjTreeSafe 1 + ${If} $LsdjOwnedRootSafe = 1 + Push "${LSDJ_DATA_ROOT}" + Call un.LsdjTreeIsLinkFree + ${EndIf} + !insertmacro LSDJ_CI_TRACE "preuninstall: owned root safe=$LsdjOwnedRootSafe tree safe=$LsdjTreeSafe" + ${If} $LsdjOwnedRootSafe != 1 + ${OrIf} $LsdjTreeSafe != 1 + StrCpy $LsdjDataRemovalFailed 1 + DetailPrint "Refusing to remove LSDJ data: exact ownership or reparse-safety validation failed at ${LSDJ_DATA_ROOT}" + StrCpy $DeleteAppDataCheckboxState 0 + ${IfNot} ${Silent} + MessageBox MB_ICONSTOP|MB_OK "LSDJ will preserve the data at:$\n${LSDJ_DATA_ROOT}$\n$\nThe root or ownership marker is invalid, or the tree contains a reparse point, so automatic removal is unsafe." + ${EndIf} + ; Stop before Tauri's ordinary payload deletion too: the application and + ; data share a root in the default layout, so continuing after a root + ; ownership failure could make even narrow file deletions unsafe. + !insertmacro LSDJ_CI_TRACE "abort: unsafe data removal" + SetErrorLevel 2 + Quit + ${EndIf} + + ; GetSize reports KiB. The link-free walk above prevents it from traversing + ; junctions while measuring the exact tree that may be removed. + ${GetSize} "${LSDJ_DATA_ROOT}" "/S=0K" $R8 $R9 $R7 + DetailPrint "Selected LSDJ data removal: ${LSDJ_DATA_ROOT} ($R8 KiB)" + ${IfNot} ${Silent} + MessageBox MB_ICONEXCLAMATION|MB_YESNO|MB_DEFBUTTON2 "Permanently remove downloaded models, runtimes, settings, and user data?$\n$\nLocation: ${LSDJ_DATA_ROOT}$\nSize: $R8 KiB$\n$\nThis cannot be undone." IDYES lsdj_confirm_data_removal IDNO lsdj_keep_data + lsdj_keep_data: + StrCpy $DeleteAppDataCheckboxState 0 + Goto lsdj_data_decision_done + lsdj_confirm_data_removal: + ${EndIf} + ${EndIf} + lsdj_data_decision_done: + + ; Tauri's generic checkbox handling recursively removes undisclosed bundle-ID + ; APPDATA roots. Preserve the choice separately and suppress that deletion. + StrCpy $LsdjDeleteData $DeleteAppDataCheckboxState + StrCpy $DeleteAppDataCheckboxState 0 +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + ${If} $LsdjDeleteData = 1 + ${AndIf} $UpdateMode <> 1 + !ifdef LSDJ_CI_ADVERSARIAL_TESTS + ; Unsigned hosted-CI installers can pause after confirmation/core removal + ; so the test can deterministically replace the marker before this second + ; validation. This branch is absent from release installers. + ClearErrors + ${GetOptions} $CMDLINE "/LSDJ-CI-PAUSE-BEFORE-PURGE" $R8 + ${IfNot} ${Errors} + FileOpen $R7 "$TEMP\lsdj-ci-before-purge.ready" w + FileWrite $R7 "ready" + FileClose $R7 + Sleep 5000 + Delete "$TEMP\lsdj-ci-before-purge.ready" + ${EndIf} + !endif + + ; Revalidate canonical path, root, marker, and every tree entry immediately + ; before deletion. The deletion walk performs the same checks again. + Call un.LsdjOwnedDataRootIsSafe + StrCpy $LsdjTreeSafe 1 + ${If} $LsdjOwnedRootSafe = 1 + Push "${LSDJ_DATA_ROOT}" + Call un.LsdjTreeIsLinkFree + ${EndIf} + ${If} $LsdjOwnedRootSafe = 1 + ${AndIf} $LsdjTreeSafe = 1 + StrCpy $LsdjDeleteFailure 0 + Push "${LSDJ_DATA_ROOT}" + Call un.LsdjDeleteTreeWithoutLinks + ${If} $LsdjDeleteFailure = 0 + ; Match Tauri's explicit-data-removal registry cleanup without invoking + ; its generic recursive directory deletion. + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + ${Else} + DetailPrint "LSDJ data removal stopped because a reparse point or filesystem error was observed" + StrCpy $LsdjDataRemovalFailed 1 + ${EndIf} + ${Else} + DetailPrint "LSDJ data was preserved because exact ownership or reparse-safety validation changed" + StrCpy $LsdjDataRemovalFailed 1 + ${EndIf} + ${EndIf} + ${If} $LsdjDataRemovalFailed = 1 + SetErrorLevel 2 + ${EndIf} +!macroend