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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 66 additions & 28 deletions src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -673,6 +675,17 @@ pub fn install_from_url(
path: &PathBuf,
paths: &GlobalPaths,
) -> Result<crate::config_file::JuliaupConfigChannel> {
// 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-")
Expand Down Expand Up @@ -728,6 +741,16 @@ pub fn install_non_db_version(
name: &String,
paths: &GlobalPaths,
) -> Result<crate::config_file::JuliaupConfigChannel> {
// 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()?;

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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::<Option<String>, anyhow::Error>(Some(etag))
Ok::<Option<String>, anyhow::Error>(etag)
} else {
Ok::<Option<String>, anyhow::Error>(None)
}
Expand All @@ -1745,6 +1776,9 @@ fn download_direct_download_etags(
) -> Result<Vec<(String, Option<String>)>> {
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();
Expand All @@ -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();
Expand All @@ -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::<Option<String>, anyhow::Error>(Some(etag))
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());

Ok::<Option<String>, anyhow::Error>(etag)
} else {
Ok::<Option<String>, anyhow::Error>(None)
}
Expand Down
180 changes: 180 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> = 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<bool> {
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<bool> {
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<Url> {
let base_url = if let Ok(val) = std::env::var("JULIAUP_SERVER") {
if val.ends_with('/') {
Expand Down