diff --git a/README.md b/README.md index bb33010e..627990a2 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,9 @@ the `JULIAUP_DEPOT_PATH` environment variable. Caution: Previous versions of Jul Juliaup by default downloads julia binary tarballs from the official server "https://julialang-s3.julialang.org". If requested, the environment variable `JULIAUP_SERVER` can be used to tell Juliaup to use a third-party mirror server. +**Note:** Nightly and PR channels (e.g., `nightly`, `pr123`) require the server to provide `etag` headers in HTTP responses for version tracking. +If your custom mirror server does not support `etag` headers, these channels will not be available. Regular versioned Julia releases will still work normally. + ## Development guides For juliaup developers, information on how to build juliaup locally, update julia versions, and release updates diff --git a/src/operations.rs b/src/operations.rs index 82fbe9cb..de280f6d 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -10,6 +10,7 @@ use crate::get_bundled_julia_version; use crate::get_juliaup_target; use crate::global_paths::GlobalPaths; use crate::jsonstructs_versionsdb::JuliaupVersionDB; +use crate::utils::check_server_supports_nightlies; use crate::utils::get_bin_dir; use crate::utils::get_julianightlies_base_url; use crate::utils::get_juliaserver_base_url; @@ -203,13 +204,13 @@ pub fn download_extract_sans_parent( pb.set_prefix(DOWNLOADING_PREFIX); pb.set_style(bar_style()); - let last_modified = match response + // Extract etag if present, otherwise return empty string + // Empty etag is valid for regular version installs from servers without etag support + let last_modified = response .headers() - .get("etag") { - Some(etag) => Ok(etag.to_str().unwrap().to_string()), - None => Err(anyhow!(format!("Failed to get etag from `{}`.\n\ - This is likely due to requesting a pull request that does not have a cached build available. You may have to build locally.", url))), - }?; + .get("etag") + .map(|etag| etag.to_str().unwrap_or("").to_string()) + .unwrap_or_default(); let response_with_pb = pb.wrap_read(response); @@ -260,15 +261,16 @@ pub fn download_extract_sans_parent( http_response .EnsureSuccessStatusCode() - .with_context(|| format!("Failed to get etag from `{}`.\n\ - This is likely due to requesting a pull request that does not have a cached build available. You may have to build locally.", url))?; + .with_context(|| format!("Failed to download from `{}`.", url))?; + // Extract etag if present, otherwise return empty string + // Empty etag is valid for regular version installs from servers without etag support let last_modified = http_response .Headers() - .unwrap() - .Lookup(&HSTRING::from("etag")) - .unwrap() - .to_string(); + .ok() + .and_then(|headers| headers.Lookup(&HSTRING::from("etag")).ok()) + .map(|etag| etag.to_string()) + .unwrap_or_default(); let http_response_content = http_response .Content() @@ -673,6 +675,17 @@ pub fn install_from_url( path: &PathBuf, paths: &GlobalPaths, ) -> Result { + // Check if the nightly server supports etag headers (required for nightly/PR channels) + // Do this BEFORE downloading to avoid wasting bandwidth + if !check_server_supports_nightlies() + .context("Failed to check if nightly server supports etag headers")? + { + bail!( + "The configured nightly server does not support etag headers, which are required for nightly and PR channels.\n\ + Nightly and PR channels cannot be installed from this server." + ); + } + // Download and extract into a temporary directory let temp_dir = Builder::new() .prefix("julia-temp-") @@ -728,6 +741,16 @@ pub fn install_non_db_version( name: &String, paths: &GlobalPaths, ) -> Result { + // Check if the nightly server supports etag headers (required for nightly/PR channels) + if !check_server_supports_nightlies() + .context("Failed to check if nightly server supports etag headers")? + { + bail!( + "The configured nightly server does not support etag headers, which are required for nightly and PR channels.\n\ + Nightly and PR channels cannot be installed from this server." + ); + } + // Determine the download URL let download_url_base = get_julianightlies_base_url()?; @@ -1674,6 +1697,9 @@ fn download_direct_download_etags( use windows::Web::Http::HttpMethod; use windows::Web::Http::HttpRequestMessage; + // Check if the server supports etag headers (required for nightly/PR updates) + let server_supports_etag = check_server_supports_nightlies().unwrap_or(false); + let http_client = http_client()?; let mut requests = Vec::new(); @@ -1687,6 +1713,13 @@ fn download_direct_download_etags( } if let JuliaupConfigChannel::DirectDownloadChannel { url, .. } = installed_channel { + // If server doesn't support etag, we can't check for updates on nightly/PR channels + // Return None gracefully so the update process can continue with other channels + if !server_supports_etag { + requests.push((channel_name.clone(), None)); + continue; + } + let http_client = http_client.clone(); let url_clone = url.clone(); let channel_name_clone = channel_name.clone(); @@ -1713,16 +1746,14 @@ fn download_direct_download_etags( .map_err(|e| anyhow!("Failed to get response: {:?}", e))?; if response.IsSuccessStatusCode()? { - let headers = response + // Gracefully handle missing etag - return None instead of error + let etag = response .Headers() - .map_err(|e| anyhow!("Failed to get headers: {:?}", e))?; - - let etag = headers - .Lookup(&HSTRING::from("ETag")) - .map_err(|e| anyhow!("ETag header not found: {:?}", e))? - .to_string(); + .ok() + .and_then(|headers| headers.Lookup(&HSTRING::from("ETag")).ok()) + .map(|s| s.to_string()); - Ok::, anyhow::Error>(Some(etag)) + Ok::, anyhow::Error>(etag) } else { Ok::, anyhow::Error>(None) } @@ -1745,6 +1776,9 @@ fn download_direct_download_etags( ) -> Result)>> { use std::sync::Arc; + // Check if the server supports etag headers (required for nightly/PR updates) + let server_supports_etag = check_server_supports_nightlies().unwrap_or(false); + let client = Arc::new(http_client()?); let mut requests = Vec::new(); @@ -1758,6 +1792,13 @@ fn download_direct_download_etags( } if let JuliaupConfigChannel::DirectDownloadChannel { url, .. } = installed_channel { + // If server doesn't support etag, we can't check for updates on nightly/PR channels + // Return None gracefully so the update process can continue with other channels + if !server_supports_etag { + requests.push((channel_name.clone(), None)); + continue; + } + let client = Arc::clone(&client); let url_clone = url.clone(); let channel_name_clone = channel_name.clone(); @@ -1775,17 +1816,14 @@ fn download_direct_download_etags( })?; if response.status().is_success() { + // Gracefully handle missing etag - return None instead of error let etag = response .headers() .get("etag") - .ok_or_else(|| { - anyhow!("ETag header not found in response from {}", &url_clone) - })? - .to_str() - .map_err(|e| anyhow!("Failed to parse ETag header: {}", e))? - .to_string(); - - Ok::, anyhow::Error>(Some(etag)) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + Ok::, anyhow::Error>(etag) } else { Ok::, anyhow::Error>(None) } diff --git a/src/utils.rs b/src/utils.rs index f52e7868..61ef5e2a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,8 +6,188 @@ use retry::{ }; use semver::{BuildMetadata, Version}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use url::Url; +/// Cached result of whether the nightly server supports etag headers. +/// This is used to avoid repeated HTTP requests to check server capabilities. +static NIGHTLY_SERVER_SUPPORTS_ETAG: OnceLock = OnceLock::new(); + +/// Checks if the nightly server supports etag headers. +/// This is required for nightly and PR channel support because we use etags +/// to track versions of these builds. +/// +/// The result is cached after the first check. +/// If JULIAUP_SERVER equals the default official ones, it works as usual (assumes ETAG support). +/// Otherwise, sends a HEAD check request to verify ETAG support. +#[cfg(not(windows))] +pub fn check_server_supports_nightlies() -> Result { + Ok(*NIGHTLY_SERVER_SUPPORTS_ETAG.get_or_init(|| { + // Check if JULIAUP_SERVER equals the default official one + let is_default_server = { + let julia_server = std::env::var("JULIAUP_SERVER") + .unwrap_or_else(|_| "https://julialang-s3.julialang.org".to_string()); + + // Normalize URL (remove trailing slashes for comparison) + let julia_server_normalized = julia_server.trim_end_matches('/'); + + julia_server_normalized == "https://julialang-s3.julialang.org" + }; + + // If using default official servers, assume ETAG support + if is_default_server { + return true; + } + + // For custom servers, check via HEAD request + let base_url = match get_julianightlies_base_url() { + Ok(url) => url, + Err(_) => return false, + }; + + let test_url = match base_url.join("bin/") { + Ok(url) => url, + Err(_) => return false, + }; + + let client = reqwest::blocking::Client::new(); + match client.head(test_url.as_str()).send() { + Ok(response) => { + let has_etag = response.headers().get("etag").is_some(); + log::debug!("Server etag support check: {}", has_etag); + has_etag + } + Err(e) => { + log::debug!("Failed to check server etag support: {}", e); + false + } + } + })) +} + +/// Checks if the nightly server supports etag headers. +/// This is required for nightly and PR channel support because we use etags +/// to track versions of these builds. +/// +/// The result is cached after the first check. +/// If JULIAUP_SERVER equals the default official ones, it works as usual (assumes ETAG support). +/// Otherwise, sends a HEAD check request to verify ETAG support. +#[cfg(windows)] +pub fn check_server_supports_nightlies() -> Result { + use windows::core::HSTRING; + use windows::Foundation::Uri; + use windows::Web::Http::HttpClient; + use windows::Web::Http::HttpMethod; + use windows::Web::Http::HttpRequestMessage; + + Ok(*NIGHTLY_SERVER_SUPPORTS_ETAG.get_or_init(|| { + // Check if JULIAUP_SERVER equals the default official one + let is_default_server = { + let julia_server = std::env::var("JULIAUP_SERVER") + .unwrap_or_else(|_| "https://julialang-s3.julialang.org".to_string()); + + // Parse and compare URLs properly to handle variations + match Url::parse(&julia_server) { + Ok(parsed) => { + if let Ok(default_url) = Url::parse("https://julialang-s3.julialang.org") { + parsed.scheme() == default_url.scheme() + && parsed.host_str() == default_url.host_str() + && parsed.path().trim_end_matches('/') + == default_url.path().trim_end_matches('/') + } else { + false + } + } + Err(_) => { + // Fall back to simple string comparison if URL parsing fails + let julia_server_normalized = julia_server.trim_end_matches('/'); + julia_server_normalized == "https://julialang-s3.julialang.org" + } + } + }; + + // If using default official servers, assume ETAG support + if is_default_server { + return true; + } + + // For custom servers, check via HEAD request + let base_url = match get_julianightlies_base_url() { + Ok(url) => url, + Err(e) => { + log::debug!("Failed to get nightly base URL: {}", e); + return false; + } + }; + + let test_url = match base_url.join("bin/") { + Ok(url) => url, + Err(e) => { + log::debug!("Failed to join bin/ to base URL: {}", e); + return false; + } + }; + + let http_client = match HttpClient::new() { + Ok(client) => client, + Err(e) => { + log::debug!("Failed to create HTTP client: {:?}", e); + return false; + } + }; + + let request_uri = match Uri::CreateUri(&HSTRING::from(test_url.as_str())) { + Ok(uri) => uri, + Err(e) => { + log::debug!("Failed to create URI: {:?}", e); + return false; + } + }; + + let head_method = match HttpMethod::Head() { + Ok(m) => m, + Err(e) => { + log::debug!("Failed to create HEAD method: {:?}", e); + return false; + } + }; + + let request = match HttpRequestMessage::Create(&head_method, &request_uri) { + Ok(req) => req, + Err(e) => { + log::debug!("Failed to create request: {:?}", e); + return false; + } + }; + + let response = match http_client.SendRequestAsync(&request) { + Ok(async_op) => match async_op.join() { + Ok(resp) => resp, + Err(e) => { + log::debug!("Failed to send HEAD request: {:?}", e); + return false; + } + }, + Err(e) => { + log::debug!("Failed to start HEAD request: {:?}", e); + return false; + } + }; + + match response.Headers() { + Ok(headers) => { + let has_etag = headers.Lookup(&HSTRING::from("ETag")).is_ok(); + log::debug!("Server etag support check: {}", has_etag); + has_etag + } + Err(e) => { + log::debug!("Failed to get response headers: {:?}", e); + false + } + } + })) +} + pub fn get_juliaserver_base_url() -> Result { let base_url = if let Ok(val) = std::env::var("JULIAUP_SERVER") { if val.ends_with('/') {