11//! Utilities related to parsing responses to partial requests.
22
3- use reqwest:: header;
43use reqwest:: header:: HeaderValue ;
54use reqwest:: StatusCode ;
5+ use reqwest:: { header, Response } ;
6+
7+ pub ( super ) enum PartialResponse {
8+ /// Server returned a partial content response starting at given position
9+ PartialContent ( u64 ) ,
10+
11+ /// Server returned regular OK response, resume writing at 0
12+ CompleteContent ,
13+
14+ /// Server returned partial content but resource was modified, request needs to be retried
15+ ResourceModified ,
16+ }
617
718/// Returns the position of the partial response in the resource.
819///
@@ -11,26 +22,77 @@ use reqwest::StatusCode;
1122/// Content-Range header. The server could also just ignore the Range header and
1223/// respond with 200 OK, in which case we need to download the entire resource
1324/// all over again.
14- pub fn response_range_start ( response : & reqwest:: Response ) -> Result < u64 , InvalidResponseError > {
15- let chunk_pos = match response. status ( ) {
25+ pub ( super ) fn response_range_start (
26+ response : & reqwest:: Response ,
27+ prev_response : & Response ,
28+ ) -> Result < PartialResponse , InvalidResponseError > {
29+ match response. status ( ) {
1630 // Complete response, seek to the beginning of the file
17- StatusCode :: OK => 0 ,
31+ StatusCode :: OK => Ok ( PartialResponse :: CompleteContent ) ,
1832
1933 // Partial response, the range might be different from what we
2034 // requested, so we need to parse it. Because we only request a single
2135 // range from the current position to the end of the document, we can
2236 // ignore multipart/byteranges media type.
23- StatusCode :: PARTIAL_CONTENT => partial_response_start_range ( response) ?,
37+ StatusCode :: PARTIAL_CONTENT => {
38+ if was_resource_modified ( response, prev_response) {
39+ return Ok ( PartialResponse :: ResourceModified ) ;
40+ }
41+ let pos = partial_response_start_range ( response) ?;
42+ Ok ( PartialResponse :: PartialContent ( pos) )
43+ }
2444
2545 // We don't expect to receive any other 200-299 status code, but if we
2646 // do, treat it the same as OK
27- status_code if status_code. is_success ( ) => 0 ,
47+ status_code if status_code. is_success ( ) => Ok ( PartialResponse :: CompleteContent ) ,
48+
49+ status_code => Err ( InvalidResponseError :: UnexpectedStatus ( status_code) ) ,
50+ }
51+ }
52+
53+ /// Checks if the resource was modified between the current and previous response.
54+ ///
55+ /// If the resource was updated, we should restart download and request full range of the new
56+ /// resource. Otherwise, a partial request can be used to resume the download.
57+ fn was_resource_modified ( response : & Response , prev_response : & Response ) -> bool {
58+ if response. status ( ) != hyper:: StatusCode :: PARTIAL_CONTENT {
59+ // not using a partial request, don't care if it's modified or not
60+ return false ;
61+ }
62+
63+ // etags in current and previous request must match
64+ let etag = response
65+ . headers ( )
66+ . get ( header:: ETAG )
67+ . and_then ( |h| h. to_str ( ) . ok ( ) ) ;
68+ let prev_etag = prev_response
69+ . headers ( )
70+ . get ( header:: ETAG )
71+ . and_then ( |h| h. to_str ( ) . ok ( ) ) ;
2872
29- status_code => {
30- return Err ( InvalidResponseError :: UnexpectedStatus ( status_code) ) ;
73+ match ( etag, prev_etag) {
74+ ( None , None ) => {
75+ // no etags in either request, assume resource is unchanged
76+ false
3177 }
32- } ;
33- Ok ( chunk_pos)
78+ ( None , Some ( _) ) | ( Some ( _) , None ) => {
79+ // previous request didn't have etag and this does or vice versa, abort
80+ true
81+ }
82+ ( Some ( etag) , Some ( prev_etag) ) => {
83+ // Examples:
84+ // ETag: "xyzzy"
85+ // ETag: W/"xyzzy"
86+ // ETag: ""
87+ if etag. starts_with ( "W/" ) {
88+ // validator is weak, but in range requests tags must match using strong comparison
89+ // https://www.rfc-editor.org/rfc/rfc9110#entity.tag.comparison
90+ return true ;
91+ }
92+
93+ etag != prev_etag
94+ }
95+ }
3496}
3597
3698#[ derive( Debug , thiserror:: Error ) ]
@@ -88,3 +150,31 @@ pub struct ContentRangeParseError {
88150 reason : & ' static str ,
89151 value : header:: HeaderValue ,
90152}
153+
154+ #[ cfg( test) ]
155+ mod tests {
156+ use super :: * ;
157+
158+ #[ test_case:: test_case( Some ( r#""xyzzy""# ) , Some ( r#""xyzzy""# ) , false ) ]
159+ #[ test_case:: test_case( Some ( r#"W/"xyzzy""# ) , Some ( r#""xyzzy""# ) , true ) ]
160+ #[ test_case:: test_case( Some ( r#""xyzzy""# ) , Some ( r#"W/"xyzzy""# ) , true ) ]
161+ #[ test_case:: test_case( Some ( r#""xyzzy1""# ) , Some ( r#""xyzzy2""# ) , true ) ]
162+ #[ test_case:: test_case( None , None , false ) ]
163+ #[ test_case:: test_case( Some ( r#""xyzzy1""# ) , None , true ) ]
164+ #[ test_case:: test_case( None , Some ( r#""xyzzy2""# ) , true ) ]
165+ fn verifies_etags ( etag1 : Option < & ' static str > , etag2 : Option < & ' static str > , modified : bool ) {
166+ let mut response1 = http:: Response :: builder ( ) . status ( StatusCode :: PARTIAL_CONTENT ) ;
167+ if let Some ( etag) = etag1 {
168+ response1 = response1. header ( http:: header:: ETAG , etag) ;
169+ }
170+ let response1 = response1. body ( "" ) . unwrap ( ) . into ( ) ;
171+
172+ let mut response2 = http:: Response :: builder ( ) . status ( StatusCode :: PARTIAL_CONTENT ) ;
173+ if let Some ( etag) = etag2 {
174+ response2 = response2. header ( http:: header:: ETAG , etag) ;
175+ }
176+ let response2 = response2. body ( "" ) . unwrap ( ) . into ( ) ;
177+
178+ assert_eq ! ( was_resource_modified( & response1, & response2) , modified) ;
179+ }
180+ }
0 commit comments