use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTimeError};
use anyhow::{Context, Result};
use axoupdater::{
AxoUpdater, AxoupdateError, ReleaseSource, ReleaseSourceType, UpdateRequest,
app_name_to_env_var,
};
use owo_colors::OwoColorize;
use serde::Deserialize;
use tempfile::TempDir;
use thiserror::Error;
use tokio::process::Command;
use tracing::{debug, warn};
use url::Url;
use uv_bin_install::{Binary, find_matching_version};
use uv_client::{BaseClientBuilder, RetriableError, WrappedReqwestError, fetch_with_url_fallback};
use uv_fs::Simplified;
use uv_pep440::{Version as Pep440Version, VersionSpecifier, VersionSpecifiers};
use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars;
use crate::commands::ExitStatus;
use crate::printer::Printer;
const UV_GITHUB_RELEASES_DOWNLOAD_PREFIX: &str =
"https://github.com/astral-sh/uv/releases/download/";
const UV_MIRROR_RELEASES_DOWNLOAD_PREFIX: &str =
"https://releases.astral.sh/github/uv/releases/download/";
const AXOUPDATER_CONFIG_PATH: &str = "AXOUPDATER_CONFIG_PATH";
const AXOUPDATER_CONFIG_WORKING_DIR: &str = "AXOUPDATER_CONFIG_WORKING_DIR";
pub(crate) async fn self_update(
version: Option<String>,
token: Option<String>,
dry_run: bool,
printer: Printer,
client_builder: BaseClientBuilder<'_>,
) -> Result<ExitStatus> {
if client_builder.is_offline() {
writeln!(
printer.stderr_important(),
"{}",
format_args!(
"{}{} Self-update is not possible because network connectivity is disabled (i.e., with `--offline`)",
"error".red().bold(),
":".bold()
)
)?;
return Ok(ExitStatus::Failure);
}
let mut updater = AxoUpdater::new_for("uv");
let updater_client = client_builder.build()?;
updater
.set_client(updater_client.raw_client().clone())
.disable_installer_output();
if let Some(ref token) = token {
updater.set_github_token(token);
}
let Ok(updater) = updater.load_receipt() else {
debug!("No receipt found; assuming uv was installed via a package manager");
writeln!(
printer.stderr_important(),
"{}",
format_args!(
concat!(
"{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.",
"\n",
"\n",
"If you installed uv with pip, brew, or another package manager, update uv with `pip install --upgrade`, `brew upgrade`, or similar."
),
"error".red().bold(),
":".bold()
)
)?;
return Ok(ExitStatus::Error);
};
if let Ok(version) = env!("CARGO_PKG_VERSION").parse() {
let _ = updater.set_current_version(version);
}
if !updater.check_receipt_is_for_this_executable()? {
let current_exe = std::env::current_exe()?;
let receipt_prefix = updater.install_prefix_root()?;
writeln!(
printer.stderr_important(),
"{}",
format_args!(
concat!(
"{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.",
"\n",
"\n",
"The current executable is at `{}` but the standalone installer was used to install uv to `{}`. Are multiple copies of uv installed?"
),
"error".red().bold(),
":".bold(),
current_exe.simplified_display().bold().cyan(),
receipt_prefix.simplified_display().bold().cyan()
)
)?;
return Ok(ExitStatus::Error);
}
writeln!(
printer.stderr(),
"{}",
format_args!(
"{}{} Checking for updates...",
"info".cyan().bold(),
":".bold()
)
)?;
if is_official_public_uv_install(updater.source.as_ref()) {
debug!("Using official public self-update path");
let retry_policy = client_builder.retry_policy();
let client = client_builder.clone().retries(0).build()?;
let constraints = official_target_version_specifiers(version.as_deref())?;
let resolved = find_matching_version(
Binary::Uv,
constraints.as_ref(),
None,
&client,
&retry_policy,
)
.await
.with_context(|| match version.as_deref() {
Some(version) => format!("Failed to resolve uv version `{version}`"),
None => "Failed to resolve the latest uv version".to_string(),
})?;
debug!("Resolved self-update target to `uv=={}`", resolved.version);
let current_version = Pep440Version::from_str(env!("CARGO_PKG_VERSION"))
.context("Failed to parse the current uv version")?;
if !is_update_needed(¤t_version, &resolved.version, version.is_some()) {
writeln!(
printer.stderr(),
"{}",
format_args!(
"{}{} You're already on version {} of uv{}.",
"success".green().bold(),
":".bold(),
format!("v{}", env!("CARGO_PKG_VERSION")).bold().cyan(),
if version.is_none() {
" (the latest version)".to_string()
} else {
String::new()
}
)
)?;
return Ok(ExitStatus::Success);
}
if dry_run {
writeln!(
printer.stderr_important(),
"Would update uv from {} to {}",
format!("v{}", env!("CARGO_PKG_VERSION")).bold().white(),
format!("v{}", resolved.version).bold().white(),
)?;
return Ok(ExitStatus::Success);
}
return run_official_updater(
updater,
¤t_version,
&resolved.version,
printer,
client_builder,
token.as_deref(),
)
.await;
}
debug!("Using custom self-update path");
let update_request = if let Some(version) = version {
UpdateRequest::SpecificTag(version)
} else {
UpdateRequest::Latest
};
updater.configure_version_specifier(update_request.clone());
if dry_run {
if updater.is_update_needed().await? {
let version = match update_request {
UpdateRequest::Latest | UpdateRequest::LatestMaybePrerelease => {
"the latest version".to_string()
}
UpdateRequest::SpecificTag(version) | UpdateRequest::SpecificVersion(version) => {
format!("v{version}")
}
};
writeln!(
printer.stderr_important(),
"Would update uv from {} to {}",
format!("v{}", env!("CARGO_PKG_VERSION")).bold().white(),
version.bold().white(),
)?;
} else {
writeln!(
printer.stderr(),
"{}",
format_args!(
"You're on the latest version of uv ({})",
format!("v{}", env!("CARGO_PKG_VERSION")).bold().white()
)
)?;
}
return Ok(ExitStatus::Success);
}
run_custom_updater(updater, printer, token.is_some()).await
}
fn is_official_public_uv_install(source: Option<&ReleaseSource>) -> bool {
is_official_public_uv_install_with_overrides(
source,
std::env::var_os(EnvVars::UV_INSTALLER_GITHUB_BASE_URL).is_some(),
std::env::var_os(EnvVars::UV_INSTALLER_GHE_BASE_URL).is_some(),
)
}
fn is_official_public_uv_install_with_overrides(
source: Option<&ReleaseSource>,
has_github_base_url_override: bool,
has_ghe_base_url_override: bool,
) -> bool {
if has_github_base_url_override || has_ghe_base_url_override {
return false;
}
matches!(
source,
Some(ReleaseSource {
release_type: ReleaseSourceType::GitHub,
owner,
name,
app_name,
}) if owner == "astral-sh" && name == "uv" && app_name == "uv"
)
}
fn official_target_version_specifiers(
target_version: Option<&str>,
) -> Result<Option<VersionSpecifiers>> {
let Some(target_version) = target_version else {
return Ok(None);
};
let pep440_version = Pep440Version::from_str(target_version)
.with_context(|| format!("Failed to parse version specifier `{target_version}`"))?;
if pep440_version.to_string() != target_version || pep440_version.release().len() < 3 {
warn!(
"Rejecting explicit self-update version specifier `{target_version}` after parsing it as `{pep440_version}`"
);
anyhow::bail!(
"Failed to parse version specifier `{target_version}`: explicit versions must include an exact major.minor.patch release"
);
}
Ok(Some(VersionSpecifiers::from(
VersionSpecifier::equals_version(pep440_version),
)))
}
fn is_update_needed(
current_version: &Pep440Version,
target_version: &Pep440Version,
has_target_version: bool,
) -> bool {
if has_target_version {
current_version != target_version
} else {
current_version < target_version
}
}
async fn run_official_updater(
updater: &AxoUpdater,
current_version: &Pep440Version,
target_version: &Pep440Version,
printer: Printer,
client_builder: BaseClientBuilder<'_>,
github_token: Option<&str>,
) -> Result<ExitStatus> {
let installer_urls = official_installer_urls(target_version)?;
let temp_dir = TempDir::new()?;
let installer_path = temp_dir.path().join(installer_filename());
let install_prefix = PathBuf::from(updater.install_prefix_root()?.as_str());
let modify_path = load_receipt_modify_path("uv")
.context("Failed to determine whether the existing standalone install modified PATH")?;
download_installer_from_urls(
&installer_urls,
&installer_path,
client_builder,
github_token,
)
.await?;
execute_official_installer(&installer_path, &install_prefix, modify_path).await?;
let direction = if current_version > target_version {
"Downgraded"
} else {
"Upgraded"
};
writeln!(
printer.stderr(),
"{}",
format_args!(
"{}{} {direction} uv from {} to {}! {}",
"success".green().bold(),
":".bold(),
format!("v{current_version}").bold().cyan(),
format!("v{target_version}").bold().cyan(),
format!("https://github.com/astral-sh/uv/releases/tag/{target_version}").cyan(),
)
)?;
Ok(ExitStatus::Success)
}
fn installer_filename() -> &'static str {
if cfg!(windows) {
"uv-installer.ps1"
} else {
"uv-installer.sh"
}
}
fn official_installer_urls(version: &Pep440Version) -> Result<Vec<DisplaySafeUrl>> {
let filename = installer_filename();
let mirror = format!("{UV_MIRROR_RELEASES_DOWNLOAD_PREFIX}{version}/{filename}");
let canonical = format!("{UV_GITHUB_RELEASES_DOWNLOAD_PREFIX}{version}/{filename}");
Ok(vec![
DisplaySafeUrl::parse(&mirror).with_context(|| format!("Failed to parse `{mirror}`"))?,
DisplaySafeUrl::parse(&canonical)
.with_context(|| format!("Failed to parse `{canonical}`"))?,
])
}
async fn download_installer_from_urls(
urls: &[DisplaySafeUrl],
installer_path: &Path,
client_builder: BaseClientBuilder<'_>,
github_token: Option<&str>,
) -> Result<()> {
let retry_policy = client_builder.retry_policy();
let client = client_builder
.retries(0)
.build()
.context("Failed to build HTTP client for self-update")?;
fetch_with_url_fallback(urls, retry_policy, "official uv installer", |url| async {
let mut request = client.for_host(&url).get(Url::from(url.clone()));
if let Some(github_token) = installer_download_github_token(&url, github_token) {
request = request.header("Authorization", format!("Bearer {github_token}"));
}
let response = request
.send()
.await
.map_err(|source| InstallerDownloadError::Download {
url: url.clone(),
source: source.into(),
})?;
let response =
response
.error_for_status()
.map_err(|source| InstallerDownloadError::Download {
url: url.clone(),
source: source.into(),
})?;
let bytes = response
.bytes()
.await
.map_err(|source| InstallerDownloadError::Download {
url,
source: source.into(),
})?;
fs_err::tokio::write(installer_path, &bytes)
.await
.map_err(|source| InstallerDownloadError::Write {
path: installer_path.to_path_buf(),
source,
})?;
Ok::<(), InstallerDownloadError>(())
})
.await?;
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
fs_err::tokio::set_permissions(installer_path, Permissions::from_mode(0o744)).await?;
}
Ok(())
}
fn installer_download_github_token<'a>(
url: &DisplaySafeUrl,
github_token: Option<&'a str>,
) -> Option<&'a str> {
match url.host_str() {
Some("github.com") => github_token,
_ => None,
}
}
async fn execute_official_installer(
installer_path: &Path,
install_prefix: &Path,
modify_path: bool,
) -> Result<(), AxoupdateError> {
let mut command = if cfg!(windows) {
let mut command = Command::new("powershell");
command.arg("-ExecutionPolicy").arg("ByPass");
command.arg(installer_path);
command
} else {
Command::new(installer_path)
};
let to_restore = if cfg!(windows) {
let old_path = std::env::current_exe()?;
let mut previous_path = old_path.as_os_str().to_os_string();
previous_path.push(".previous.exe");
let previous_path = PathBuf::from(previous_path);
fs_err::rename(&old_path, &previous_path)?;
Some((previous_path, old_path))
} else {
None
};
command.env_remove(EnvVars::PS_MODULE_PATH);
command.env("CARGO_DIST_FORCE_INSTALL_DIR", install_prefix);
command.env(EnvVars::UV_INSTALL_DIR, install_prefix);
if !modify_path {
let app_name_env_var = app_name_to_env_var("uv");
command.env(format!("{app_name_env_var}_NO_MODIFY_PATH"), "1");
}
let result = command.output().await;
let failed = result
.as_ref()
.map(|output| !output.status.success())
.unwrap_or(true);
if let Some((previous_path, old_path)) = to_restore.as_ref() {
if failed {
fs_err::rename(previous_path, old_path)?;
} else {
#[cfg(windows)]
self_replace::self_delete_at(previous_path)
.map_err(|_| AxoupdateError::CleanupFailed {})?;
}
}
let output = result?;
if output.status.success() {
return Ok(());
}
let stdout =
(!output.stdout.is_empty()).then(|| String::from_utf8_lossy(&output.stdout).to_string());
let stderr =
(!output.stderr.is_empty()).then(|| String::from_utf8_lossy(&output.stderr).to_string());
Err(AxoupdateError::InstallFailed {
status: output.status.code(),
stdout,
stderr,
})
}
fn load_receipt_modify_path(app_name: &str) -> Result<bool> {
let Some(receipt_path) = find_receipt_path(app_name)? else {
anyhow::bail!("Failed to locate the standalone install receipt for `{app_name}`");
};
let receipt = fs_err::read(&receipt_path).with_context(|| {
format!(
"Failed to read install receipt at `{}`",
receipt_path.display()
)
})?;
let receipt: StandaloneInstallReceipt =
serde_json::from_slice(&receipt).with_context(|| {
format!(
"Failed to parse install receipt at `{}`",
receipt_path.display()
)
})?;
Ok(receipt.modify_path)
}
fn find_receipt_path(app_name: &str) -> Result<Option<PathBuf>> {
for prefix in receipt_prefixes(app_name)? {
let receipt_path = prefix.join(format!("{app_name}-receipt.json"));
if receipt_path.exists() {
return Ok(Some(receipt_path));
}
}
Ok(None)
}
fn receipt_prefixes(app_name: &str) -> Result<Vec<PathBuf>> {
if std::env::var_os(AXOUPDATER_CONFIG_WORKING_DIR).is_some() {
return Ok(vec![std::env::current_dir()?]);
}
if let Some(path) = std::env::var_os(AXOUPDATER_CONFIG_PATH) {
return Ok(vec![PathBuf::from(path)]);
}
let mut prefixes = Vec::new();
if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") {
let path = PathBuf::from(path).join(app_name);
if path.exists() {
prefixes.push(path);
}
}
#[cfg(windows)]
if let Some(path) = std::env::var_os("LOCALAPPDATA") {
prefixes.push(PathBuf::from(path).join(app_name));
}
#[cfg(not(windows))]
if let Ok(path) = etcetera::home_dir() {
prefixes.push(path.join(".config").join(app_name));
}
Ok(prefixes)
}
async fn run_custom_updater(
updater: &mut AxoUpdater,
printer: Printer,
has_token: bool,
) -> Result<ExitStatus> {
match updater.run().await {
Ok(Some(result)) => {
let direction = if result
.old_version
.as_ref()
.is_some_and(|old_version| *old_version > result.new_version)
{
"Downgraded"
} else {
"Upgraded"
};
let version_information = if let Some(old_version) = result.old_version {
format!(
"from {} to {}",
format!("v{old_version}").bold().cyan(),
format!("v{}", result.new_version).bold().cyan(),
)
} else {
format!("to {}", format!("v{}", result.new_version).bold().cyan())
};
writeln!(
printer.stderr_important(),
"{}",
format_args!(
"{}{} {direction} uv {}! {}",
"success".green().bold(),
":".bold(),
version_information,
format!(
"https://github.com/astral-sh/uv/releases/tag/{}",
result.new_version_tag
)
.cyan()
)
)?;
}
Ok(None) => {
writeln!(
printer.stderr(),
"{}",
format_args!(
"{}{} You're on the latest version of uv ({})",
"success".green().bold(),
":".bold(),
format!("v{}", env!("CARGO_PKG_VERSION")).bold().cyan()
)
)?;
}
Err(err) => {
return if let AxoupdateError::Reqwest(err) = err {
if err.status() == Some(http::StatusCode::FORBIDDEN) && !has_token {
writeln!(
printer.stderr_important(),
"{}",
format_args!(
"{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.",
"error".red().bold(),
":".bold(),
"`--token`".green().bold()
)
)?;
Ok(ExitStatus::Error)
} else {
Err(err.into())
}
} else {
Err(err.into())
};
}
}
Ok(ExitStatus::Success)
}
#[derive(Debug, Deserialize)]
struct StandaloneInstallReceipt {
#[serde(default = "default_modify_path")]
modify_path: bool,
}
const fn default_modify_path() -> bool {
true
}
#[derive(Debug, Error)]
enum InstallerDownloadError {
#[error("Failed to download installer from: {url}")]
Download {
url: DisplaySafeUrl,
#[source]
source: WrappedReqwestError,
},
#[error("Failed to write installer to: {path}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Request failed after {retries} {subject} in {duration:.1}s",
subject = if *retries > 1 { "retries" } else { "retry" },
duration = duration.as_secs_f32()
)]
RetriedError {
#[source]
err: Box<Self>,
retries: u32,
duration: Duration,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
SystemTime(#[from] SystemTimeError),
}
impl RetriableError for InstallerDownloadError {
fn should_try_next_url(&self) -> bool {
match self {
Self::Download { source, .. } => should_try_next_installer_url(source),
Self::RetriedError { err, .. } => err.should_try_next_url(),
Self::Write { .. } | Self::Io(..) | Self::SystemTime(..) => false,
}
}
fn retries(&self) -> u32 {
if let Self::RetriedError { retries, .. } = self {
*retries
} else {
0
}
}
fn into_retried(self, retries: u32, duration: Duration) -> Self {
Self::RetriedError {
err: Box::new(self),
retries,
duration,
}
}
}
fn should_try_next_installer_url(error: &WrappedReqwestError) -> bool {
if let Some(error) = error.inner()
&& (error.status().is_some()
|| error.is_timeout()
|| error.is_connect()
|| error.is_request()
|| error.is_body()
|| error.is_decode())
{
return true;
}
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error);
while let Some(error) = source {
if let Some(io_error) = error.downcast_ref::<std::io::Error>()
&& matches!(
io_error.kind(),
std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::InvalidData
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::UnexpectedEof
)
{
return true;
}
source = error.source();
}
false
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, Sender};
use std::thread::JoinHandle;
use std::time::Duration;
use super::*;
fn spawn_http_server(
response: String,
) -> (DisplaySafeUrl, Arc<AtomicUsize>, Sender<()>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(AtomicUsize::new(0));
let requests_clone = Arc::clone(&requests);
let (shutdown_tx, shutdown_rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
loop {
if shutdown_rx.try_recv().is_ok() {
return;
}
match listener.accept() {
Ok((mut stream, _)) => {
requests_clone.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
stream.write_all(response.as_bytes()).unwrap();
return;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("failed to accept connection: {err}"),
}
}
});
(
DisplaySafeUrl::parse(&format!("http://{addr}/uv-installer.sh")).unwrap(),
requests,
shutdown_tx,
handle,
)
}
fn installer_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{body}",
body.len()
)
}
fn not_found_response() -> String {
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_string()
}
#[test]
fn test_is_official_public_uv_install() {
let source = ReleaseSource {
release_type: ReleaseSourceType::GitHub,
owner: "astral-sh".to_string(),
name: "uv".to_string(),
app_name: "uv".to_string(),
};
assert!(!is_official_public_uv_install_with_overrides(
None, false, false,
));
assert!(is_official_public_uv_install_with_overrides(
Some(&source),
false,
false,
));
assert!(!is_official_public_uv_install_with_overrides(
Some(&source),
true,
false,
));
assert!(!is_official_public_uv_install_with_overrides(
Some(&source),
false,
true,
));
let source = ReleaseSource {
owner: "astral-sh".to_string(),
name: "ruff".to_string(),
app_name: "uv".to_string(),
..source
};
assert!(!is_official_public_uv_install_with_overrides(
Some(&source),
false,
false,
));
}
#[test]
fn test_official_target_version_specifiers() {
assert_eq!(official_target_version_specifiers(None).unwrap(), None);
assert_eq!(
official_target_version_specifiers(Some("1.2.3")).unwrap(),
Some(VersionSpecifiers::from(VersionSpecifier::equals_version(
Pep440Version::new([1, 2, 3]),
)))
);
assert!(official_target_version_specifiers(Some("0.10")).is_err());
assert!(official_target_version_specifiers(Some("v1.2.3")).is_err());
}
#[test]
fn test_official_update_needed() {
assert!(!is_update_needed(
&Pep440Version::new([1, 2, 3]),
&Pep440Version::new([1, 2, 3]),
false,
));
assert!(is_update_needed(
&Pep440Version::new([1, 2, 3]),
&Pep440Version::new([1, 2, 4]),
false,
));
assert!(!is_update_needed(
&Pep440Version::new([1, 2, 4]),
&Pep440Version::new([1, 2, 3]),
false,
));
assert!(!is_update_needed(
&Pep440Version::new([1, 2, 3]),
&Pep440Version::new([1, 2, 3]),
true,
));
assert!(is_update_needed(
&Pep440Version::new([1, 2, 4]),
&Pep440Version::new([1, 2, 3]),
true,
));
}
#[test]
fn test_official_installer_urls() {
let urls = official_installer_urls(&Pep440Version::new([1, 2, 3]))
.unwrap()
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>();
assert_eq!(
urls,
vec![
format!(
"https://releases.astral.sh/github/uv/releases/download/1.2.3/{}",
installer_filename()
),
format!(
"https://github.com/astral-sh/uv/releases/download/1.2.3/{}",
installer_filename()
),
]
);
}
#[test]
fn test_installer_download_github_token() {
let mirror = DisplaySafeUrl::parse(
"https://releases.astral.sh/github/uv/releases/download/1.2.3/uv-installer.sh",
)
.unwrap();
let github = DisplaySafeUrl::parse(
"https://github.com/astral-sh/uv/releases/download/1.2.3/uv-installer.sh",
)
.unwrap();
assert_eq!(
installer_download_github_token(&mirror, Some("token")),
None
);
assert_eq!(
installer_download_github_token(&github, Some("token")),
Some("token")
);
assert_eq!(installer_download_github_token(&github, None), None);
}
#[tokio::test]
async fn test_download_installer_falls_back_to_canonical_url() {
let (mirror_url, mirror_requests, mirror_shutdown, mirror_handle) =
spawn_http_server(not_found_response());
let (canonical_url, canonical_requests, canonical_shutdown, canonical_handle) =
spawn_http_server(installer_response("echo canonical installer\n"));
let temp_dir = TempDir::new().unwrap();
let installer_path = temp_dir.path().join("installer.sh");
download_installer_from_urls(
&[mirror_url, canonical_url],
&installer_path,
BaseClientBuilder::default(),
None,
)
.await
.expect("404 from mirror should fall back to canonical installer URL");
let _ = mirror_shutdown.send(());
let _ = canonical_shutdown.send(());
mirror_handle.join().unwrap();
canonical_handle.join().unwrap();
assert_eq!(mirror_requests.load(Ordering::SeqCst), 1);
assert_eq!(canonical_requests.load(Ordering::SeqCst), 1);
assert_eq!(
fs_err::read_to_string(&installer_path).unwrap(),
"echo canonical installer\n"
);
}
#[test]
fn test_standalone_install_receipt_defaults_modify_path_to_true() {
let receipt: StandaloneInstallReceipt =
serde_json::from_str("{}\n").expect("receipt without modify_path should parse");
assert!(receipt.modify_path);
let receipt: StandaloneInstallReceipt = serde_json::from_str("{\"modify_path\":false}\n")
.expect("receipt with explicit modify_path should parse");
assert!(!receipt.modify_path);
}
#[cfg(unix)]
#[tokio::test]
async fn test_download_installer_sets_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let (url, _requests, shutdown_tx, handle) =
spawn_http_server(installer_response("echo installer\n"));
let temp_dir = TempDir::new().unwrap();
let installer_path = temp_dir.path().join("installer.sh");
download_installer_from_urls(&[url], &installer_path, BaseClientBuilder::default(), None)
.await
.expect("installer download should succeed");
let _ = shutdown_tx.send(());
handle.join().unwrap();
let mode = fs_err::metadata(&installer_path)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o100, 0o100, "installer should be owner-executable");
}
#[cfg(unix)]
#[tokio::test]
async fn test_execute_official_installer_reports_failure() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let installer_path = temp_dir.path().join("installer.sh");
let install_prefix = temp_dir.path().join("install-prefix");
fs_err::write(
&installer_path,
"#!/bin/sh\nprintf 'hello from stdout\\n'\nprintf 'hello from stderr\\n' >&2\nexit 23\n",
)
.unwrap();
fs_err::set_permissions(&installer_path, std::fs::Permissions::from_mode(0o744)).unwrap();
let err = execute_official_installer(&installer_path, &install_prefix, true)
.await
.expect_err("failing installer should return an error");
let AxoupdateError::InstallFailed {
status,
stdout,
stderr,
} = err
else {
panic!("expected InstallFailed error");
};
assert_eq!(status, Some(23));
assert_eq!(stdout.as_deref(), Some("hello from stdout\n"));
assert_eq!(stderr.as_deref(), Some("hello from stderr\n"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_execute_official_installer_sets_install_env_vars() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("env.txt");
let installer_path = temp_dir.path().join("installer.sh");
let install_prefix = temp_dir.path().join("install-prefix");
fs_err::write(
&installer_path,
format!(
"#!/bin/sh\nset -eu\n{{\nprintf 'CARGO_DIST_FORCE_INSTALL_DIR=%s\\n' \"$CARGO_DIST_FORCE_INSTALL_DIR\"\nprintf 'UV_INSTALL_DIR=%s\\n' \"$UV_INSTALL_DIR\"\nprintf 'UV_NO_MODIFY_PATH=%s\\n' \"${{UV_NO_MODIFY_PATH-}}\"\n}} > \"{}\"\n",
output_path.display()
),
)
.unwrap();
fs_err::set_permissions(&installer_path, std::fs::Permissions::from_mode(0o744)).unwrap();
execute_official_installer(&installer_path, &install_prefix, false)
.await
.unwrap();
assert_eq!(
fs_err::read_to_string(&output_path).unwrap(),
format!(
"CARGO_DIST_FORCE_INSTALL_DIR={}\nUV_INSTALL_DIR={}\nUV_NO_MODIFY_PATH=1\n",
install_prefix.display(),
install_prefix.display(),
)
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_execute_official_installer_preserves_modify_path_default() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("env.txt");
let installer_path = temp_dir.path().join("installer.sh");
let install_prefix = temp_dir.path().join("install-prefix");
fs_err::write(
&installer_path,
format!(
"#!/bin/sh\nset -eu\n{{\nprintf 'UV_NO_MODIFY_PATH=%s\\n' \"${{UV_NO_MODIFY_PATH-}}\"\n}} > \"{}\"\n",
output_path.display()
),
)
.unwrap();
fs_err::set_permissions(&installer_path, std::fs::Permissions::from_mode(0o744)).unwrap();
execute_official_installer(&installer_path, &install_prefix, true)
.await
.unwrap();
assert_eq!(
fs_err::read_to_string(&output_path).unwrap(),
"UV_NO_MODIFY_PATH=\n"
);
}
}