Skip to content
Closed
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
111 changes: 68 additions & 43 deletions crates/trident/src/io_utils/http_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ use std::{env, io::BufReader};
use anyhow::{ensure, Context, Error};
use log::{debug, trace, warn};
use oci_client::{secrets::RegistryAuth, Client as OciClient, Reference};
use reqwest::blocking::{Client, Response};
use reqwest::{
blocking::{Client, Response},
header::{ACCEPT_RANGES, AUTHORIZATION, CONTENT_LENGTH, CONTENT_RANGE, RANGE},
StatusCode,
};
use tokio::runtime::Runtime;
use url::Url;

#[cfg(feature = "dangerous-options")]
use docker_credential::{self, DockerCredential};

use super::http_range::HttpRangeRequest;

#[cfg(feature = "dangerous-options")]
const DOCKER_CONFIG_FILE_PATH: &str = ".docker/config.json";

Expand Down Expand Up @@ -82,7 +88,7 @@ impl HttpFile {
let request_sender = || {
let mut request = client.head(url.as_str());
if let Some(token) = &token {
request = request.header("Authorization", format!("Bearer {token}"));
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
request.send()
};
Expand All @@ -92,48 +98,50 @@ impl HttpFile {
// Get the file size from the response headers
let size = response
.headers()
.get("Content-Length")
.ok_or(IoError::other(
"Failed to get 'Content-Length' in the response header",
))?
.get(CONTENT_LENGTH)
.ok_or_else(|| {
IoError::other(format!(
"Failed to get '{CONTENT_LENGTH}' in the response header"
))
})?
.to_str()
.map_err(|e| {
IoError::new(
IoErrorKind::InvalidData,
format!("Could not parse 'Content-Length': {e}"),
format!("Could not parse '{CONTENT_LENGTH}': {e}"),
)
})?
.parse()
.map_err(|e| {
IoError::new(
IoErrorKind::InvalidData,
format!("Could not parse 'Content-Length' as an integer: {e}"),
format!("Could not parse '{CONTENT_LENGTH}' as an integer: {e}"),
)
})?;

trace!("HTTP file '{}' has size: {}", url, size);

// Ensure the server supports range requests, this implementation
// requires that feature!
let accept_ranges_header = response.headers().get("Accept-Ranges");
let accept_ranges_header = response.headers().get(ACCEPT_RANGES);
if accept_ranges_header.is_none() && ignore_ranges_header_absence {
warn!("OCI server does not provide 'Accept-Ranges' header, continuing anyway");
warn!("OCI server does not provide '{ACCEPT_RANGES}' header, continuing anyway");
} else if accept_ranges_header
.ok_or(IoError::other(
"Server does not support range requests: 'Accept-Ranges' header was not provided",
.ok_or_else(|| IoError::other(
format!("Server does not support range requests: '{ACCEPT_RANGES}' header was not provided"),
))?
.to_str()
.map_err(|e| {
IoError::new(
IoErrorKind::InvalidData,
format!("Could not parse 'Accept-Ranges': {e}"),
format!("Could not parse '{ACCEPT_RANGES}': {e}"),
)
})?
.to_lowercase()
.eq("none")
{
return Err(IoError::other(
"Server does not support range requests: 'Accept-Ranges: none'",
format!("Server does not support range requests: '{ACCEPT_RANGES}: none'"),
));
}

Expand Down Expand Up @@ -175,29 +183,48 @@ impl HttpFile {
/// enabled, will default to anonymous access.
fn get_auth(_img_ref: &Reference) -> RegistryAuth {
#[cfg(feature = "dangerous-options")]
if let Ok(docker_config) = std::fs::File::open(
env::home_dir()
.unwrap_or_default()
.join(DOCKER_CONFIG_FILE_PATH),
) {
'config_auth: {
let Some(user_home) = env::home_dir() else {
debug!("Could not determine user home directory, using anonymous access.");
break 'config_auth;
};

let docker_config_path = user_home.join(DOCKER_CONFIG_FILE_PATH);
if !docker_config_path.exists() {
debug!(
"Docker config file does not exist at '{}'",
docker_config_path.display()
);
break 'config_auth;
}

let docker_config = match std::fs::File::open(docker_config_path) {
Ok(file) => file,
Err(e) => {
debug!("Failed to open docker config file: {}", e);
break 'config_auth;
}
};

let registry = _img_ref
.resolve_registry()
.strip_suffix('/')
.unwrap_or_else(|| _img_ref.resolve_registry());

match docker_credential::get_credential_from_reader(
BufReader::new(docker_config),
registry,
) {
Ok(DockerCredential::UsernamePassword(username, password)) => {
debug!("Found username and password docker credential");
debug!("Using username and password docker credential");
return RegistryAuth::Basic(username, password);
}
Ok(DockerCredential::IdentityToken(_)) => {
debug!("Found identity token docker credential")
debug!("Found identity token docker credential, ignoring")
}
Err(_) => debug!("Failed to find docker credentials"),
Err(e) => debug!("Failed to retrieve docker credentials: {e}"),
}
};
}

debug!("Proceeding with anonymous access");
RegistryAuth::Anonymous
Expand Down Expand Up @@ -241,12 +268,16 @@ impl HttpFile {
fn http_to_io_err(e: reqwest::Error) -> IoError {
let formatted = format!("HTTP File error: {e}");
if let Some(status) = e.status() {
match status.as_u16() {
400 => IoError::new(IoErrorKind::InvalidInput, formatted),
401 | 403 => IoError::new(IoErrorKind::PermissionDenied, formatted),
404 => IoError::new(IoErrorKind::NotFound, formatted),
408 => IoError::new(IoErrorKind::TimedOut, formatted),
500..=599 => IoError::new(IoErrorKind::ConnectionAborted, formatted),
match status {
StatusCode::BAD_REQUEST => IoError::new(IoErrorKind::InvalidInput, formatted),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
IoError::new(IoErrorKind::PermissionDenied, formatted)
}
StatusCode::NOT_FOUND => IoError::new(IoErrorKind::NotFound, formatted),
StatusCode::REQUEST_TIMEOUT => IoError::new(IoErrorKind::TimedOut, formatted),
_ if status.is_server_error() => {
IoError::new(IoErrorKind::ConnectionAborted, formatted)
}
_ => IoError::other(formatted),
}
} else if e.is_timeout() {
Expand All @@ -267,20 +298,12 @@ impl HttpFile {
let mut request = self.client.get(self.url.as_str());

if let Some(token) = self.token.clone() {
request = request.header("Authorization", format!("Bearer {token}"));
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}

// Generate the range header when appropriate
let range_header = match (start, end) {
(Some(start), Some(end)) => Some(format!("bytes={start}-{end}")),
(Some(start), None) => Some(format!("bytes={start}-")),
(None, Some(end)) => Some(format!("bytes=0-{end}")),
(None, None) => None,
};

// Add the range header to the request
if let Some(range) = range_header {
request = request.header("Range", range);
if let Some(range) = HttpRangeRequest::new(start, end).to_header_value() {
request = request.header(RANGE, range);
}

request.send()
Expand Down Expand Up @@ -350,7 +373,7 @@ impl HttpFile {

let response = self.reader(Some(section_offset), Some(end))?;

if let Some(data) = response.headers().get("Content-Range") {
if let Some(data) = response.headers().get(CONTENT_RANGE) {
trace!(
"Returned content range: {:?}",
String::from_utf8_lossy(data.as_bytes())
Expand Down Expand Up @@ -447,6 +470,8 @@ mod tests {
},
};

use reqwest::header::CONTENT_TYPE;

#[test]
fn test_retrieve_access_token() {
let client = OciClient::default();
Expand Down Expand Up @@ -496,8 +521,8 @@ mod tests {
let document_mock = server
.mock("GET", relative_file_path)
.with_body(data)
.with_header("content-length", &data.len().to_string())
.with_header("content-type", "text/plain")
.with_header(CONTENT_RANGE, &data.len().to_string())
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header constant should be CONTENT_LENGTH, not CONTENT_RANGE. This test is mocking a response header that specifies the content length of the response body, not a content range. The old code used "content-length" (lowercase) which is correct. CONTENT_RANGE is used in responses to range requests to indicate what range was returned (e.g., "bytes 0-99/1000"), not the total content length.

Suggested change
.with_header(CONTENT_RANGE, &data.len().to_string())
.with_header(CONTENT_LENGTH, &data.len().to_string())

Copilot uses AI. Check for mistakes.
.with_header(CONTENT_TYPE, "text/plain")
.with_status(200)
.expect(1)
.create();
Expand Down
44 changes: 44 additions & 0 deletions crates/trident/src/io_utils/http_range.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/// A struct representing a file range in an HTTP request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct HttpRangeRequest {
pub start: Option<u64>,
pub end: Option<u64>,
}
Comment on lines +1 to +6
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for this struct should clarify the semantics of the start and end fields. Specifically, it should state whether end represents an inclusive byte position (e.g., end=99 means bytes 0-99) or an exclusive position (e.g., end=100 means bytes 0-99). Based on the usage in section_reader which calculates end = section_offset + size - 1, it appears end is intended to be an inclusive byte position, but this should be explicitly documented to prevent bugs.

Copilot uses AI. Check for mistakes.

impl HttpRangeRequest {
/// Converts the HttpRangeRequest to a header value string. Returns None in
/// cases where no range header would be provided in the request, that is,
/// when requesting the whole file because both start and end are None.
pub fn to_header_value(self) -> Option<String> {
Some(match (self.start, self.end) {
(Some(start), Some(end)) => format!("bytes={}-{}", start, end - 1),
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The end - 1 calculation is incorrect here. The end parameter passed to this method already represents an inclusive byte position (as calculated in section_reader at line 365: let end = section_offset + size - 1). Subtracting 1 again will result in requesting one fewer byte than intended. For example, if requesting bytes 0-99, this will generate "bytes=0-98" instead of "bytes=0-99", resulting in only 99 bytes being read instead of 100.

Copilot uses AI. Check for mistakes.
(Some(start), None) => format!("bytes={}-", start),
(None, Some(end)) => format!("bytes=-{}", end),
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation changes the semantics from the old code. The old code generated bytes=0-{end} which means "get bytes from position 0 to position end (inclusive)". The new code generates bytes=-{end} which means "get the last end bytes of the file". These are different HTTP range semantics. While this case doesn't appear to be used in the current codebase, the semantic change could cause bugs if this method is used with start=None, end=Some(value) in the future. The old behavior should be preserved or the semantics should be clearly documented.

Copilot uses AI. Check for mistakes.
(None, None) => return None,
})
}

pub fn new(start: Option<u64>, end: Option<u64>) -> Self {
Self { start, end }
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_to_header_value() {
let range = HttpRangeRequest::new(Some(0), Some(100));
assert_eq!(range.to_header_value(), Some("bytes=0-99".to_string()));
Comment on lines +32 to +33
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test assertion is incorrect. If the end parameter is 100 and represents an inclusive byte position, the header should be "bytes=0-100", not "bytes=0-99". The test is currently passing because the implementation has a bug (subtracting 1), but both the test and implementation are wrong. The test should either pass Some(99) as end to get "bytes=0-99", or expect "bytes=0-100" if passing Some(100).

Copilot uses AI. Check for mistakes.

let range = HttpRangeRequest::new(Some(50), None);
assert_eq!(range.to_header_value(), Some("bytes=50-".to_string()));

let range = HttpRangeRequest::new(None, Some(200));
assert_eq!(range.to_header_value(), Some("bytes=-200".to_string()));

let range = HttpRangeRequest::new(None, None);
assert_eq!(range.to_header_value(), None);
}
}
1 change: 1 addition & 0 deletions crates/trident/src/io_utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod file_reader;
pub mod hashing_reader;
pub mod http_file;
mod http_range;
pub mod image_streamer;
Loading