mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 22:40:44 +01:00
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:
1 parent
da1d7444b8
commit
0c9872f842
5 files changed
+159
-8
No files matched your search
Generated
+1
@@ -4601,6 +4601,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"http-body 1.0.1",
|
||||
"httpdate",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user