@@ -118,22 +118,7 @@ enum UrlExpiry {
118118 Expired ( DateTime < Utc > ) ,
119119}
120120
121- fn parse_url_expiry ( url : & str , now : DateTime < Utc > ) -> UrlExpiry {
122- let Ok ( parsed) = Url :: parse ( url) else {
123- return UrlExpiry :: Missing ;
124- } ;
125- let Some ( value) = parsed
126- . query_pairs ( )
127- . find_map ( |( key, value) | ( key == "Expires" ) . then_some ( value) )
128- else {
129- return UrlExpiry :: Missing ;
130- } ;
131- let Ok ( timestamp) = value. parse :: < i64 > ( ) else {
132- return UrlExpiry :: Malformed ;
133- } ;
134- let Some ( expires_at) = DateTime :: from_timestamp ( timestamp, 0 ) else {
135- return UrlExpiry :: Malformed ;
136- } ;
121+ fn classify_url_expiry ( expires_at : DateTime < Utc > , now : DateTime < Utc > ) -> UrlExpiry {
137122 let confidently_expired = expires_at
138123 . checked_add_signed ( chrono:: Duration :: seconds ( CLOCK_SKEW_TOLERANCE_SECS ) )
139124 . is_some_and ( |deadline| deadline < now) ;
@@ -145,6 +130,72 @@ fn parse_url_expiry(url: &str, now: DateTime<Utc>) -> UrlExpiry {
145130 }
146131}
147132
133+ fn parse_relative_url_expiry (
134+ parsed : & Url ,
135+ date_key : & str ,
136+ lifetime_key : & str ,
137+ now : DateTime < Utc > ,
138+ ) -> UrlExpiry {
139+ let signed_at = parsed
140+ . query_pairs ( )
141+ . find_map ( |( key, value) | ( key == date_key) . then_some ( value) ) ;
142+ let lifetime = parsed
143+ . query_pairs ( )
144+ . find_map ( |( key, value) | ( key == lifetime_key) . then_some ( value) ) ;
145+ match ( signed_at, lifetime) {
146+ ( None , None ) => UrlExpiry :: Missing ,
147+ ( Some ( signed_at) , Some ( lifetime) ) => {
148+ let Ok ( signed_at) = chrono:: NaiveDateTime :: parse_from_str ( & signed_at, "%Y%m%dT%H%M%SZ" )
149+ else {
150+ return UrlExpiry :: Malformed ;
151+ } ;
152+ let Ok ( lifetime_seconds) = lifetime. parse :: < i64 > ( ) else {
153+ return UrlExpiry :: Malformed ;
154+ } ;
155+ if lifetime_seconds < 0 {
156+ return UrlExpiry :: Malformed ;
157+ }
158+ let Some ( lifetime) = chrono:: Duration :: try_seconds ( lifetime_seconds) else {
159+ return UrlExpiry :: Malformed ;
160+ } ;
161+ let Some ( expires_at) = signed_at. and_utc ( ) . checked_add_signed ( lifetime) else {
162+ return UrlExpiry :: Malformed ;
163+ } ;
164+ classify_url_expiry ( expires_at, now)
165+ }
166+ _ => UrlExpiry :: Malformed ,
167+ }
168+ }
169+
170+ fn parse_url_expiry ( url : & str , now : DateTime < Utc > ) -> UrlExpiry {
171+ let Ok ( parsed) = Url :: parse ( url) else {
172+ return UrlExpiry :: Missing ;
173+ } ;
174+ if let Some ( value) = parsed
175+ . query_pairs ( )
176+ . find_map ( |( key, value) | ( key == "Expires" ) . then_some ( value) )
177+ {
178+ let Ok ( timestamp) = value. parse :: < i64 > ( ) else {
179+ return UrlExpiry :: Malformed ;
180+ } ;
181+ let Some ( expires_at) = DateTime :: from_timestamp ( timestamp, 0 ) else {
182+ return UrlExpiry :: Malformed ;
183+ } ;
184+ return classify_url_expiry ( expires_at, now) ;
185+ }
186+
187+ for ( date_key, lifetime_key) in [
188+ ( "X-Goog-Date" , "X-Goog-Expires" ) ,
189+ ( "X-Amz-Date" , "X-Amz-Expires" ) ,
190+ ] {
191+ let expiry = parse_relative_url_expiry ( & parsed, date_key, lifetime_key, now) ;
192+ if expiry != UrlExpiry :: Missing {
193+ return expiry;
194+ }
195+ }
196+ UrlExpiry :: Missing
197+ }
198+
148199#[ derive( Debug , Error , Clone , Copy , PartialEq , Eq ) ]
149200enum UrlRejection {
150201 #[ error( "download URL is malformed" ) ]
@@ -1059,6 +1110,99 @@ mod tests {
10591110 ) ;
10601111 }
10611112
1113+ #[ test]
1114+ fn parses_gcs_v4_expiry ( ) {
1115+ let now = DateTime :: parse_from_rfc3339 ( "2026-02-01T19:45:00Z" )
1116+ . unwrap ( )
1117+ . with_timezone ( & Utc ) ;
1118+ let expired_at = DateTime :: parse_from_rfc3339 ( "2026-02-01T19:40:47Z" )
1119+ . unwrap ( )
1120+ . with_timezone ( & Utc ) ;
1121+
1122+ assert_eq ! (
1123+ parse_url_expiry(
1124+ "https://storage.googleapis.com/bucket/image.jpg?X-Goog-Date=20260125T194047Z&X-Goog-Expires=604800&X-Goog-Signature=secret" ,
1125+ now,
1126+ ) ,
1127+ UrlExpiry :: Expired ( expired_at)
1128+ ) ;
1129+ assert_eq ! (
1130+ parse_url_expiry(
1131+ "https://storage.googleapis.com/bucket/image.jpg?X-Goog-Expires=604800&X-Goog-Date=20260125T195000Z" ,
1132+ now,
1133+ ) ,
1134+ UrlExpiry :: Active (
1135+ DateTime :: parse_from_rfc3339( "2026-02-01T19:50:00Z" )
1136+ . unwrap( )
1137+ . with_timezone( & Utc )
1138+ )
1139+ ) ;
1140+ assert_eq ! (
1141+ parse_url_expiry(
1142+ "https://storage.googleapis.com/bucket/image.jpg?X-Goog-Date=invalid&X-Goog-Expires=604800" ,
1143+ now,
1144+ ) ,
1145+ UrlExpiry :: Malformed
1146+ ) ;
1147+ assert_eq ! (
1148+ parse_url_expiry(
1149+ "https://storage.googleapis.com/bucket/image.jpg?X-Goog-Date=20260125T194047Z" ,
1150+ now,
1151+ ) ,
1152+ UrlExpiry :: Malformed
1153+ ) ;
1154+ assert_eq ! (
1155+ parse_url_expiry(
1156+ "https://storage.googleapis.com/bucket/image.jpg?X-Goog-Date=20260125T194047Z&X-Goog-Expires=-1" ,
1157+ now,
1158+ ) ,
1159+ UrlExpiry :: Malformed
1160+ ) ;
1161+ }
1162+
1163+ #[ test]
1164+ fn parses_aws_v4_expiry ( ) {
1165+ let now = DateTime :: parse_from_rfc3339 ( "2013-05-25T00:03:00Z" )
1166+ . unwrap ( )
1167+ . with_timezone ( & Utc ) ;
1168+ let expired_at = DateTime :: parse_from_rfc3339 ( "2013-05-25T00:00:00Z" )
1169+ . unwrap ( )
1170+ . with_timezone ( & Utc ) ;
1171+
1172+ assert_eq ! (
1173+ parse_url_expiry(
1174+ "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-Signature=secret" ,
1175+ now,
1176+ ) ,
1177+ UrlExpiry :: Expired ( expired_at)
1178+ ) ;
1179+ assert_eq ! (
1180+ parse_url_expiry(
1181+ "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Expires=86400&X-Amz-Date=20130524T120000Z" ,
1182+ now,
1183+ ) ,
1184+ UrlExpiry :: Active (
1185+ DateTime :: parse_from_rfc3339( "2013-05-25T12:00:00Z" )
1186+ . unwrap( )
1187+ . with_timezone( & Utc )
1188+ )
1189+ ) ;
1190+ assert_eq ! (
1191+ parse_url_expiry(
1192+ "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Date=invalid&X-Amz-Expires=86400" ,
1193+ now,
1194+ ) ,
1195+ UrlExpiry :: Malformed
1196+ ) ;
1197+ assert_eq ! (
1198+ parse_url_expiry(
1199+ "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Date=20130524T000000Z" ,
1200+ now,
1201+ ) ,
1202+ UrlExpiry :: Malformed
1203+ ) ;
1204+ }
1205+
10621206 #[ test]
10631207 fn aggregates_failures_with_safe_bounded_examples ( ) {
10641208 let mut summary = FailureSummary :: default ( ) ;
0 commit comments