Skip to content

Commit 24323a0

Browse files
alukachclaude
andauthored
fix: handle Range header in HEAD requests (#114)
## What I'm changing The HEAD handler was ignoring Range headers entirely, always returning 200 OK with the full Content-Length. This caused clients issuing `HEAD -H "Range: bytes=0-1023"` to receive incorrect responses missing 206 status, Content-Range, and Accept-Ranges headers. ## How I did it - Extract range parsing into `parse_range_str`/`parse_range_header` in utils/core.rs with comprehensive unit tests (13 test cases) - Update HEAD handler to return 206 Partial Content with correct Content-Range and Content-Length when a Range header is present - Refactor GET handler to use the same shared parsing logic --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47e3b32 commit 24323a0

2 files changed

Lines changed: 207 additions & 44 deletions

File tree

src/main.rs

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
mod apis;
22
mod backends;
33
mod utils;
4-
use crate::utils::core::{split_at_first_slash, StreamingResponse};
4+
use crate::utils::core::{split_at_first_slash, RangeRequest, StreamingResponse};
55
use actix_cors::Cors;
66
use actix_web::body::{BodySize, BoxBody, MessageBody};
77
use actix_web::error::ErrorInternalServerError;
88
use actix_web::{
9-
delete, get, head, http::header::CONTENT_TYPE, http::header::RANGE, middleware, post, put, web,
10-
App, HttpRequest, HttpResponse, HttpServer, Responder,
9+
delete, get, head,
10+
http::header::{CONTENT_TYPE, RANGE},
11+
middleware, post, put, web, App, HttpRequest, HttpResponse, HttpServer, Responder,
1112
};
1213

1314
use apis::source::{RepositoryPermission, SourceApi};
@@ -56,27 +57,11 @@ async fn get_object(
5657
user_identity: web::ReqData<UserIdentity>,
5758
) -> Result<impl Responder, BackendError> {
5859
let (account_id, repository_id, key) = path.into_inner();
59-
let headers = req.headers();
60-
let mut range_start = 0;
61-
let mut is_range_request = false;
62-
63-
let range = headers
60+
let range_info: Option<RangeRequest> = req
61+
.headers()
6462
.get(RANGE)
65-
.and_then(|h| h.to_str().ok())
66-
.and_then(|r| r.strip_prefix("bytes="))
67-
.and_then(|bytes_range| bytes_range.split_once('-'))
68-
.and_then(|(start, end)| {
69-
start.parse::<u64>().ok().map(|s| {
70-
range_start = s;
71-
if end.is_empty() || end.parse::<u64>().is_ok() {
72-
is_range_request = true;
73-
Some(format!("bytes={start}-{end}"))
74-
} else {
75-
None
76-
}
77-
})
78-
})
79-
.flatten();
63+
.and_then(|v| String::from_utf8(v.as_ref().to_vec()).ok())
64+
.and_then(|s| s.parse().ok());
8065

8166
let client = api_client
8267
.get_backend_client(&account_id, &repository_id)
@@ -92,13 +77,13 @@ async fn get_object(
9277
.await?;
9378

9479
// Found the repository, now try to get the object
95-
let res = client.get_object(key.clone(), range).await?;
80+
let res = client
81+
.get_object(key.clone(), range_info.map(Into::into))
82+
.await?;
9683

97-
let mut content_length = String::from("*");
98-
// Remove this if statement to increase performance since it's making an extra request just to get the total content-length
99-
// This is only needed for range requests and in theory, you can return a * in the Content-Range header to indicate that the content length is unknown
100-
if is_range_request {
101-
content_length = client
84+
let mut total_content_length = String::from("*");
85+
if range_info.is_some() {
86+
total_content_length = client
10287
.head_object(key.clone())
10388
.await?
10489
.content_length
@@ -110,7 +95,7 @@ async fn get_object(
11095
.map(|result| result.map_err(|e| ErrorInternalServerError(e.to_string())));
11196

11297
let streaming_response = StreamingResponse::new(stream, res.content_length);
113-
let mut response = if is_range_request {
98+
let mut response = if range_info.is_some() {
11499
HttpResponse::PartialContent()
115100
} else {
116101
HttpResponse::Ok()
@@ -124,15 +109,15 @@ async fn get_object(
124109
.insert_header(("Content-Length", res.content_length.to_string()))
125110
.insert_header(("ETag", res.etag));
126111

127-
if is_range_request {
112+
if let Some(ref range) = range_info {
128113
response = response
129114
.insert_header((
130115
"Content-Range",
131116
format!(
132117
"bytes {}-{}/{}",
133-
range_start,
134-
range_start + res.content_length - 1,
135-
content_length
118+
range.start,
119+
range.start + res.content_length - 1,
120+
total_content_length
136121
),
137122
))
138123
.insert_header((
@@ -336,6 +321,7 @@ async fn post_handler(
336321
#[head("/{account_id}/{repository_id}/{key:.*}")]
337322
async fn head_object(
338323
api_client: web::Data<SourceApi>,
324+
req: HttpRequest,
339325
path: web::Path<(String, String, String)>,
340326
user_identity: web::ReqData<UserIdentity>,
341327
) -> Result<impl Responder, BackendError> {
@@ -355,15 +341,45 @@ async fn head_object(
355341
.await?;
356342

357343
let res = client.head_object(key.clone()).await?;
358-
Ok(HttpResponse::Ok()
359-
.insert_header(("Accept-Ranges", "bytes"))
360-
.insert_header(("Access-Control-Expose-Headers", "Accept-Ranges"))
361-
.insert_header(("Content-Type", res.content_type))
362-
.insert_header(("Last-Modified", res.last_modified))
363-
.insert_header(("ETag", res.etag))
364-
.body(BoxBody::new(FakeBody {
365-
size: res.content_length as usize,
366-
})))
344+
let total_size = res.content_length;
345+
let range_info: Option<RangeRequest> = req
346+
.headers()
347+
.get(RANGE)
348+
.and_then(|v| v.to_str().ok())
349+
.and_then(|s| s.parse().ok());
350+
351+
let response = if let Some(ref range) = range_info {
352+
let end = range.end.unwrap();
353+
let content_length = end - range.start + 1;
354+
HttpResponse::PartialContent()
355+
.insert_header(("Accept-Ranges", "bytes"))
356+
.insert_header((
357+
"Content-Range",
358+
format!("bytes {}-{}/{}", range.start, end, total_size),
359+
))
360+
.insert_header((
361+
"Access-Control-Expose-Headers",
362+
"Accept-Ranges, Content-Range",
363+
))
364+
.insert_header(("Content-Type", res.content_type))
365+
.insert_header(("Last-Modified", res.last_modified))
366+
.insert_header(("ETag", res.etag))
367+
.body(BoxBody::new(FakeBody {
368+
size: content_length as usize,
369+
}))
370+
} else {
371+
HttpResponse::Ok()
372+
.insert_header(("Accept-Ranges", "bytes"))
373+
.insert_header(("Access-Control-Expose-Headers", "Accept-Ranges"))
374+
.insert_header(("Content-Type", res.content_type))
375+
.insert_header(("Last-Modified", res.last_modified))
376+
.insert_header(("ETag", res.etag))
377+
.body(BoxBody::new(FakeBody {
378+
size: total_size as usize,
379+
}))
380+
};
381+
382+
Ok(response)
367383
}
368384

369385
#[derive(Deserialize)]

src/utils/core.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use actix_web::{
44
};
55
use futures::Stream;
66
use pin_project_lite::pin_project;
7-
use std::pin::Pin;
87
use std::task::{Context, Poll};
8+
use std::{pin::Pin, str::FromStr};
99

1010
pin_project! {
1111
pub struct StreamingResponse<S> {
@@ -97,3 +97,150 @@ pub fn split_at_first_slash(input: &str) -> (&str, &str) {
9797
None => (input, ""),
9898
}
9999
}
100+
101+
/// Parsed range request information.
102+
#[derive(Debug, Clone, Copy, PartialEq)]
103+
pub struct RangeRequest {
104+
/// The start byte offset
105+
pub start: u64,
106+
/// The end byte offset (inclusive), or `None` for open-ended ranges (e.g. "bytes=100-")
107+
pub end: Option<u64>,
108+
}
109+
110+
impl From<RangeRequest> for String {
111+
fn from(r: RangeRequest) -> Self {
112+
match r.end {
113+
Some(end) => format!("bytes={}-{}", r.start, end),
114+
None => format!("bytes={}-", r.start),
115+
}
116+
}
117+
}
118+
119+
impl FromStr for RangeRequest {
120+
type Err = ();
121+
122+
fn from_str(s: &str) -> Result<Self, Self::Err> {
123+
let bytes_range = s.strip_prefix("bytes=").ok_or(())?;
124+
let (start_str, end_str) = bytes_range.split_once('-').ok_or(())?;
125+
let start = start_str.parse::<u64>().map_err(|_| ())?;
126+
127+
let end = if end_str.is_empty() {
128+
None
129+
} else {
130+
let end = end_str.parse::<u64>().map_err(|_| ())?;
131+
if start > end {
132+
return Err(());
133+
}
134+
Some(end)
135+
};
136+
137+
Ok(RangeRequest { start, end })
138+
}
139+
}
140+
141+
#[cfg(test)]
142+
mod tests {
143+
use super::*;
144+
145+
#[test]
146+
fn test_parse_range_full_range() {
147+
let result: RangeRequest = "bytes=0-1023".parse().unwrap();
148+
assert_eq!(
149+
result,
150+
RangeRequest {
151+
start: 0,
152+
end: Some(1023),
153+
}
154+
);
155+
}
156+
157+
#[test]
158+
fn test_parse_range_open_ended() {
159+
let result = "bytes=100-".parse::<RangeRequest>().unwrap();
160+
assert_eq!(
161+
result,
162+
RangeRequest {
163+
start: 100,
164+
end: None,
165+
}
166+
);
167+
}
168+
169+
#[test]
170+
fn test_parse_range_open_ended_no_total_size() {
171+
// Without with_total_size, end remains None
172+
let result: RangeRequest = "bytes=100-".parse().unwrap();
173+
assert_eq!(result.end, None);
174+
}
175+
176+
#[test]
177+
fn test_parse_range_missing_prefix() {
178+
assert!("invalid=0-100".parse::<RangeRequest>().is_err());
179+
}
180+
181+
#[test]
182+
fn test_parse_range_non_numeric_start() {
183+
assert!("bytes=abc-100".parse::<RangeRequest>().is_err());
184+
}
185+
186+
#[test]
187+
fn test_parse_range_non_numeric_end() {
188+
assert!("bytes=0-abc".parse::<RangeRequest>().is_err());
189+
}
190+
191+
#[test]
192+
fn test_parse_range_start_greater_than_end() {
193+
assert!("bytes=500-100".parse::<RangeRequest>().is_err());
194+
}
195+
196+
#[test]
197+
fn test_parse_range_start_beyond_total_size() {
198+
// Parsing succeeds; validation against total_size is the caller's responsibility
199+
let result: RangeRequest = "bytes=1000-1023".parse().unwrap();
200+
assert_eq!(result.start, 1000);
201+
assert_eq!(result.end, Some(1023));
202+
}
203+
204+
#[test]
205+
fn test_parse_range_single_byte() {
206+
let result: RangeRequest = "bytes=0-0".parse().unwrap();
207+
assert_eq!(
208+
result,
209+
RangeRequest {
210+
start: 0,
211+
end: Some(0),
212+
}
213+
);
214+
}
215+
216+
#[test]
217+
fn test_parse_range_large_file() {
218+
let rr: RangeRequest = "bytes=0-1023".parse().unwrap();
219+
assert_eq!(rr.start, 0);
220+
assert_eq!(rr.end, Some(1023));
221+
assert_eq!(rr.end.unwrap() - rr.start + 1, 1024);
222+
}
223+
224+
#[test]
225+
fn test_parse_range_no_hyphen() {
226+
assert!("bytes=100".parse::<RangeRequest>().is_err());
227+
}
228+
229+
#[test]
230+
fn test_parse_range_content_length_calculation() {
231+
let result: RangeRequest = "bytes=0-1023".parse().unwrap();
232+
assert_eq!(result.end.unwrap() - result.start + 1, 1024);
233+
}
234+
235+
#[test]
236+
fn test_parse_range_content_range_format() {
237+
let result: RangeRequest = "bytes=0-1023".parse().unwrap();
238+
let content_range = format!(
239+
"bytes {}-{}/{}",
240+
result.start,
241+
result.end.unwrap(),
242+
3515053862u64
243+
);
244+
assert_eq!(content_range, "bytes 0-1023/3515053862");
245+
}
246+
}

0 commit comments

Comments
 (0)