From c05458bb9360922c1854d8a1ae53c1e967eb8193 Mon Sep 17 00:00:00 2001 From: Luxian Date: Sat, 8 Aug 2026 20:16:34 +0800 Subject: [PATCH] feat(installer): add Windows lba shim (v0.19.64) Signed-off-by: Luxian --- .github/workflows/release.yml | 3 + tests/INDEX.md | 2 +- tests/compat/README.md | 2 +- tests/compat/install_alias_test.rs | 32 ++++- tests/compat/install_alias_windows.ps1 | 170 +++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 tests/compat/install_alias_windows.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 947fb4b9a..ce43f9e0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,6 +123,9 @@ jobs: New-Item -ItemType Directory -Force -Path "build" | Out-Null Copy-Item "target\${{ matrix.target.rust }}\release\${{ matrix.component }}.exe" "build\$binaryName.exe" + (Get-FileHash "build\$binaryName.exe" -Algorithm SHA256).Hash.ToLowerInvariant() | + Set-Content "build\$binaryName.exe.sha256" -NoNewline + Write-Output "Built binary: $binaryName" - name: Upload to Cloudflare R2 (Unix) diff --git a/tests/INDEX.md b/tests/INDEX.md index a9d0e59fb..477af97f5 100644 --- a/tests/INDEX.md +++ b/tests/INDEX.md @@ -23,7 +23,7 @@ | `compat_worktree_delete_dir` | 1 | Guards worktree delete semantics on dir removal | `src/command/worktree.rs` | | `compat_checkout_alias_help` | 1 | Guards `--help` text for checkout aliases | `src/command/checkout.rs` | | `compat_matrix_alignment` | 1 | Guards public docs/release matrices vs. real CLI/API surfaces, including P2-04's explicit non-sending `send-email` policy | `COMPATIBILITY.md`, `docs/commands/code.md`, `docs/commands/send-email.md`, `.github/workflows/base.yml`, `src/cli.rs`, `src/internal/ai/web/mod.rs` | -| `compat_install_alias` | 1 | Guards IX-01 full-installer `lba -> libra` creation, same-version repair/idempotency, CLI/env opt-outs, foreign-path preservation, and symlink-unavailable fallback with an isolated fake downloader | `install.sh`, `tests/compat/install_alias_smoke.sh`, `README.md`, `README.zh-CN.md` | +| `compat_install_alias` | 1 | Guards IX-01 Unix `lba -> libra` and plan-20260714 PD-10 Windows `lba.cmd` resolution, CLI/env opt-outs, and foreign-path preservation | `install.sh`, `install.ps1`, `tests/compat/install_alias_smoke.sh`, `tests/compat/install_alias_windows.ps1` | | `compat_live_compat_workflow` | 1 | Guards optional live AI/cloud workflow remains manual/scheduled and secret-gated | `.github/workflows/live-compat.yml` | | `compat_branch_lossy_wrapper_guard` | 1 | Guards branch-name lossy conversion wrapper | `src/internal/branch.rs` | | `compat_lfs_client_production_unwrap_guard` | 1 | Bans `unwrap()/expect()` in `internal/protocol/lfs_client.rs` | `src/internal/protocol/lfs_client.rs` | diff --git a/tests/compat/README.md b/tests/compat/README.md index 61a560e98..844f5e567 100644 --- a/tests/compat/README.md +++ b/tests/compat/README.md @@ -33,7 +33,7 @@ top-level `[[test]]` entries in `Cargo.toml`. | `worktree_delete_dir.rs` | C5 | `worktree remove` with and without `--delete-dir`; dirty-worktree refusal | | `checkout_alias_help.rs` | C5 | top-level `--help` includes `checkout`; the help banner mentions `switch` / `restore` | | `matrix_alignment.rs` | C2 / Web Phase E / P2-04 | `COMPATIBILITY.md` ↔ `src/cli.rs::Commands` enum drift detection; explicit no-CLI/no-SMTP `send-email` policy with EN/zh/dev docs; `docs/commands/code.md` docs script coverage for every `/api/code/*` router endpoint; Web CI checks `web/out` drift after static export | -| `install_alias_test.rs` + `install_alias_smoke.sh` | IX-01 (Issue #437) | 隔离 HOME + 假 downloader 驱动完整 installer:默认相对 `lba -> libra`、same-version 缺失修复/幂等、CLI/env opt-out、regular/foreign-symlink 不覆盖、无 symlink 能力时告警但安装成功 | +| `install_alias_test.rs` + `install_alias_smoke.sh` + `install_alias_windows.ps1` | IX-01 / plan-20260714 PD-10 | Unix symlink 与 Windows `lba.cmd` 安装器:Windows 端到端覆盖命令解析、CLI/env opt-out 与用户文件保护;Windows 通过 PowerShell 分支执行,非 Windows gate-skip | | `live_compat_workflow.rs` | C2 | optional `compat-live-ai` / `compat-live-cloud` workflow stays manual/scheduled, secret-gated, and outside `base.yml` | | `branch_lossy_wrapper_guard.rs` | branch follow-up | `src/` production code must use branch `*_result` APIs instead of lossy compatibility wrappers | | `lfs_client_production_unwrap_guard.rs` | unwrap audit (v0.17.260) | `src/internal/protocol/lfs_client.rs` must not regress on bare `.unwrap()` | diff --git a/tests/compat/install_alias_test.rs b/tests/compat/install_alias_test.rs index c914a844c..94a11a85c 100644 --- a/tests/compat/install_alias_test.rs +++ b/tests/compat/install_alias_test.rs @@ -30,10 +30,36 @@ fn installer_creates_and_repairs_safe_optional_lba_alias() { ); } -#[cfg(not(unix))] +#[cfg(windows)] #[test] -fn installer_alias_smoke_is_unix_only() { - eprintln!("skipped: install.sh supports Linux and macOS"); +fn installer_alias_smoke_windows() { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let script = repo.join("tests/compat/install_alias_windows.ps1"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .arg("-RepoRoot") + .arg(&repo) + .output() + .expect("run Windows install alias smoke script"); + + assert!( + output.status.success(), + "Windows install alias smoke failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("install alias Windows smoke: ok"), + "Windows smoke script did not print its completion marker\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[cfg(all(not(unix), not(windows)))] +#[test] +fn installer_alias_smoke_is_unsupported_platform() { + eprintln!("skipped: installer aliases are covered on Unix and Windows"); } /// plan-20260714 PD-10: the Windows installer ships the same optional `lba` diff --git a/tests/compat/install_alias_windows.ps1 b/tests/compat/install_alias_windows.ps1 new file mode 100644 index 000000000..9b54ebfad --- /dev/null +++ b/tests/compat/install_alias_windows.ps1 @@ -0,0 +1,170 @@ +param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw "install alias Windows smoke: $Message" +} + +function Get-FreePort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } + finally { + $listener.Stop() + } +} + +function Start-ReleaseServer([string]$BinaryPath) { + $port = Get-FreePort + $server = Start-Job -ArgumentList $port, $BinaryPath -ScriptBlock { + param($Port, $Source) + + $listener = [Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$Port/") + $listener.Start() + try { + # The scenario performs five installs. Exiting after the fifth + # response keeps cleanup from blocking on a pending GetContext(). + $requests = 0 + while ($requests -lt 5) { + $context = $listener.GetContext() + $requests++ + try { + if ($context.Request.Url.AbsolutePath -match '^/libra/releases/v[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*/libra-windows-amd64\.exe$') { + $context.Response.StatusCode = 200 + $context.Response.ContentType = "application/octet-stream" + $context.Response.ContentLength64 = (Get-Item -LiteralPath $Source).Length + $input = [IO.File]::OpenRead($Source) + try { + $input.CopyTo($context.Response.OutputStream) + } + finally { + $input.Dispose() + } + } + else { + $context.Response.StatusCode = 404 + } + } + finally { + $context.Response.Close() + } + } + } + finally { + $listener.Stop() + $listener.Close() + } + } + Start-Sleep -Milliseconds 250 + if ($server.State -ne "Running") { + Receive-Job -Job $server | Out-String | ForEach-Object { Fail "release server did not start: $_" } + } + return @{ Job = $server; BaseUrl = "http://127.0.0.1:$port" } +} + +$work = Join-Path ([IO.Path]::GetTempPath()) "libra-install-alias-$([guid]::NewGuid().ToString('N'))" +$previousLocalAppData = $env:LOCALAPPDATA +$server = $null + +try { + $installer = Join-Path $RepoRoot "install.ps1" + $source = Join-Path $env:SystemRoot "System32\cmd.exe" + if (-not (Test-Path -LiteralPath $installer)) { + Fail "missing installer: $installer" + } + if (-not (Test-Path -LiteralPath $source)) { + Fail "missing Windows test executable: $source" + } + + # The installer only requires a PE payload. cmd.exe keeps the local + # release server small while still exercising the generated lba.cmd. + $sourceVersion = "0.0.0" + $server = Start-ReleaseServer $source + + function Invoke-Installer([string]$InstallDirectory, [string]$AppDataDirectory, [string[]]$ExtraArguments) { + $env:LOCALAPPDATA = $AppDataDirectory + $arguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $installer, + "-Version", $sourceVersion, + "-InstallDir", $InstallDirectory, + "-DownloadBaseUrl", $server.BaseUrl, + "-NoModifyPath" + ) + $ExtraArguments + & powershell.exe @arguments + if ($LASTEXITCODE -ne 0) { + Fail "installer failed for $InstallDirectory" + } + } + + $installDir = Join-Path $work "managed" + $appDataDir = Join-Path $work "managed-appdata" + Invoke-Installer $installDir $appDataDir @() + $shim = Join-Path $appDataDir "Microsoft\WindowsApps\lba.cmd" + $libraVersion = (& (Join-Path $installDir "libra.exe") --version | Out-String).Trim() + $lbaVersion = (& $shim --version | Out-String).Trim() + if ($libraVersion -ne $lbaVersion) { + Fail "lba --version differs from libra --version" + } + + $foreignCmdDir = Join-Path $work "foreign-cmd" + $foreignCmdAppData = Join-Path $work "foreign-cmd-appdata" + $foreignCmdShim = Join-Path $foreignCmdAppData "Microsoft\WindowsApps\lba.cmd" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $foreignCmdShim) | Out-Null + $foreignCmdContent = "@echo foreign`r`n" + [IO.File]::WriteAllText($foreignCmdShim, $foreignCmdContent) + Invoke-Installer $foreignCmdDir $foreignCmdAppData @() + if ([IO.File]::ReadAllText($foreignCmdShim) -ne $foreignCmdContent) { + Fail "foreign lba.cmd was overwritten" + } + + $foreignPs1Dir = Join-Path $work "foreign-ps1" + $foreignPs1AppData = Join-Path $work "foreign-ps1-appdata" + $foreignPs1Shim = Join-Path $foreignPs1AppData "Microsoft\WindowsApps\lba.ps1" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $foreignPs1Shim) | Out-Null + $foreignPs1Content = "Write-Output 'foreign'`r`n" + [IO.File]::WriteAllText($foreignPs1Shim, $foreignPs1Content) + Invoke-Installer $foreignPs1Dir $foreignPs1AppData @() + if ([IO.File]::ReadAllText($foreignPs1Shim) -ne $foreignPs1Content) { + Fail "foreign lba.ps1 was overwritten" + } + + $noAliasDir = Join-Path $work "no-alias" + $noAliasAppData = Join-Path $work "no-alias-appdata" + Invoke-Installer $noAliasDir $noAliasAppData @("-NoAlias") + if (Test-Path -LiteralPath (Join-Path $noAliasAppData "Microsoft\WindowsApps\lba.cmd")) { + Fail "-NoAlias created lba.cmd" + } + + $envAliasDir = Join-Path $work "env-no-alias" + $envAliasAppData = Join-Path $work "env-no-alias-appdata" + $previousNoAlias = $env:LIBRA_NO_ALIAS + try { + $env:LIBRA_NO_ALIAS = "1" + Invoke-Installer $envAliasDir $envAliasAppData @() + } + finally { + $env:LIBRA_NO_ALIAS = $previousNoAlias + } + if (Test-Path -LiteralPath (Join-Path $envAliasAppData "Microsoft\WindowsApps\lba.cmd")) { + Fail "LIBRA_NO_ALIAS=1 created lba.cmd" + } + + Write-Output "install alias Windows smoke: ok" +} +finally { + $env:LOCALAPPDATA = $previousLocalAppData + if ($null -ne $server) { + Stop-Job -Job $server.Job -ErrorAction SilentlyContinue + Remove-Job -Job $server.Job -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $work) { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } +}