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
61 changes: 49 additions & 12 deletions crates/server/src/routes/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,19 +248,19 @@ pub async fn get_object(
let mut resp_headers = build_object_headers(&info);

let range = parse_range(&headers, info.size)?;
let (status, start, end_inclusive) = match range {
Some((s, e)) => {
resp_headers.insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {s}-{e}/{}", info.size))
.map_err(|_| ApiError::Internal(anyhow::anyhow!("content-range encode")))?,
);
(StatusCode::PARTIAL_CONTENT, s, e)
}
None => (StatusCode::OK, 0, info.size.saturating_sub(1)),
if let Some((s, e)) = range {
resp_headers.insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {s}-{e}/{}", info.size))
.map_err(|_| ApiError::Internal(anyhow::anyhow!("content-range encode")))?,
);
}
let status = if range.is_some() {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};

let length = end_inclusive.saturating_sub(start).saturating_add(1);
let (start, length) = get_window(info.size, range);
resp_headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&length.to_string())
Expand Down Expand Up @@ -718,3 +718,40 @@ fn parse_range(headers: &HeaderMap, size: u64) -> Result<Option<(u64, u64)>, Api
}
Ok(Some((start, end)))
}

/// Byte window for a GET: `(start, length)`.
///
/// `range` is the inclusive `(start, end)` from [`parse_range`]. A missing
/// Range is the exclusive window `(0, size)` — same as S3 `GetObject`.
/// Inclusive-end (`size.saturating_sub(1) + 1`) turns size=0 into length 1
/// and mmap's one byte past EOF.
fn get_window(size: u64, range: Option<(u64, u64)>) -> (u64, u64) {
match range {
Some((s, e)) => (s, e.saturating_sub(s).saturating_add(1)),
None => (0, size),
}
}

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

#[test]
fn empty_object_full_get_is_zero_bytes() {
assert_eq!(
get_window(0, None),
(0, 0),
"GET of a 0-byte object must not ask mmap for 1 byte"
);
}

#[test]
fn nonempty_full_get_is_the_object_size() {
assert_eq!(get_window(10, None), (0, 10));
}

#[test]
fn inclusive_range_length() {
assert_eq!(get_window(10, Some((2, 5))), (2, 4));
}
}
59 changes: 59 additions & 0 deletions crates/server/tests/rest/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,65 @@ async fn put_get_head_delete_roundtrip() {
assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND);
}

/// PUT of an empty object is legal. GET without Range must return
/// Content-Length 0 and an empty body — not mmap 1 byte past EOF
/// (Darwin 500, Linux a one-byte zero body).
#[tokio::test]
async fn empty_object_get_is_zero_bytes() {
let bucket = unique_bucket("empty");
create_bucket(&bucket, "private").await;
let token = bucket_token(&bucket);

let res = client()
.put(url(&format!("/v1/{bucket}/marker")))
.bearer_auth(&token)
.header("content-type", "application/octet-stream")
.body(Vec::<u8>::new())
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::CREATED);
let put_body: serde_json::Value = res.json().await.unwrap();
assert_eq!(put_body["size"], 0);

let res = client()
.head(url(&format!("/v1/{bucket}/marker")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
assert_eq!(
res.headers()
.get("content-length")
.and_then(|v| v.to_str().ok()),
Some("0")
);

let res = client()
.get(url(&format!("/v1/{bucket}/marker")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
reqwest::StatusCode::OK,
"GET empty must not 500 (mmap past EOF)"
);
assert_eq!(
res.headers()
.get("content-length")
.and_then(|v| v.to_str().ok()),
Some("0")
);
let bytes = res.bytes().await.unwrap();
assert!(
bytes.is_empty(),
"GET empty must not invent a byte: {bytes:?}"
);
}

#[tokio::test]
async fn private_object_requires_auth() {
let bucket = unique_bucket("priv");
Expand Down