diff --git a/crates/server/src/routes/objects.rs b/crates/server/src/routes/objects.rs index a790f4c..1732377 100644 --- a/crates/server/src/routes/objects.rs +++ b/crates/server/src/routes/objects.rs @@ -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()) @@ -718,3 +718,40 @@ fn parse_range(headers: &HeaderMap, size: u64) -> Result, 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)); + } +} diff --git a/crates/server/tests/rest/objects.rs b/crates/server/tests/rest/objects.rs index 56c3eaf..316b8e7 100644 --- a/crates/server/tests/rest/objects.rs +++ b/crates/server/tests/rest/objects.rs @@ -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::::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");