diff --git a/README.md b/README.md index 053f5fb..c83d846 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ More examples can be found in the examples directory on GitHub. * [`HeadObject`][headobject] * [`GetObject`][getobject] * [`PutObject`][putobject] + * [`CopyObject`][copyobject] * [`DeleteObject`][deleteobject] * [`DeleteObjects`][deleteobjects] * [`ListObjectsV2`][listobjectsv2] @@ -67,6 +68,7 @@ More examples can be found in the examples directory on GitHub. [createbucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html [deletebucket]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html [createmultipart]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html +[copyobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html [deleteobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html [deleteobjects]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html [getobject]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html diff --git a/src/actions/copy_object.rs b/src/actions/copy_object.rs new file mode 100644 index 0000000..56f636e --- /dev/null +++ b/src/actions/copy_object.rs @@ -0,0 +1,164 @@ +use std::borrow::{Borrow, Cow}; +use std::iter; +use std::time::Duration; + +use time::OffsetDateTime; +use url::Url; + +use super::S3Action; +use crate::actions::Method; +use crate::signing::sign; +use crate::sorting_iter::SortingIterator; +use crate::{Bucket, Credentials, Map}; + +/// Create a copy of an object that is already stored in S3, using a `PUT` request. +/// +/// Note that: +/// * only objects up to 5 GB can be copied using this method +/// * even if the server returns a 200 response the copy might have failed +/// +/// Find out more about CopyObject from the [AWS API Reference][api] +/// +/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html +#[derive(Debug, Clone)] +pub struct CopyObject<'a> { + bucket: &'a Bucket, + credentials: Option<&'a Credentials>, + src_object: &'a str, + dst_object: &'a str, + prepend_bucket: bool, + + query: Map<'a>, + headers: Map<'a>, +} + +impl<'a> CopyObject<'a> { + #[inline] + pub fn new( + bucket: &'a Bucket, + credentials: Option<&'a Credentials>, + src_object: &'a str, + dst_object: &'a str, + prepend_bucket: bool, + ) -> Self { + Self { + bucket, + credentials, + src_object, + dst_object, + prepend_bucket, + + query: Map::new(), + headers: Map::new(), + } + } +} + +impl<'a> S3Action<'a> for CopyObject<'a> { + const METHOD: Method = Method::Put; + + fn sign(&self, expires_in: Duration) -> Url { + let now = OffsetDateTime::now_utc(); + self.sign_with_time(expires_in, &now) + } + + fn query_mut(&mut self) -> &mut Map<'a> { + &mut self.query + } + + fn headers_mut(&mut self) -> &mut Map<'a> { + &mut self.headers + } + + fn sign_with_time(&self, expires_in: Duration, time: &OffsetDateTime) -> Url { + let url = self.bucket.object_url(self.dst_object).unwrap(); + let copy_source = if self.prepend_bucket { + Cow::from(format!("{}/{}", self.bucket.name(), self.src_object)) + } else { + Cow::from(self.src_object) + }; + let query = SortingIterator::new( + iter::once(("x-amz-copy-source", copy_source.borrow())), + self.query.iter(), + ); + + match self.credentials { + Some(credentials) => sign( + time, + Method::Put, + url, + credentials.key(), + credentials.secret(), + credentials.token(), + self.bucket.region(), + expires_in.as_secs(), + query, + self.headers.iter(), + ), + None => crate::signing::util::add_query_params(url, query), + } + } +} + +#[cfg(test)] +mod tests { + use time::OffsetDateTime; + + use pretty_assertions::assert_eq; + + use super::*; + use crate::{Bucket, Credentials, UrlStyle}; + + #[test] + fn aws_example() { + // Fri, 24 May 2013 00:00:00 GMT + let date = OffsetDateTime::from_unix_timestamp(1369353600).unwrap(); + let expires_in = Duration::from_secs(86400); + + let endpoint = "https://s3.amazonaws.com".parse().unwrap(); + let bucket = Bucket::new( + endpoint, + UrlStyle::VirtualHost, + "examplebucket", + "us-east-1", + ) + .unwrap(); + let credentials = Credentials::new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ); + + let action = CopyObject::new( + &bucket, + Some(&credentials), + "test.txt", + "test_copy.txt", + true, + ); + + let url = action.sign_with_time(expires_in, &date); + let expected = "https://examplebucket.s3.amazonaws.com/test_copy.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&x-amz-copy-source=examplebucket%2Ftest.txt&X-Amz-Signature=760326dbb90c424f6b5dcfa5f8473754f44cb4c05c173416feb1b9306dc64d35"; + + assert_eq!(expected, url.as_str()); + } + + #[test] + fn anonymous_custom_query() { + let expires_in = Duration::from_secs(86400); + + let endpoint = "https://s3.amazonaws.com".parse().unwrap(); + let bucket = Bucket::new( + endpoint, + UrlStyle::VirtualHost, + "examplebucket", + "us-east-1", + ) + .unwrap(); + + let action = CopyObject::new(&bucket, None, "test.txt", "test_copy.txt", true); + let url = action.sign(expires_in); + let expected = "https://examplebucket.s3.amazonaws.com/test_copy.txt?x-amz-copy-source=examplebucket%2Ftest.txt"; + + assert_eq!(expected, url.as_str()); + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 4f37f68..f56967c 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -4,6 +4,7 @@ use std::time::Duration; use url::Url; +pub use self::copy_object::CopyObject; pub use self::create_bucket::CreateBucket; pub use self::delete_bucket::DeleteBucket; pub use self::delete_object::DeleteObject; @@ -25,6 +26,7 @@ pub use self::multipart_upload::upload::UploadPart; pub use self::put_object::PutObject; use crate::{Map, Method}; +mod copy_object; mod create_bucket; mod delete_bucket; mod delete_object; diff --git a/src/bucket.rs b/src/bucket.rs index 60fa1f1..120125b 100644 --- a/src/bucket.rs +++ b/src/bucket.rs @@ -5,8 +5,8 @@ use std::fmt::{self, Display}; use url::{ParseError, Url}; use crate::actions::{ - AbortMultipartUpload, CreateBucket, DeleteBucket, DeleteObject, GetObject, HeadObject, - PutObject, UploadPart, + AbortMultipartUpload, CopyObject, CreateBucket, DeleteBucket, DeleteObject, GetObject, + HeadObject, PutObject, UploadPart, }; #[cfg(feature = "full")] use crate::actions::{ @@ -207,6 +207,32 @@ impl Bucket { PutObject::new(self, credentials, object) } + /// Create a copy of an object in S3 in this bucket, using a `PUT` request. + /// + /// See [`CopyObject`] for more details. + pub fn copy_object<'a>( + &'a self, + credentials: Option<&'a Credentials>, + src_object: &'a str, + dst_object: &'a str, + ) -> CopyObject<'a> { + CopyObject::new(self, credentials, src_object, dst_object, true) + } + + /// Create a copy of an object in S3 in a potentially different bucket, using a `PUT` request. + /// + /// Requires a bucket name to be prepended to `src_object`, separated by a slash. + /// + /// See [`CopyObject`] for more details. + pub fn copy_object_from_bucket<'a>( + &'a self, + credentials: Option<&'a Credentials>, + src_object: &'a str, + dst_object: &'a str, + ) -> CopyObject<'a> { + CopyObject::new(self, credentials, src_object, dst_object, false) + } + /// Delete an object from S3, using a `DELETE` request. /// /// See [`DeleteObject`] for more details. diff --git a/tests/copy.rs b/tests/copy.rs new file mode 100644 index 0000000..550123b --- /dev/null +++ b/tests/copy.rs @@ -0,0 +1,124 @@ +use std::time::Duration; + +use rusty_s3::actions::{ListObjectsV2, S3Action}; + +mod common; + +#[tokio::test] +async fn test_copy() { + let (bucket, credentials, client) = common::bucket().await; + let (bucket_copy, credentials_copy, client_copy) = common::bucket().await; + + let action = bucket.list_objects_v2(Some(&credentials)); + let url = action.sign(Duration::from_secs(60)); + let resp = client + .get(url) + .send() + .await + .expect("send ListObjectsV2") + .error_for_status() + .expect("ListObjectsV2 unexpected status code"); + let text = resp.text().await.expect("ListObjectsV2 read respose body"); + let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response"); + + assert!(list.contents.is_empty()); + + assert_eq!(list.max_keys, 4500); + assert!(list.common_prefixes.is_empty()); + assert!(list.next_continuation_token.is_none()); + assert!(list.start_after.is_none()); + + let body = vec![b'r'; 1024]; + + let action = bucket.put_object(Some(&credentials), "test.txt"); + let url = action.sign(Duration::from_secs(60)); + client + .put(url) + .body(body.clone()) + .send() + .await + .expect("send PutObject") + .error_for_status() + .expect("PutObject unexpected status code"); + + // Copy same bucket + let action = bucket.copy_object(Some(&credentials), "test.txt", "test_copy.txt"); + let url = action.sign(Duration::from_secs(60)); + client + .put(url) + .send() + .await + .expect("send CopyObject") + .error_for_status() + .expect("CopyObject unexpected status code"); + + let action = bucket.list_objects_v2(Some(&credentials)); + let url = action.sign(Duration::from_secs(60)); + let resp = client + .get(url) + .send() + .await + .expect("send ListObjectsV2") + .error_for_status() + .expect("ListObjectsV2 unexpected status code"); + let text = resp.text().await.expect("ListObjectsV2 read respose body"); + let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response"); + assert_eq!(list.contents.len(), 2); + + // Copy different bucket + let dest_object = format!("{}/{}", bucket.name(), "test.txt"); + let action = + bucket_copy.copy_object_from_bucket(Some(&credentials_copy), &dest_object, "test_copy.txt"); + let url = action.sign(Duration::from_secs(60)); + client_copy + .put(url) + .send() + .await + .expect("send CopyObject") + .error_for_status() + .expect("CopyObject unexpected status code"); + + let action = bucket_copy.list_objects_v2(Some(&credentials_copy)); + let url = action.sign(Duration::from_secs(60)); + let resp = client_copy + .get(url) + .send() + .await + .expect("send ListObjectsV2") + .error_for_status() + .expect("ListObjectsV2 unexpected status code"); + let text = resp.text().await.expect("ListObjectsV2 read respose body"); + let list = ListObjectsV2::parse_response(&text).expect("ListObjectsV2 parse response"); + assert_eq!(list.contents.len(), 1); + + // Check length + let action = bucket.head_object(Some(&credentials), "test_copy.txt"); + let url = action.sign(Duration::from_secs(60)); + let resp = client + .head(url) + .send() + .await + .expect("send HeadObject") + .error_for_status() + .expect("HeadObject unexpected status code"); + let content_length = resp + .headers() + .get("content-length") + .expect("Content-Length header") + .to_str() + .expect("Content-Length to_str()"); + assert_eq!(content_length, "1024"); + + // Check content + let action = bucket.get_object(Some(&credentials), "test_copy.txt"); + let url = action.sign(Duration::from_secs(60)); + let resp = client + .get(url) + .send() + .await + .expect("send GetObject") + .error_for_status() + .expect("GetObject unexpected status code"); + let bytes = resp.bytes().await.expect("GetObject read response body"); + assert_eq!(body, bytes); +}