aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-08-14 21:48:59 +0200
committerGitHub <noreply@github.com>2026-08-14 22:48:59 +0300
commitcb727d055545b5b2fc366f778e4c57874d0aa8c1 (patch)
tree112a6b2ca98c807eeb376c76764e34fb2acef5c9
parentd76657f2c7fc18dcce3b9af43842c8afaba1646b (diff)
fix: use verified local packages for windows updates (#2816)
refs #2751
-rw-r--r--Cargo.toml1
-rw-r--r--scripts/windows_install_conpty_package_test.ps154
-rw-r--r--src/update.rs120
-rw-r--r--website/install.ps155
4 files changed, 202 insertions, 28 deletions
diff --git a/Cargo.toml b/Cargo.toml
index d1af3964..ffb734f2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ include = [
"assets/sounds/*",
"docs/next/api/herdr-api.schema.json",
"skills/herdr/SKILL.md",
+ "website/install.ps1",
"README.md",
"LICENSE",
"Cargo.toml",
diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1
index 0da92989..4b0e73f3 100644
--- a/scripts/windows_install_conpty_package_test.ps1
+++ b/scripts/windows_install_conpty_package_test.ps1
@@ -113,10 +113,56 @@ try {
}
}
- & "$PSScriptRoot\..\website\install.ps1" `
- -ManifestUrl $manifestUrl `
- -InstallDir $installDir `
- -ExpectedBuildId "installer-test"
+ # Keep the existing positional web-installer contract, including Retain in slot five.
+ & "$PSScriptRoot\..\website\install.ps1" "preview" $manifestUrl $installDir "installer-test" 3
+
+ $localInstallDir = Join-Path $root "local-bin"
+ $env:HERDR_HOME = Join-Path $root "local-home"
+ $partialLocalModeRejected = $false
+ try {
+ & $installerPath `
+ -InstallDir $localInstallDir `
+ -LocalPackagePath $archive
+ } catch {
+ if ($_.Exception.Message -notlike "Local package mode requires*") {
+ throw
+ }
+ $partialLocalModeRejected = $true
+ }
+ if (-not $partialLocalModeRejected) {
+ throw "installer accepted partial local-package inputs"
+ }
+
+ $badLocalChecksumRejected = $false
+ try {
+ & $installerPath `
+ -ManifestUrl "$manifestUrl/unused" `
+ -InstallDir $localInstallDir `
+ -LocalPackagePath $archive `
+ -LocalPackageFormat "zip" `
+ -LocalPackageIdentity "0.0.0-preview.local-package" `
+ -LocalPackageSha256 ("0" * 64)
+ } catch {
+ if ($_.Exception.Message -notlike "Downloaded Herdr checksum did not match.*") {
+ throw
+ }
+ $badLocalChecksumRejected = $true
+ }
+ if (-not $badLocalChecksumRejected) {
+ throw "installer accepted a local package with the wrong checksum"
+ }
+
+ & $installerPath `
+ -ManifestUrl "$manifestUrl/unused" `
+ -InstallDir $localInstallDir `
+ -LocalPackagePath $archive `
+ -LocalPackageFormat "zip" `
+ -LocalPackageIdentity "0.0.0-preview.local-package" `
+ -LocalPackageSha256 $hash
+ if (-not (Test-Path -LiteralPath (Join-Path $localInstallDir "herdr.exe") -PathType Leaf)) {
+ throw "installer did not activate the verified local package"
+ }
+ $env:HERDR_HOME = $herdrHome
$required = @(
"herdr.exe",
diff --git a/src/update.rs b/src/update.rs
index abc9663f..161386f9 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -8,7 +8,6 @@
use std::collections::BTreeMap;
use std::env;
-#[cfg(not(windows))]
use std::fs;
#[cfg(not(windows))]
use std::io;
@@ -125,6 +124,8 @@ impl UpdateChannel {
struct AssetRef {
url: String,
sha256: Option<String>,
+ #[cfg(windows)]
+ format: Option<String>,
}
impl<'de> Deserialize<'de> for AssetRef {
@@ -137,6 +138,8 @@ impl<'de> Deserialize<'de> for AssetRef {
serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self {
url: url.trim().to_string(),
sha256: None,
+ #[cfg(windows)]
+ format: None,
}),
serde_json::Value::Object(mut object) => {
let url = object
@@ -146,12 +149,18 @@ impl<'de> Deserialize<'de> for AssetRef {
let sha256 = object
.remove("sha256")
.and_then(|value| value.as_str().map(str::to_string));
+ #[cfg(windows)]
+ let format = object
+ .remove("format")
+ .and_then(|value| value.as_str().map(str::to_string));
if url.trim().is_empty() {
return Err(serde::de::Error::custom("asset url must not be empty"));
}
Ok(Self {
url: url.trim().to_string(),
sha256: sha256.filter(|value| !value.trim().is_empty()),
+ #[cfg(windows)]
+ format: format.filter(|value| !value.trim().is_empty()),
})
}
_ => Err(serde::de::Error::custom(
@@ -161,6 +170,30 @@ impl<'de> Deserialize<'de> for AssetRef {
}
}
+#[cfg(windows)]
+impl AssetRef {
+ fn package_format(&self) -> Result<String, String> {
+ let format = self
+ .format
+ .clone()
+ .unwrap_or_else(|| {
+ if self.url.to_ascii_lowercase().ends_with(".zip") {
+ "zip"
+ } else {
+ "exe"
+ }
+ .into()
+ })
+ .to_ascii_lowercase();
+ match format.as_str() {
+ "zip" | "exe" => Ok(format),
+ _ => Err(format!(
+ "update manifest asset has unsupported format '{format}'"
+ )),
+ }
+ }
+}
+
#[derive(Deserialize)]
struct UpdateManifest {
version: String,
@@ -275,6 +308,8 @@ struct ReleaseInfo {
target_protocol: Option<u32>,
download_url: String,
sha256: Option<String>,
+ #[cfg(windows)]
+ package_format: String,
notes_body: String,
}
@@ -382,6 +417,8 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
target_protocol: manifest.protocol,
download_url,
sha256: Some(sha256),
+ #[cfg(windows)]
+ package_format: asset.package_format()?,
notes_body,
}))
}
@@ -467,6 +504,8 @@ fn release_info_from_preview_manifest(
target_protocol: Some(manifest.protocol),
download_url,
sha256: asset.sha256.clone(),
+ #[cfg(windows)]
+ package_format: asset.package_format()?,
notes_body,
}))
}
@@ -637,29 +676,81 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String>
}
#[cfg(windows)]
+const WINDOWS_INSTALLER: &str = include_str!("../website/install.ps1");
+
+#[cfg(windows)]
+struct DownloadedWindowsUpdate {
+ package_path: PathBuf,
+ installer_path: PathBuf,
+}
+
+#[cfg(windows)]
+impl Drop for DownloadedWindowsUpdate {
+ fn drop(&mut self) {
+ let _ = fs::remove_file(&self.package_path);
+ let _ = fs::remove_file(&self.installer_path);
+ }
+}
+
+#[cfg(windows)]
+fn download_windows_update(release: &ReleaseInfo) -> Result<DownloadedWindowsUpdate, String> {
+ let expected_sha256 = release
+ .sha256
+ .as_deref()
+ .ok_or("Windows update asset is missing a SHA-256 checksum")?;
+ let stem = format!("herdr-update-{}", std::process::id());
+ let update = DownloadedWindowsUpdate {
+ package_path: env::temp_dir().join(format!("{stem}.{}", release.package_format)),
+ installer_path: env::temp_dir().join(format!("{stem}.ps1")),
+ };
+ fs::write(&update.installer_path, WINDOWS_INSTALLER)
+ .map_err(|err| format!("failed to prepare Windows installer: {err}"))?;
+
+ let status = crate::noninteractive_process::curl_command()
+ .args(["-sfL", "--max-time", "120", "-o"])
+ .arg(&update.package_path)
+ .arg(&release.download_url)
+ .status()
+ .map_err(|err| format!("download failed: {err}"))?;
+ if !status.success() {
+ return Err("download failed".into());
+ }
+ crate::checksum::verify_sha256(&update.package_path, expected_sha256)
+ .map_err(|err| format!("downloaded update checksum verification failed: {err}"))?;
+ tracing::info!(sha256 = %expected_sha256, "downloaded update checksum verified");
+
+ Ok(update)
+}
+
+#[cfg(windows)]
fn install_windows_update_with_installer(
- channel: UpdateChannel,
- expected_build_id: Option<&str>,
+ release: &ReleaseInfo,
+ update: &DownloadedWindowsUpdate,
) -> Result<(), String> {
+ let expected_sha256 = release
+ .sha256
+ .as_deref()
+ .ok_or("Windows update asset is missing a SHA-256 checksum")?;
let mut command = Command::new("powershell");
command
+ .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
+ .arg(&update.installer_path)
+ .args(["-Channel", release.channel.as_str(), "-LocalPackagePath"])
+ .arg(&update.package_path)
.args([
- "-NoProfile",
- "-ExecutionPolicy",
- "Bypass",
- "-Command",
- "irm https://herdr.dev/install.ps1 | iex",
+ "-LocalPackageFormat",
+ &release.package_format,
+ "-LocalPackageIdentity",
+ release.label(),
+ "-LocalPackageSha256",
+ expected_sha256,
])
- .env("HERDR_CHANNEL", channel.as_str())
// Drop any inherited PSModulePath. When herdr is launched from
// PowerShell 7, its Core module paths come first and Windows
// PowerShell 5.1 (this `powershell`) fails to autoload cmdlets like
// Get-FileHash. Removing it lets 5.1 compute its own default path.
// See PowerShell/PowerShell#8635.
.env_remove("PSModulePath");
- if let Some(build_id) = expected_build_id {
- command.env("HERDR_EXPECTED_BUILD_ID", build_id);
- }
let status = command
.status()
.map_err(|err| format!("failed to run Windows installer: {err}"))?;
@@ -2041,7 +2132,10 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
if let Some(sha256) = &release.sha256 {
tracing::debug!(sha256 = %sha256, "selected Windows update asset has checksum");
}
- install_windows_update_with_installer(channel, release.build_id.as_deref())?;
+ eprintln!("downloading {}...", release.label());
+ let downloaded_update = download_windows_update(&release)?;
+ eprintln!("downloaded {}", release.label());
+ install_windows_update_with_installer(&release, &downloaded_update)?;
let updated_exe = windows_installed_herdr_exe_path()?;
eprintln!("installed {}", release.label());
print_outdated_integration_notice_with_updated_binary(&updated_exe);
diff --git a/website/install.ps1 b/website/install.ps1
index d6367312..55b3b381 100644
--- a/website/install.ps1
+++ b/website/install.ps1
@@ -4,7 +4,11 @@ param(
[string]$ManifestUrl = $env:HERDR_MANIFEST_URL,
[string]$InstallDir = $env:HERDR_INSTALL_DIR,
[string]$ExpectedBuildId = $env:HERDR_EXPECTED_BUILD_ID,
- [int]$Retain = 3
+ [int]$Retain = 3,
+ [string]$LocalPackagePath,
+ [string]$LocalPackageFormat,
+ [string]$LocalPackageIdentity,
+ [string]$LocalPackageSha256
)
Set-StrictMode -Version Latest
@@ -20,6 +24,21 @@ if ($Channel -notin @("stable", "preview")) {
exit 1
}
+$localPackageValueCount = @(
+ $LocalPackagePath,
+ $LocalPackageFormat,
+ $LocalPackageIdentity,
+ $LocalPackageSha256 |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+).Count
+if ($localPackageValueCount -notin @(0, 4)) {
+ throw "Local package mode requires path, format, identity, and SHA-256."
+}
+$useLocalPackage = $localPackageValueCount -eq 4
+if ($useLocalPackage -and $LocalPackageFormat -notin @("zip", "exe")) {
+ throw "Local Herdr package has unsupported format '$LocalPackageFormat'."
+}
+
function Write-Step {
param([string]$Message)
Write-Host "==> $Message"
@@ -548,7 +567,7 @@ switch ($architecture) {
}
}
-if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
+if (-not $useLocalPackage -and [string]::IsNullOrWhiteSpace($ManifestUrl)) {
$ManifestUrl = if ($Channel -eq "preview") {
"https://herdr.dev/preview.json"
} else {
@@ -589,13 +608,21 @@ if (-not [string]::IsNullOrWhiteSpace($existingHerdr) -and -not (Test-PathStarts
Write-WarningStep "PATH order decides which Herdr runs. This installer will put $visibleBinDir first for future and current PowerShell sessions."
}
-Write-Step "Fetching Herdr $Channel manifest"
-$manifest = ConvertTo-ManifestObject -Manifest (Invoke-RestMethod -Uri $ManifestUrl)
-if (-not [string]::IsNullOrWhiteSpace($ExpectedBuildId) -and [string]$manifest.build_id -ne $ExpectedBuildId) {
- throw "Preview manifest changed while updating. Expected build $ExpectedBuildId but found $($manifest.build_id). Run herdr update again."
+if ($useLocalPackage) {
+ $versionIdentity = $LocalPackageIdentity
+ $asset = [PSCustomObject]@{
+ Sha256 = $LocalPackageSha256
+ Format = $LocalPackageFormat
+ }
+} else {
+ Write-Step "Fetching Herdr $Channel manifest"
+ $manifest = ConvertTo-ManifestObject -Manifest (Invoke-RestMethod -Uri $ManifestUrl)
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedBuildId) -and [string]$manifest.build_id -ne $ExpectedBuildId) {
+ throw "Preview manifest changed while updating. Expected build $ExpectedBuildId but found $($manifest.build_id). Run herdr update again."
+ }
+ $versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel
+ $asset = Get-ManifestAsset -Manifest $manifest -Target $target
}
-$versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel
-$asset = Get-ManifestAsset -Manifest $manifest -Target $target
$safeVersionIdentity = $versionIdentity -replace '[^0-9A-Za-z._-]', '-'
$releaseName = "$safeVersionIdentity-$targetTriple"
$releaseDir = Join-Path $releasesDir $releaseName
@@ -609,10 +636,16 @@ try {
Remove-StaleInstallArtifacts -ReleasesDir $releasesDir
if (-not (Test-HerdrReleaseComplete -ReleaseDir $releaseDir -Format $asset.Format)) {
- $downloadPath = Join-Path $tempDir "herdr-download.$($asset.Format)"
+ $downloadPath = if ($useLocalPackage) {
+ $LocalPackagePath
+ } else {
+ Join-Path $tempDir "herdr-download.$($asset.Format)"
+ }
$stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID"
- Write-Step "Downloading Herdr"
- Invoke-WebRequest -Uri $asset.Url -OutFile $downloadPath
+ if (-not $useLocalPackage) {
+ Write-Step "Downloading Herdr"
+ Invoke-WebRequest -Uri $asset.Url -OutFile $downloadPath
+ }
Test-FileDigest -Path $downloadPath -ExpectedDigest $asset.Sha256
if ($asset.Format -eq "zip") {