From a03d9209bff1f02faace971dac8294a874d55b8b Mon Sep 17 00:00:00 2001 From: Paulo Cabral Sanz Date: Sat, 15 Aug 2026 01:13:39 -0300 Subject: [PATCH 1/2] test: GET of a 0-byte object must not ask mmap for 1 byte Inclusive-end on size=0 is length 1. HEAD already advertises 0. Darwin mmap fails with EINVAL (500); Linux returns a phantom 0x00. --- crates/server/src/routes/objects.rs | 61 +++++++++++++++++++++++------ crates/server/tests/rest/objects.rs | 59 ++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/crates/server/src/routes/objects.rs b/crates/server/src/routes/objects.rs index a790f4c..d612207 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`]. +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 => { + let end_inclusive = size.saturating_sub(1); + (0, end_inclusive.saturating_add(1)) + } + } +} + +#[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"); From 26c4f8a8cd11d3c1b74448ccc74b5b10ae5b0feb Mon Sep 17 00:00:00 2001 From: Paulo Cabral Sanz Date: Sat, 15 Aug 2026 01:13:50 -0300 Subject: [PATCH 2/2] fix: empty REST GET is length 0, not mmap(1) Full GET uses the exclusive window (0, size), matching S3 GetObject and HEAD Content-Length. size=0 now takes Body::empty() instead of mmap past EOF. --- crates/server/src/routes/objects.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/server/src/routes/objects.rs b/crates/server/src/routes/objects.rs index d612207..1732377 100644 --- a/crates/server/src/routes/objects.rs +++ b/crates/server/src/routes/objects.rs @@ -721,14 +721,14 @@ fn parse_range(headers: &HeaderMap, size: u64) -> Result, Api /// Byte window for a GET: `(start, length)`. /// -/// `range` is the inclusive `(start, end)` from [`parse_range`]. +/// `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 => { - let end_inclusive = size.saturating_sub(1); - (0, end_inclusive.saturating_add(1)) - } + None => (0, size), } }