Skip to content

test(windows): cover hash argument quoting #10

test(windows): cover hash argument quoting

test(windows): cover hash argument quoting #10

name: Windows v0.4.3 upgrade synthetic E2E
on:
push:
branches:
- fix/v043-unified-upgrade
workflow_dispatch:
permissions:
contents: read
jobs:
e2e:
runs-on: windows-2022
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Install NSIS
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
choco install nsis --yes --no-progress
$roots = @(
(Join-Path ${env:ProgramFiles(x86)} "NSIS\makensis.exe"),
(Join-Path $env:ProgramFiles "NSIS\makensis.exe")
)
$makensis = $roots | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $makensis) { throw "makensis.exe not found" }
"MAKENSIS=$makensis" >> $env:GITHUB_ENV
- name: Build synthetic candidate CLI and inner installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
rustc -vV
if ($LASTEXITCODE -ne 0) { throw "rustc is unavailable" }
@'
use std::{env, fs, path::{Path, PathBuf}, process};
fn log(text: &str) {
let mut paths = vec![env::temp_dir().join("webcodex-stub.log")];
if let Ok(path) = env::var("STUB_LOG") { paths.push(path.into()); }
for path in paths {
let old = fs::read_to_string(&path).unwrap_or_default();
let _ = fs::write(path, format!("{old}candidate {text}\n"));
}
}
fn option(args: &[String], name: &str) -> Option<String> {
args.windows(2).find(|pair| pair[0] == name).map(|pair| pair[1].clone())
}
fn same(a: impl AsRef<Path>, b: impl AsRef<Path>) -> bool {
fs::read(a).ok().zip(fs::read(b).ok()).is_some_and(|(a, b)| a == b)
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
log(&format!("args={}", args.join(" ")));
if args.as_slice().starts_with(&["environment".into(), "--help".into()]) {
log("help=0");
return;
}
if args.len() >= 2 && args[0] == "environment" && args[1] == "installer-verify-same" {
let Some(runtime) = option(&args, "--expected-runtime-dir").map(PathBuf::from) else { process::exit(20) };
let Some(source) = env::var_os("STUB_CANDIDATE_BIN").map(PathBuf::from) else { process::exit(20) };
let Some(root) = runtime.parent() else { process::exit(20) };
let pairs = [
(source.join("webcodex.exe"), runtime.join("webcodex.exe")),
(source.join("webcodex-server.exe"), runtime.join("webcodex-server.exe")),
(source.join("webcodex-runner.exe"), runtime.join("webcodex-runner.exe")),
(source.join("webcodex-desktop.exe"), root.join("WebCodex.exe")),
];
for (expected, installed) in pairs {
if !same(&expected, &installed) {
log(&format!("verify=21 {}", installed.display()));
process::exit(21);
}
}
log("verify=0");
return;
}
log("other=0");
}
'@ | Set-Content -LiteralPath candidate.rs -Encoding utf8
@'
use std::{env, fs, path::PathBuf, process};
fn log(text: &str) {
let mut paths = vec![env::temp_dir().join("webcodex-stub.log")];
if let Ok(path) = env::var("STUB_LOG") { paths.push(path.into()); }
for path in paths {
let old = fs::read_to_string(&path).unwrap_or_default();
let _ = fs::write(path, format!("{old}inner {text}\n"));
}
}
fn main() {
log("start");
let Some(source) = env::var_os("STUB_CANDIDATE_BIN").map(PathBuf::from) else { process::exit(30) };
let Some(root) = env::var_os("STUB_INSTALL_ROOT").map(PathBuf::from) else { process::exit(31) };
let runtime = root.join("webcodex-runtime");
if fs::create_dir_all(&runtime).is_err() { process::exit(32) }
for (src, dst) in [
(source.join("webcodex.exe"), runtime.join("webcodex.exe")),
(source.join("webcodex-server.exe"), runtime.join("webcodex-server.exe")),
(source.join("webcodex-runner.exe"), runtime.join("webcodex-runner.exe")),
(source.join("webcodex-desktop.exe"), root.join("WebCodex.exe")),
] {
if fs::copy(src, dst).is_err() { process::exit(33) }
}
log("copies=done");
}
'@ | Set-Content -LiteralPath inner.rs -Encoding utf8
rustc -O candidate.rs -o candidate-stub.exe
if ($LASTEXITCODE -ne 0) { throw "candidate stub compile failed" }
rustc -O inner.rs -o inner-stub.exe
if ($LASTEXITCODE -ne 0) { throw "inner stub compile failed" }
- name: Build synthetic unified outer installer from production bootstrap
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
@'
import argparse
import hashlib
import json
import os
import shutil
import sys
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path("scripts/tests").resolve()))
import test_prepare_windows_unified_installer as tests
case = tests.WindowsUnifiedNsisTests(methodName="test_outer_bootstrap_extracts_candidate_in_section_before_inner_install")
case.setUp()
try:
root = Path("synthetic-candidate").resolve()
root.mkdir()
infos = case.fixture(root)
bin_dir = root / "artifacts" / "bin"
stub = Path("candidate-stub.exe").resolve()
for name in tests.nsis.BINARIES:
shutil.copyfile(stub, bin_dir / f"{name}.exe")
manifest_path = root / "source-manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
sums = {}
for name in tests.nsis.BINARIES:
path = bin_dir / f"{name}.exe"
rel = path.relative_to(root).as_posix()
digest = hashlib.sha256(path.read_bytes()).hexdigest()
manifest["artifacts"][name]["sha256"] = digest
sums[rel] = digest
manifest["desktop_payload"]["sha256"] = tests.nsis.collector.package.tree_digest(bin_dir / "webcodex-desktop.exe")
manifest["desktop_payload"]["managed_files"] = [
{"path": path, "sha256": manifest["artifacts"][name]["sha256"]}
for path, name in sorted(tests.nsis.MANAGED_INSTALL_FILES.items())
]
raw = json.dumps(manifest, indent=2, sort_keys=True).encode() + b"\n"
manifest_path.write_bytes(raw)
sums["source-manifest.json"] = hashlib.sha256(raw).hexdigest()
(root / "SHA256SUMS").write_text(
"".join(f"{digest} {name}\n" for name, digest in sorted(sums.items())),
encoding="ascii",
)
output = Path("synthetic-unified.exe").resolve()
with mock.patch.object(tests.nsis.collector, "probe", side_effect=lambda path, name: infos[name]):
tests.bootstrap.build(argparse.Namespace(
candidate_dir=root,
inner_installer=Path("inner-stub.exe").resolve(),
platform="win32-x64",
output=output,
makensis=os.environ["MAKENSIS"],
))
finally:
case.doCleanups()
'@ | python -
if ($LASTEXITCODE -ne 0) { throw "synthetic unified installer build failed" }
- name: Install official v0.4.3
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path legacy | Out-Null
$base = "https://github.com/yyjeqhc/webcodex/releases/download/v0.4.3"
$installer = Join-Path $PWD "legacy\webcodex-desktop-v0.4.3-win32-x64-setup.exe"
$sums = Join-Path $PWD "legacy\SHA256SUMS"
Invoke-WebRequest -UseBasicParsing "$base/webcodex-desktop-v0.4.3-win32-x64-setup.exe" -OutFile $installer
Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile $sums
$line = Get-Content -LiteralPath $sums | Where-Object { $_ -match 'webcodex-desktop-v0\.4\.3-win32-x64-setup\.exe$' } | Select-Object -First 1
$expected = ($line -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "official v0.4.3 checksum mismatch" }
$p = Start-Process -FilePath $installer -ArgumentList "/S" -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "official v0.4.3 installation failed" }
$key = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\WebCodex Desktop"
$root = (Get-ItemProperty -LiteralPath $key).InstallLocation.Trim('"')
Set-Content -LiteralPath (Join-Path $root "legacy-upgrade-sentinel.txt") -Value "preserve-me" -Encoding ascii
"LEGACY_INSTALL_ROOT=$root" >> $env:GITHUB_ENV
- name: Upgrade v0.4.3 through patched outer bootstrap
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:STUB_CANDIDATE_BIN = (Resolve-Path "synthetic-candidate\artifacts\bin").Path
$env:STUB_INSTALL_ROOT = $env:LEGACY_INSTALL_ROOT
$env:STUB_LOG = Join-Path $PWD "stub-invocations.log"
Remove-Item -LiteralPath (Join-Path $env:TEMP "webcodex-stub.log") -Force -ErrorAction SilentlyContinue
$p = Start-Process -FilePath (Resolve-Path "synthetic-unified.exe") -ArgumentList "/S" -PassThru
if (-not $p.WaitForExit(15000)) {
"===== process snapshot after 15s ====="
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'synthetic-unified|webcodex|powershell|inner-stub|candidate-stub' } |
Select-Object ProcessId,ParentProcessId,Name,CommandLine |
Format-List | Out-String | Write-Host
"===== stub log ====="
foreach ($path in @($env:STUB_LOG, (Join-Path $env:TEMP "webcodex-stub.log"))) {
if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path }
}
}
if (-not $p.WaitForExit(45000)) {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'synthetic-unified|webcodex|powershell|inner-stub|candidate-stub' } |
Select-Object ProcessId,ParentProcessId,Name,CommandLine |
Format-List | Out-String | Write-Host
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
foreach ($path in @($env:STUB_LOG, (Join-Path $env:TEMP "webcodex-stub.log"))) {
if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path }
}
throw "patched synthetic unified upgrade did not terminate within 60 seconds"
}
if ($p.ExitCode -ne 0) {
if (Test-Path -LiteralPath $env:STUB_LOG) { Get-Content -LiteralPath $env:STUB_LOG }
throw "patched synthetic unified upgrade failed with exit code $($p.ExitCode)"
}
$root = $env:LEGACY_INSTALL_ROOT
$runtime = Join-Path $root "webcodex-runtime"
$pairs = @(
@((Join-Path $env:STUB_CANDIDATE_BIN "webcodex.exe"), (Join-Path $runtime "webcodex.exe")),
@((Join-Path $env:STUB_CANDIDATE_BIN "webcodex-server.exe"), (Join-Path $runtime "webcodex-server.exe")),
@((Join-Path $env:STUB_CANDIDATE_BIN "webcodex-runner.exe"), (Join-Path $runtime "webcodex-runner.exe")),
@((Join-Path $env:STUB_CANDIDATE_BIN "webcodex-desktop.exe"), (Join-Path $root "WebCodex.exe"))
)
foreach ($pair in $pairs) {
$want = (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash
$got = (Get-FileHash -LiteralPath $pair[1] -Algorithm SHA256).Hash
if ($want -ne $got) { throw "installed managed executable does not match synthetic candidate: $($pair[1])" }
}
if ((Get-Content -LiteralPath (Join-Path $root "legacy-upgrade-sentinel.txt") -Raw).Trim() -ne "preserve-me") {
throw "legacy non-managed sentinel was not preserved"
}
$environmentRecord = Join-Path $env:APPDATA "webcodex\environment\environment.json"
if (Test-Path -LiteralPath $environmentRecord) {
throw "package-only legacy upgrade unexpectedly fabricated an EnvironmentStore"
}
[ordered]@{
official_v043_installed = $true
patched_outer_exit = $p.ExitCode
all_four_managed_executables_replaced = $true
unmanaged_sentinel_preserved = $true
environment_store_fabricated = $false
} | ConvertTo-Json | Set-Content -LiteralPath synthetic-e2e-result.json -Encoding utf8
- name: Upload E2E evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-v043-upgrade-synthetic-e2e
path: |
synthetic-unified.nsi
synthetic-unified.exe
synthetic-e2e-result.json
stub-invocations.log
if-no-files-found: warn
retention-days: 14