From 0c9872f842b32e84d7f80ea6eed0f9d6086e89a2 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:06:40 +0530 Subject: [PATCH] filer, volume: ignore Range when If-Range no longer matches (#12052) * filer, volume: ignore Range when If-Range no longer matches A GET with Range and a stale If-Range validator got 206 with the requested bytes of the current file. RFC 9110 13.1.5: send the whole content instead, so a resumed download restarts rather than mixing two versions. * volume(rs): drop Range when If-Range no longer matches Mirror the Go fix for the Rust volume server: evaluate If-Range against the response ETag (strong comparison) or Last-Modified once the needle's validators are known, then drop the range on a stale validator so the whole current representation is sent. A dropped range on the RangeFromSource strategy falls back to a full stream of the data-file region; buffered and chunk-manifest paths already take Option<&str>. * volume(rs): fail If-Range when the header value is not readable * volume(rs): accept all HTTP-date formats in If-Range like Go * server: cover alternate HTTP-date formats in If-Range test --------- Co-authored-by: Chris Lu --- seaweed-volume/Cargo.lock | 1 + seaweed-volume/Cargo.toml | 1 + seaweed-volume/src/server/handlers.rs | 98 ++++++++++++++++++++++++--- weed/server/common.go | 25 +++++++ weed/server/common_test.go | 42 ++++++++++++ 5 files changed, 159 insertions(+), 8 deletions(-) diff --git a/seaweed-volume/Cargo.lock b/seaweed-volume/Cargo.lock index afabe1c8aa..93b7f78f71 100644 --- a/seaweed-volume/Cargo.lock +++ b/seaweed-volume/Cargo.lock @@ -4601,6 +4601,7 @@ dependencies = [ "futures", "hex", "http-body 1.0.1", + "httpdate", "hyper", "hyper-util", "image", diff --git a/seaweed-volume/Cargo.toml b/seaweed-volume/Cargo.toml index 29c37d4ff3..960b256e1d 100644 --- a/seaweed-volume/Cargo.toml +++ b/seaweed-volume/Cargo.toml @@ -51,6 +51,7 @@ prost-types = "0.14" # HTTP server axum = { version = "0.8", features = ["multipart"] } http-body = "1" +httpdate = "1" hyper = { version = "1", features = ["full"] } hyper-util = { version = "0.1", features = ["tokio", "service", "server-auto", "http1", "http2"] } tower = { version = "0.5", features = ["util"] } diff --git a/seaweed-volume/src/server/handlers.rs b/seaweed-volume/src/server/handlers.rs index 6826d4f3f1..25bf8dfc3d 100644 --- a/seaweed-volume/src/server/handlers.rs +++ b/seaweed-volume/src/server/handlers.rs @@ -1120,6 +1120,10 @@ async fn get_or_head_handler_inner( }; let (etag, last_modified_str) = etag_and_last_modified(&n); + // A Range whose If-Range no longer matches is ignored and the whole + // content is sent, so a resumed download restarts instead of mixing + // two versions. + let range = range.filter(|_| if_range_matches(&headers, &etag, &last_modified_str)); if let Some(resp) = not_modified_response(&n, &headers, &etag, &last_modified_str) { // A request that ends here was answered by the meta read alone. if let Some(io_errors) = strategy.io_errors() { @@ -1167,14 +1171,20 @@ async fn get_or_head_handler_inner( return head_from_meta_response(&info, response_headers); } ReadStrategy::RangeFromSource(info) => { - return range_from_source_response( - &state, - range.as_deref().unwrap_or_default(), - info, - response_headers, - track_download, - ) - .await; + return match range.as_deref() { + Some(range) => { + range_from_source_response( + &state, + range, + info, + response_headers, + track_download, + ) + .await + } + // If-Range dropped the range; serve the whole content. + None => stream_response(&state, info, response_headers, track_download), + }; } ReadStrategy::Buffered => {} } @@ -1557,6 +1567,31 @@ fn not_modified_response( None } +/// If-Range (RFC 9110 13.1.5): an entity-tag by strong comparison, or a date +/// equal to Last-Modified. The response etag here is always a strong tag. +fn if_range_matches(headers: &HeaderMap, etag: &str, last_modified_str: &Option) -> bool { + let Some(raw) = headers.get(header::IF_RANGE) else { + return true; + }; + let Ok(if_range) = raw.to_str() else { + return false; + }; + let if_range = if_range.trim(); + if if_range.is_empty() { + return true; + } + if if_range.starts_with('"') || if_range.starts_with("W/") { + return if_range == etag; + } + let Ok(date) = httpdate::parse_http_date(if_range) else { + return false; + }; + last_modified_str + .as_deref() + .and_then(|lm| httpdate::parse_http_date(lm).ok()) + .is_some_and(|lm| date == lm) +} + /// Weak comparison against "*" or a comma-separated entity-tag list (RFC 9110 13.1.2). fn etag_list_matches(header: &str, etag: &str) -> bool { if header.trim() == "*" { @@ -5767,6 +5802,53 @@ mod tests { assert!(resp.headers().contains_key(header::LAST_MODIFIED)); } + #[test] + fn test_if_range_matches() { + let etag = "\"v2\"".to_string(); + let last_modified_str = Some("Sat, 24 Oct 2026 22:01:04 GMT".to_string()); + let cases: Vec<(Option<&str>, bool)> = vec![ + (None, true), + (Some("\"v2\""), true), + (Some("Sat, 24 Oct 2026 22:01:04 GMT"), true), + (Some("Saturday, 24-Oct-26 22:01:04 GMT"), true), + (Some("Sat Oct 24 22:01:04 2026"), true), + (Some("\"v1\""), false), + (Some("W/\"v2\""), false), + (Some("Sat, 24 Oct 2026 22:01:03 GMT"), false), + (Some("not a date"), false), + ]; + for (if_range, want) in cases { + let mut headers = HeaderMap::new(); + if let Some(v) = if_range { + headers.insert(header::IF_RANGE, v.parse().unwrap()); + } + assert_eq!( + if_range_matches(&headers, &etag, &last_modified_str), + want, + "{if_range:?}" + ); + } + + // A present value that is not valid UTF-8 fails the condition. + let mut headers = HeaderMap::new(); + headers.insert( + header::IF_RANGE, + header::HeaderValue::from_bytes(b"\"\xff\xfev2\"").unwrap(), + ); + assert!(!if_range_matches(&headers, &etag, &last_modified_str)); + + // No Last-Modified on the representation: a date validator fails, + // an entity-tag still applies. + let mut headers = HeaderMap::new(); + headers.insert(header::IF_RANGE, "\"v2\"".parse().unwrap()); + assert!(if_range_matches(&headers, &etag, &None)); + headers.insert( + header::IF_RANGE, + "Sat, 24 Oct 2026 22:01:04 GMT".parse().unwrap(), + ); + assert!(!if_range_matches(&headers, &etag, &None)); + } + /// A GET whose needle read is parked must not hold the store lock: a /// writer, here a real append to the same volume, must get through. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/weed/server/common.go b/weed/server/common.go index 2749b4f3f7..bd2901cd91 100644 --- a/weed/server/common.go +++ b/weed/server/common.go @@ -313,8 +313,33 @@ func (t *responseBodyTracker) Write(p []byte) (int, error) { return n, err } +// ifRangeMatches reports whether an If-Range validator still names the +// representation described by header (RFC 9110 13.1.5): an entity tag by +// strong comparison, or a date equal to Last-Modified. +func ifRangeMatches(ifRange string, header http.Header) bool { + ifRange = strings.TrimSpace(ifRange) + if ifRange == "" { + return true + } + if strings.HasPrefix(ifRange, "\"") || strings.HasPrefix(ifRange, "W/") { + etag := header.Get("ETag") + return etag != "" && !strings.HasPrefix(etag, "W/") && ifRange == etag + } + date, err := http.ParseTime(ifRange) + if err != nil { + return false + } + lastModified, err := http.ParseTime(header.Get("Last-Modified")) + return err == nil && date.Equal(lastModified) +} + func ProcessRangeRequest(r *http.Request, w http.ResponseWriter, totalSize int64, mimeType string, prepareWriteFn func(offset int64, size int64) (filer.DoStreamContent, error)) error { rangeReq := r.Header.Get("Range") + // A Range whose If-Range no longer matches is ignored and the whole content + // is sent, so a resumed download restarts instead of mixing two versions. + if rangeReq != "" && !ifRangeMatches(r.Header.Get("If-Range"), w.Header()) { + rangeReq = "" + } sink := &responseBodyTracker{ResponseWriter: w} bufferedWriter := writePool.Get().(*bufio.Writer) bufferedWriter.Reset(sink) diff --git a/weed/server/common_test.go b/weed/server/common_test.go index caf960745f..2d60a89326 100644 --- a/weed/server/common_test.go +++ b/weed/server/common_test.go @@ -343,6 +343,48 @@ func TestProcessRangeRequestRanges(t *testing.T) { } } +func TestProcessRangeRequestIfRange(t *testing.T) { + data := []byte("0123456789") + const etag = `"v2"` + const lastModified = "Sat, 24 Oct 2026 22:01:04 GMT" + tests := []struct { + ifRange string + wantCode int + wantBody string + }{ + {"", http.StatusPartialContent, "01"}, + {etag, http.StatusPartialContent, "01"}, + {lastModified, http.StatusPartialContent, "01"}, + {"Saturday, 24-Oct-26 22:01:04 GMT", http.StatusPartialContent, "01"}, + {"Sat Oct 24 22:01:04 2026", http.StatusPartialContent, "01"}, + {`"v1"`, http.StatusOK, "0123456789"}, + {`W/"v2"`, http.StatusOK, "0123456789"}, + {"Sat, 24 Oct 2026 22:01:03 GMT", http.StatusOK, "0123456789"}, + {"not a date", http.StatusOK, "0123456789"}, + } + for _, tt := range tests { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test.txt", nil) + r.Header.Set("Range", "bytes=0-1") + if tt.ifRange != "" { + r.Header.Set("If-Range", tt.ifRange) + } + w := httptest.NewRecorder() + w.Header().Set("ETag", etag) + w.Header().Set("Last-Modified", lastModified) + if err := ProcessRangeRequest(r, w, int64(len(data)), "text/plain", func(offset int64, size int64) (filer.DoStreamContent, error) { + return func(writer io.Writer) error { + _, err := writer.Write(data[offset : offset+size]) + return err + }, nil + }); err != nil { + t.Errorf("If-Range %q: %v", tt.ifRange, err) + } + if w.Code != tt.wantCode || w.Body.String() != tt.wantBody { + t.Errorf("If-Range %q: got %d %q, want %d %q", tt.ifRange, w.Code, w.Body.String(), tt.wantCode, tt.wantBody) + } + } +} + // /submit must reject at the limit its master was given, not at a hardcoded // 256MB: weed server hands -volume.fileSizeLimitMB down to the master, and an // upload the volume server would store must not be turned away here (#6748).