diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/api/s3/copy.rs | 112 | ||||
-rw-r--r-- | src/api/s3/get.rs | 168 | ||||
-rw-r--r-- | src/garage/tests/s3/objects.rs | 126 |
3 files changed, 271 insertions, 135 deletions
diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index ff8019e6..a5b2d706 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -1,9 +1,9 @@ use std::pin::Pin; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures::{stream, stream::Stream, StreamExt, TryStreamExt}; use bytes::Bytes; +use http::header::HeaderName; use hyper::{Request, Response}; use serde::Serialize; @@ -26,11 +26,20 @@ use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; use crate::encryption::EncryptionParams; use crate::error::*; -use crate::get::full_object_byte_stream; +use crate::get::{full_object_byte_stream, PreconditionHeaders}; use crate::multipart; use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult}; use crate::xml::{self as s3_xml, xmlns_tag}; +pub const X_AMZ_COPY_SOURCE_IF_MATCH: HeaderName = + HeaderName::from_static("x-amz-copy-source-if-match"); +pub const X_AMZ_COPY_SOURCE_IF_NONE_MATCH: HeaderName = + HeaderName::from_static("x-amz-copy-source-if-none-match"); +pub const X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE: HeaderName = + HeaderName::from_static("x-amz-copy-source-if-modified-since"); +pub const X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE: HeaderName = + HeaderName::from_static("x-amz-copy-source-if-unmodified-since"); + // -------- CopyObject --------- pub async fn handle_copy( @@ -38,7 +47,7 @@ pub async fn handle_copy( req: &Request<ReqBody>, dest_key: &str, ) -> Result<Response<ResBody>, Error> { - let copy_precondition = CopyPreconditionHeaders::parse(req)?; + let copy_precondition = PreconditionHeaders::parse_copy_source(req)?; let checksum_algorithm = request_checksum_algorithm(req.headers())?; @@ -48,7 +57,7 @@ pub async fn handle_copy( extract_source_info(&source_object)?; // Check precondition, e.g. x-amz-copy-source-if-match - copy_precondition.check(source_version, &source_version_meta.etag)?; + copy_precondition.check_copy_source(source_version, &source_version_meta.etag)?; // Determine encryption parameters let (source_encryption, source_object_meta_inner) = @@ -335,7 +344,7 @@ pub async fn handle_upload_part_copy( part_number: u64, upload_id: &str, ) -> Result<Response<ResBody>, Error> { - let copy_precondition = CopyPreconditionHeaders::parse(req)?; + let copy_precondition = PreconditionHeaders::parse_copy_source(req)?; let dest_upload_id = multipart::decode_upload_id(upload_id)?; @@ -351,7 +360,7 @@ pub async fn handle_upload_part_copy( extract_source_info(&source_object)?; // Check precondition on source, e.g. x-amz-copy-source-if-match - copy_precondition.check(source_object_version, &source_version_meta.etag)?; + copy_precondition.check_copy_source(source_object_version, &source_version_meta.etag)?; // Determine encryption parameters let (source_encryption, _) = EncryptionParams::check_decrypt_for_copy_source( @@ -703,97 +712,6 @@ fn extract_source_info( Ok((source_version, source_version_data, source_version_meta)) } -struct CopyPreconditionHeaders { - copy_source_if_match: Option<Vec<String>>, - copy_source_if_modified_since: Option<SystemTime>, - copy_source_if_none_match: Option<Vec<String>>, - copy_source_if_unmodified_since: Option<SystemTime>, -} - -impl CopyPreconditionHeaders { - fn parse(req: &Request<ReqBody>) -> Result<Self, Error> { - Ok(Self { - copy_source_if_match: req - .headers() - .get("x-amz-copy-source-if-match") - .map(|x| x.to_str()) - .transpose()? - .map(|x| { - x.split(',') - .map(|m| m.trim().trim_matches('"').to_string()) - .collect::<Vec<_>>() - }), - copy_source_if_modified_since: req - .headers() - .get("x-amz-copy-source-if-modified-since") - .map(|x| x.to_str()) - .transpose()? - .map(httpdate::parse_http_date) - .transpose() - .ok_or_bad_request("Invalid date in x-amz-copy-source-if-modified-since")?, - copy_source_if_none_match: req - .headers() - .get("x-amz-copy-source-if-none-match") - .map(|x| x.to_str()) - .transpose()? - .map(|x| { - x.split(',') - .map(|m| m.trim().trim_matches('"').to_string()) - .collect::<Vec<_>>() - }), - copy_source_if_unmodified_since: req - .headers() - .get("x-amz-copy-source-if-unmodified-since") - .map(|x| x.to_str()) - .transpose()? - .map(httpdate::parse_http_date) - .transpose() - .ok_or_bad_request("Invalid date in x-amz-copy-source-if-unmodified-since")?, - }) - } - - fn check(&self, v: &ObjectVersion, etag: &str) -> Result<(), Error> { - let v_date = UNIX_EPOCH + Duration::from_millis(v.timestamp); - - let ok = match ( - &self.copy_source_if_match, - &self.copy_source_if_unmodified_since, - &self.copy_source_if_none_match, - &self.copy_source_if_modified_since, - ) { - // TODO I'm not sure all of the conditions are evaluated correctly here - - // If we have both if-match and if-unmodified-since, - // basically we don't care about if-unmodified-since, - // because in the spec it says that if if-match evaluates to - // true but if-unmodified-since evaluates to false, - // the copy is still done. - (Some(im), _, None, None) => im.iter().any(|x| x == etag || x == "*"), - (None, Some(ius), None, None) => v_date <= *ius, - - // If we have both if-none-match and if-modified-since, - // then both of the two conditions must evaluate to true - (None, None, Some(inm), Some(ims)) => { - !inm.iter().any(|x| x == etag || x == "*") && v_date > *ims - } - (None, None, Some(inm), None) => !inm.iter().any(|x| x == etag || x == "*"), - (None, None, None, Some(ims)) => v_date > *ims, - (None, None, None, None) => true, - _ => { - return Err(Error::bad_request( - "Invalid combination of x-amz-copy-source-if-xxxxx headers", - )) - } - }; - - if ok { - Ok(()) - } else { - Err(Error::PreconditionFailed) - } - } -} - type BlockStreamItemOk = (Bytes, Option<Hash>); type BlockStreamItem = Result<BlockStreamItemOk, garage_util::error::Error>; diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index bcb72cc3..22076603 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -2,15 +2,15 @@ use std::collections::BTreeMap; use std::convert::TryInto; use std::sync::Arc; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bytes::Bytes; use futures::future; use futures::stream::{self, Stream, StreamExt}; use http::header::{ - ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, - CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, EXPIRES, IF_MODIFIED_SINCE, IF_NONE_MATCH, - LAST_MODIFIED, RANGE, + HeaderMap, HeaderName, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, + CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, EXPIRES, IF_MATCH, + IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_UNMODIFIED_SINCE, LAST_MODIFIED, RANGE, }; use hyper::{Request, Response, StatusCode}; use tokio::sync::mpsc; @@ -29,10 +29,11 @@ use garage_api_common::helpers::*; use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE}; use crate::api_server::ResBody; +use crate::copy::*; use crate::encryption::EncryptionParams; use crate::error::*; -const X_AMZ_MP_PARTS_COUNT: &str = "x-amz-mp-parts-count"; +const X_AMZ_MP_PARTS_COUNT: HeaderName = HeaderName::from_static("x-amz-mp-parts-count"); #[derive(Default)] pub struct GetObjectOverrides { @@ -115,42 +116,22 @@ fn getobject_override_headers( Ok(()) } -fn try_answer_cached( +fn handle_http_precondition( version: &ObjectVersion, version_meta: &ObjectVersionMeta, req: &Request<()>, -) -> Option<Response<ResBody>> { - // <trinity> It is possible, and is even usually the case, [that both If-None-Match and - // If-Modified-Since] are present in a request. In this situation If-None-Match takes - // precedence and If-Modified-Since is ignored (as per 6.Precedence from rfc7232). The rational - // being that etag based matching is more accurate, it has no issue with sub-second precision - // for instance (in case of very fast updates) - let cached = if let Some(none_match) = req.headers().get(IF_NONE_MATCH) { - let none_match = none_match.to_str().ok()?; - let expected = format!("\"{}\"", version_meta.etag); - let found = none_match - .split(',') - .map(str::trim) - .any(|etag| etag == expected || etag == "\"*\""); - found - } else if let Some(modified_since) = req.headers().get(IF_MODIFIED_SINCE) { - let modified_since = modified_since.to_str().ok()?; - let client_date = httpdate::parse_http_date(modified_since).ok()?; - let server_date = UNIX_EPOCH + Duration::from_millis(version.timestamp); - client_date >= server_date - } else { - false - }; +) -> Result<Option<Response<ResBody>>, Error> { + let precondition_headers = PreconditionHeaders::parse(req)?; - if cached { - Some( + if let Some(status_code) = precondition_headers.check(&version, &version_meta.etag)? { + Ok(Some( Response::builder() - .status(StatusCode::NOT_MODIFIED) + .status(status_code) .body(empty_body()) .unwrap(), - ) + )) } else { - None + Ok(None) } } @@ -196,8 +177,8 @@ pub async fn handle_head_without_ctx( _ => unreachable!(), }; - if let Some(cached) = try_answer_cached(object_version, version_meta, req) { - return Ok(cached); + if let Some(res) = handle_http_precondition(object_version, version_meta, req)? { + return Ok(res); } let (encryption, headers) = @@ -318,8 +299,8 @@ pub async fn handle_get_without_ctx( ObjectVersionData::FirstBlock(meta, _) => meta, }; - if let Some(cached) = try_answer_cached(last_v, last_v_meta, req) { - return Ok(cached); + if let Some(res) = handle_http_precondition(last_v, last_v_meta, req)? { + return Ok(res); } let (enc, headers) = @@ -761,3 +742,116 @@ fn std_error_from_read_error<E: std::fmt::Display>(e: E) -> std::io::Error { format!("Error while reading object data: {}", e), ) } + +// ---- + +pub struct PreconditionHeaders { + if_match: Option<Vec<String>>, + if_modified_since: Option<SystemTime>, + if_none_match: Option<Vec<String>>, + if_unmodified_since: Option<SystemTime>, +} + +impl PreconditionHeaders { + fn parse<B>(req: &Request<B>) -> Result<Self, Error> { + Self::parse_with( + req.headers(), + &IF_MATCH, + &IF_NONE_MATCH, + &IF_MODIFIED_SINCE, + &IF_UNMODIFIED_SINCE, + ) + } + + pub(crate) fn parse_copy_source<B>(req: &Request<B>) -> Result<Self, Error> { + Self::parse_with( + req.headers(), + &X_AMZ_COPY_SOURCE_IF_MATCH, + &X_AMZ_COPY_SOURCE_IF_NONE_MATCH, + &X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE, + &X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE, + ) + } + + fn parse_with( + headers: &HeaderMap, + hdr_if_match: &HeaderName, + hdr_if_none_match: &HeaderName, + hdr_if_modified_since: &HeaderName, + hdr_if_unmodified_since: &HeaderName, + ) -> Result<Self, Error> { + Ok(Self { + if_match: headers + .get(hdr_if_match) + .map(|x| x.to_str()) + .transpose()? + .map(|x| { + x.split(',') + .map(|m| m.trim().trim_matches('"').to_string()) + .collect::<Vec<_>>() + }), + if_none_match: headers + .get(hdr_if_none_match) + .map(|x| x.to_str()) + .transpose()? + .map(|x| { + x.split(',') + .map(|m| m.trim().trim_matches('"').to_string()) + .collect::<Vec<_>>() + }), + if_modified_since: headers + .get(hdr_if_modified_since) + .map(|x| x.to_str()) + .transpose()? + .map(httpdate::parse_http_date) + .transpose() + .ok_or_bad_request("Invalid date in if-modified-since")?, + if_unmodified_since: headers + .get(hdr_if_unmodified_since) + .map(|x| x.to_str()) + .transpose()? + .map(httpdate::parse_http_date) + .transpose() + .ok_or_bad_request("Invalid date in if-unmodified-since")?, + }) + } + + fn check(&self, v: &ObjectVersion, etag: &str) -> Result<Option<StatusCode>, Error> { + let v_date = UNIX_EPOCH + Duration::from_millis(v.timestamp); + + // Implemented from https://datatracker.ietf.org/doc/html/rfc7232#section-6 + + if let Some(im) = &self.if_match { + // Step 1: if-match is present + if !im.iter().any(|x| x == etag || x == "*") { + return Ok(Some(StatusCode::PRECONDITION_FAILED)); + } + } else if let Some(ius) = &self.if_unmodified_since { + // Step 2: if-unmodified-since is present, and if-match is absent + if v_date > *ius { + return Ok(Some(StatusCode::PRECONDITION_FAILED)); + } + } + + if let Some(inm) = &self.if_none_match { + // Step 3: if-none-match is present + if inm.iter().any(|x| x == etag || x == "*") { + return Ok(Some(StatusCode::NOT_MODIFIED)); + } + } else if let Some(ims) = &self.if_modified_since { + // Step 4: if-modified-since is present, and if-none-match is absent + if v_date <= *ims { + return Ok(Some(StatusCode::NOT_MODIFIED)); + } + } + + Ok(None) + } + + pub(crate) fn check_copy_source(&self, v: &ObjectVersion, etag: &str) -> Result<(), Error> { + match self.check(v, etag)? { + Some(_) => Err(Error::PreconditionFailed), + None => Ok(()), + } + } +} diff --git a/src/garage/tests/s3/objects.rs b/src/garage/tests/s3/objects.rs index dfc5253d..d63ac000 100644 --- a/src/garage/tests/s3/objects.rs +++ b/src/garage/tests/s3/objects.rs @@ -1,5 +1,6 @@ use crate::common; -use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::error::SdkError; +use aws_sdk_s3::primitives::{ByteStream, DateTime}; use aws_sdk_s3::types::{Delete, ObjectIdentifier}; const STD_KEY: &str = "hello world"; @@ -126,6 +127,129 @@ async fn test_putobject() { } #[tokio::test] +async fn test_precondition() { + let ctx = common::context(); + let bucket = ctx.create_bucket("precondition"); + + let etag = "\"46cf18a9b447991b450cad3facf5937e\""; + let etag2 = "\"ae4984b984cd984fe98d4efa954dce98\""; + let data = ByteStream::from_static(BODY); + + let r = ctx + .client + .put_object() + .bucket(&bucket) + .key(STD_KEY) + .body(data) + .send() + .await + .unwrap(); + + assert_eq!(r.e_tag.unwrap().as_str(), etag); + + let last_modified; + { + let o = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_match(etag) + .send() + .await + .unwrap(); + assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag); + last_modified = o.last_modified.unwrap(); + + let err = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_match(etag2) + .send() + .await; + assert!( + matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 412) + ); + } + { + let o = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_none_match(etag2) + .send() + .await + .unwrap(); + assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag); + + let err = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_none_match(etag) + .send() + .await; + assert!( + matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304) + ); + } + let older_date = DateTime::from_secs_f64(last_modified.as_secs_f64() - 10.0); + let newer_date = DateTime::from_secs_f64(last_modified.as_secs_f64() + 10.0); + { + let err = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_modified_since(newer_date) + .send() + .await; + assert!( + matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304) + ); + + let o = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_modified_since(older_date) + .send() + .await + .unwrap(); + assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag); + } + { + let err = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_unmodified_since(older_date) + .send() + .await; + assert!( + matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 412) + ); + + let o = ctx + .client + .get_object() + .bucket(&bucket) + .key(STD_KEY) + .if_unmodified_since(newer_date) + .send() + .await + .unwrap(); + assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag); + } +} + +#[tokio::test] async fn test_getobject() { let ctx = common::context(); let bucket = ctx.create_bucket("getobject"); |