Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 50 additions & 4 deletions scripts/windows_install_conpty_package_test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 107 additions & 13 deletions src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

use std::collections::BTreeMap;
use std::env;
#[cfg(not(windows))]
use std::fs;
#[cfg(not(windows))]
use std::io;
Expand Down Expand Up @@ -125,6 +124,8 @@ impl UpdateChannel {
struct AssetRef {
url: String,
sha256: Option<String>,
#[cfg(windows)]
format: Option<String>,
}

impl<'de> Deserialize<'de> for AssetRef {
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -275,6 +308,8 @@ struct ReleaseInfo {
target_protocol: Option<u32>,
download_url: String,
sha256: Option<String>,
#[cfg(windows)]
package_format: String,
notes_body: String,
}

Expand Down Expand Up @@ -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,
}))
}
Expand Down Expand Up @@ -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,
}))
}
Expand Down Expand Up @@ -636,30 +675,82 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> 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<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}"))?;
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 44 additions & 11 deletions website/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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") {
Expand Down
Loading