diff --git a/Cargo.toml b/Cargo.toml index d1af396426..ffb734f29c 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 0da92989d3..4b0e73f38a 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 abc9663fbc..161386f920 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, + #[cfg(windows)] + format: Option, } 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 { + 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, download_url: String, sha256: Option, + #[cfg(windows)] + package_format: String, notes_body: String, } @@ -382,6 +417,8 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result Result<(), String> Ok(()) } +#[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 { + 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 { 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 d6367312e4..55b3b381bc 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") {