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 <chrislusf@users.noreply.github.com>
This commit is contained in:
Jay HemnaniandChris Lu authored and GitHub committed 2026-10-25 06:36:40 -07:00
1 parent da1d7444b8
commit 0c9872f842
5 files changed
+159 -8

No files matched your search

+1
View File
@@ -4601,6 +4601,7 @@ dependencies = [
"futures",
"hex",
"http-body 1.0.1",
"httpdate",
"hyper",
"hyper-util",
"image",
+1
View File
@@ -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"] }
+90 -8
View File
@@ -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<String>) -> 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)]
+25
View File
@@ -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)
+42
View File
@@ -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).