From 6ccffc316228bb056fa135cc5fceb2ed75f545f5 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 3 May 2021 22:45:42 +0200 Subject: [PATCH] Improved XML serialization - Use quick_xml and serde for all XML response returned by the S3 API. - Include tests for all structs used to generate XML - Remove old manual XML escaping function which was unsafe --- src/api/encoding.rs | 17 -- src/api/error.rs | 29 ++- src/api/lib.rs | 1 + src/api/s3_bucket.rs | 92 ++----- src/api/s3_copy.rs | 16 +- src/api/s3_delete.rs | 56 ++-- src/api/s3_list.rs | 145 ++++------- src/api/s3_put.rs | 52 ++-- src/api/s3_xml.rs | 597 +++++++++++++++++++++++++++++++++++++++++++ src/util/time.rs | 2 +- 10 files changed, 727 insertions(+), 280 deletions(-) create mode 100644 src/api/s3_xml.rs diff --git a/src/api/encoding.rs b/src/api/encoding.rs index b3fbbe34..e286a784 100644 --- a/src/api/encoding.rs +++ b/src/api/encoding.rs @@ -1,13 +1,5 @@ //! Module containing various helpers for encoding -/// Escape &str for xml inclusion -pub fn xml_escape(s: &str) -> String { - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) -} - /// Encode &str for use in a URI pub fn uri_encode(string: &str, encode_slash: bool) -> String { let mut result = String::with_capacity(string.len() * 2); @@ -28,12 +20,3 @@ pub fn uri_encode(string: &str, encode_slash: bool) -> String { } result } - -/// Encode &str either as an uri, or a valid string for xml inclusion -pub fn xml_encode_key(k: &str, urlencode: bool) -> String { - if urlencode { - uri_encode(k, true) - } else { - xml_escape(k) - } -} diff --git a/src/api/error.rs b/src/api/error.rs index a49ba211..7d97366e 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -1,11 +1,9 @@ -use std::fmt::Write; - use err_derive::Error; use hyper::StatusCode; use garage_util::error::Error as GarageError; -use crate::encoding::*; +use crate::s3_xml; /// Errors of this crate #[derive(Debug, Error)] @@ -104,15 +102,22 @@ impl Error { } pub fn aws_xml(&self, garage_region: &str, path: &str) -> String { - let mut xml = String::new(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!(&mut xml, "").unwrap(); - writeln!(&mut xml, "\t{}", self.aws_code()).unwrap(); - writeln!(&mut xml, "\t{}", self).unwrap(); - writeln!(&mut xml, "\t{}", xml_escape(path)).unwrap(); - writeln!(&mut xml, "\t{}", garage_region).unwrap(); - writeln!(&mut xml, "").unwrap(); - xml + let error = s3_xml::Error { + code: s3_xml::Value(self.aws_code().to_string()), + message: s3_xml::Value(format!("{}", self)), + resource: Some(s3_xml::Value(path.to_string())), + region: Some(s3_xml::Value(garage_region.to_string())), + }; + s3_xml::to_xml_with_header(&error).unwrap_or_else(|_| { + r#" + + + InternalError + XML encoding of error failed + + "# + .into() + }) } } diff --git a/src/api/lib.rs b/src/api/lib.rs index 6c6447da..9a23c231 100644 --- a/src/api/lib.rs +++ b/src/api/lib.rs @@ -18,3 +18,4 @@ mod s3_delete; pub mod s3_get; mod s3_list; mod s3_put; +mod s3_xml; diff --git a/src/api/s3_bucket.rs b/src/api/s3_bucket.rs index e7d89698..88d13cac 100644 --- a/src/api/s3_bucket.rs +++ b/src/api/s3_bucket.rs @@ -1,73 +1,20 @@ -use std::fmt::Write; use std::sync::Arc; use hyper::{Body, Response}; -use quick_xml::se::to_string; -use serde::Serialize; use garage_model::garage::Garage; use garage_model::key_table::Key; use garage_util::time::*; use crate::error::*; - -#[derive(Debug, Serialize, PartialEq)] -struct CreationDate { - #[serde(rename = "$value")] - pub body: String, -} -#[derive(Debug, Serialize, PartialEq)] -struct Name { - #[serde(rename = "$value")] - pub body: String, -} -#[derive(Debug, Serialize, PartialEq)] -struct Bucket { - #[serde(rename = "CreationDate")] - pub creation_date: CreationDate, - #[serde(rename = "Name")] - pub name: Name, -} -#[derive(Debug, Serialize, PartialEq)] -struct DisplayName { - #[serde(rename = "$value")] - pub body: String, -} -#[derive(Debug, Serialize, PartialEq)] -struct Id { - #[serde(rename = "$value")] - pub body: String, -} -#[derive(Debug, Serialize, PartialEq)] -struct Owner { - #[serde(rename = "DisplayName")] - display_name: DisplayName, - #[serde(rename = "ID")] - id: Id, -} -#[derive(Debug, Serialize, PartialEq)] -struct BucketList { - #[serde(rename = "Bucket")] - pub entries: Vec, -} -#[derive(Debug, Serialize, PartialEq)] -struct ListAllMyBucketsResult { - #[serde(rename = "Buckets")] - buckets: BucketList, - #[serde(rename = "Owner")] - owner: Owner, -} +use crate::s3_xml; pub fn handle_get_bucket_location(garage: Arc) -> Result, Error> { - let mut xml = String::new(); - - writeln!(&mut xml, r#""#).unwrap(); - writeln!( - &mut xml, - r#"{}"#, - garage.config.s3_api.s3_region - ) - .unwrap(); + let loc = s3_xml::LocationConstraint { + xmlns: (), + region: garage.config.s3_api.s3_region.to_string(), + }; + let xml = s3_xml::to_xml_with_header(&loc)?; Ok(Response::builder() .header("Content-Type", "application/xml") @@ -75,34 +22,25 @@ pub fn handle_get_bucket_location(garage: Arc) -> Result, } pub fn handle_list_buckets(api_key: &Key) -> Result, Error> { - let list_buckets = ListAllMyBucketsResult { - owner: Owner { - display_name: DisplayName { - body: api_key.name.get().to_string(), - }, - id: Id { - body: api_key.key_id.to_string(), - }, + let list_buckets = s3_xml::ListAllMyBucketsResult { + owner: s3_xml::Owner { + display_name: s3_xml::Value(api_key.name.get().to_string()), + id: s3_xml::Value(api_key.key_id.to_string()), }, - buckets: BucketList { + buckets: s3_xml::BucketList { entries: api_key .authorized_buckets .items() .iter() - .map(|(name, ts, _)| Bucket { - creation_date: CreationDate { - body: msec_to_rfc3339(*ts), - }, - name: Name { - body: name.to_string(), - }, + .map(|(name, ts, _)| s3_xml::Bucket { + creation_date: s3_xml::Value(msec_to_rfc3339(*ts)), + name: s3_xml::Value(name.to_string()), }) .collect(), }, }; - let mut xml = r#""#.to_string(); - xml.push_str(&to_string(&list_buckets)?); + let xml = s3_xml::to_xml_with_header(&list_buckets)?; trace!("xml: {}", xml); Ok(Response::builder() diff --git a/src/api/s3_copy.rs b/src/api/s3_copy.rs index 7069489b..9ade6985 100644 --- a/src/api/s3_copy.rs +++ b/src/api/s3_copy.rs @@ -1,4 +1,3 @@ -use std::fmt::Write; use std::sync::Arc; use hyper::{Body, Request, Response}; @@ -14,6 +13,7 @@ use garage_model::version_table::*; use crate::error::*; use crate::s3_put::get_headers; +use crate::s3_xml; pub async fn handle_copy( garage: Arc, @@ -61,6 +61,8 @@ pub async fn handle_copy( _ => old_meta.clone(), }; + let etag = new_meta.etag.to_string(); + // Save object copy match source_last_state { ObjectVersionData::DeleteMarker => unreachable!(), @@ -158,13 +160,13 @@ pub async fn handle_copy( } let last_modified = msec_to_rfc3339(new_timestamp); - let mut xml = String::new(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!(&mut xml, "\t{}", last_modified).unwrap(); - writeln!(&mut xml, "").unwrap(); + let result = s3_xml::CopyObjectResult { + last_modified: s3_xml::Value(last_modified), + etag: s3_xml::Value(etag), + }; + let xml = s3_xml::to_xml_with_header(&result)?; Ok(Response::builder() .header("Content-Type", "application/xml") - .body(Body::from(xml.into_bytes()))?) + .body(Body::from(xml))?) } diff --git a/src/api/s3_delete.rs b/src/api/s3_delete.rs index 9d2a67f5..a391a33e 100644 --- a/src/api/s3_delete.rs +++ b/src/api/s3_delete.rs @@ -1,4 +1,3 @@ -use std::fmt::Write; use std::sync::Arc; use hyper::{Body, Request, Response}; @@ -9,8 +8,8 @@ use garage_util::time::*; use garage_model::garage::Garage; use garage_model::object_table::*; -use crate::encoding::*; use crate::error::*; +use crate::s3_xml; use crate::signature::verify_signed_content; async fn handle_delete_internal( @@ -85,13 +84,8 @@ pub async fn handle_delete_objects( let cmd_xml = roxmltree::Document::parse(&std::str::from_utf8(&body)?)?; let cmd = parse_delete_objects_xml(&cmd_xml).ok_or_bad_request("Invalid delete XML query")?; - let mut retxml = String::new(); - writeln!(&mut retxml, r#""#).unwrap(); - writeln!( - &mut retxml, - r#""# - ) - .unwrap(); + let mut ret_deleted = Vec::new(); + let mut ret_errors = Vec::new(); for obj in cmd.objects.iter() { match handle_delete_internal(&garage, bucket, &obj.key).await { @@ -99,42 +93,32 @@ pub async fn handle_delete_objects( if cmd.quiet { continue; } - writeln!(&mut retxml, "\t").unwrap(); - writeln!(&mut retxml, "\t\t{}", xml_escape(&obj.key)).unwrap(); - writeln!( - &mut retxml, - "\t\t{}", - hex::encode(deleted_version) - ) - .unwrap(); - writeln!( - &mut retxml, - "\t\t{}", - hex::encode(delete_marker_version) - ) - .unwrap(); - writeln!(&mut retxml, "\t").unwrap(); + ret_deleted.push(s3_xml::Deleted { + key: s3_xml::Value(obj.key.clone()), + version_id: s3_xml::Value(hex::encode(deleted_version)), + delete_marker_version_id: s3_xml::Value(hex::encode(delete_marker_version)), + }); } Err(e) => { - writeln!(&mut retxml, "\t").unwrap(); - writeln!(&mut retxml, "\t\t{}", e.http_status_code()).unwrap(); - writeln!(&mut retxml, "\t\t{}", xml_escape(&obj.key)).unwrap(); - writeln!( - &mut retxml, - "\t\t{}", - xml_escape(&format!("{}", e)) - ) - .unwrap(); - writeln!(&mut retxml, "\t").unwrap(); + ret_errors.push(s3_xml::DeleteError { + code: s3_xml::Value(e.aws_code().to_string()), + key: Some(s3_xml::Value(obj.key.clone())), + message: s3_xml::Value(format!("{}", e)), + version_id: None, + }); } } } - writeln!(&mut retxml, "").unwrap(); + let xml = s3_xml::to_xml_with_header(&s3_xml::DeleteResult { + xmlns: (), + deleted: ret_deleted, + errors: ret_errors, + })?; Ok(Response::builder() .header("Content-Type", "application/xml") - .body(Body::from(retxml.into_bytes()))?) + .body(Body::from(xml))?) } struct DeleteRequest { diff --git a/src/api/s3_list.rs b/src/api/s3_list.rs index 80fefd56..384346e0 100644 --- a/src/api/s3_list.rs +++ b/src/api/s3_list.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fmt::Write; use std::sync::Arc; use hyper::{Body, Response}; @@ -14,6 +13,7 @@ use garage_table::DeletedFilter; use crate::encoding::*; use crate::error::*; +use crate::s3_xml; #[derive(Debug)] pub struct ListObjectsQuery { @@ -163,126 +163,81 @@ pub async fn handle_list( } } - let mut xml = String::new(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!( - &mut xml, - r#""# - ) - .unwrap(); + let mut result = s3_xml::ListBucketResult { + xmlns: (), + name: s3_xml::Value(query.bucket.to_string()), + prefix: uriencode_maybe(&query.prefix, query.urlencode_resp), + marker: None, + next_marker: None, + start_after: None, + continuation_token: None, + next_continuation_token: None, + max_keys: s3_xml::IntValue(query.max_keys as i64), + delimiter: query + .delimiter + .as_ref() + .map(|x| uriencode_maybe(x, query.urlencode_resp)), + encoding_type: match query.urlencode_resp { + true => Some(s3_xml::Value("url".to_string())), + false => None, + }, - writeln!(&mut xml, "\t{}", query.bucket).unwrap(); - - // TODO: in V1, is this supposed to be urlencoded when encoding-type is URL?? - writeln!( - &mut xml, - "\t{}", - xml_encode_key(&query.prefix, query.urlencode_resp), - ) - .unwrap(); - - if let Some(delim) = &query.delimiter { - // TODO: in V1, is this supposed to be urlencoded when encoding-type is URL?? - writeln!( - &mut xml, - "\t{}", - xml_encode_key(delim, query.urlencode_resp), - ) - .unwrap(); - } - - writeln!(&mut xml, "\t{}", query.max_keys).unwrap(); - if query.urlencode_resp { - writeln!(&mut xml, "\turl").unwrap(); - } - - writeln!( - &mut xml, - "\t{}", - result_keys.len() + result_common_prefixes.len() - ) - .unwrap(); - writeln!( - &mut xml, - "\t{}", - truncated.is_some() - ) - .unwrap(); + key_count: Some(s3_xml::IntValue( + result_keys.len() as i64 + result_common_prefixes.len() as i64, + )), + is_truncated: s3_xml::Value(format!("{}", truncated.is_some())), + contents: vec![], + common_prefixes: vec![], + }; if query.is_v2 { if let Some(ct) = &query.continuation_token { - writeln!(&mut xml, "\t{}", ct).unwrap(); + result.continuation_token = Some(s3_xml::Value(ct.to_string())); } if let Some(sa) = &query.start_after { - writeln!( - &mut xml, - "\t{}", - xml_encode_key(sa, query.urlencode_resp) - ) - .unwrap(); + result.start_after = Some(uriencode_maybe(sa, query.urlencode_resp)); } if let Some(nct) = truncated { - writeln!( - &mut xml, - "\t{}", - base64::encode(nct.as_bytes()) - ) - .unwrap(); + result.next_continuation_token = Some(s3_xml::Value(base64::encode(nct.as_bytes()))); } } else { // TODO: are these supposed to be urlencoded when encoding-type is URL?? if let Some(mkr) = &query.marker { - writeln!( - &mut xml, - "\t{}", - xml_encode_key(mkr, query.urlencode_resp) - ) - .unwrap(); + result.marker = Some(uriencode_maybe(mkr, query.urlencode_resp)); } if let Some(next_marker) = truncated { - writeln!( - &mut xml, - "\t{}", - xml_encode_key(&next_marker, query.urlencode_resp) - ) - .unwrap(); + result.next_marker = Some(uriencode_maybe(&next_marker, query.urlencode_resp)); } } for (key, info) in result_keys.iter() { - let last_modif = msec_to_rfc3339(info.last_modified); - writeln!(&mut xml, "\t").unwrap(); - writeln!( - &mut xml, - "\t\t{}", - xml_encode_key(key, query.urlencode_resp), - ) - .unwrap(); - writeln!(&mut xml, "\t\t{}", last_modif).unwrap(); - writeln!(&mut xml, "\t\t{}", info.size).unwrap(); - if !info.etag.is_empty() { - writeln!(&mut xml, "\t\t\"{}\"", info.etag).unwrap(); - } - writeln!(&mut xml, "\t\tSTANDARD").unwrap(); - writeln!(&mut xml, "\t").unwrap(); + result.contents.push(s3_xml::ListBucketItem { + key: uriencode_maybe(key, query.urlencode_resp), + last_modified: s3_xml::Value(msec_to_rfc3339(info.last_modified)), + size: s3_xml::IntValue(info.size as i64), + etag: s3_xml::Value(info.etag.to_string()), + storage_class: s3_xml::Value("STANDARD".to_string()), + }); } for pfx in result_common_prefixes.iter() { - writeln!(&mut xml, "\t").unwrap(); //TODO: in V1, are these urlencoded when urlencode_resp is true ?? (proably) - writeln!( - &mut xml, - "\t\t{}", - xml_encode_key(pfx, query.urlencode_resp), - ) - .unwrap(); - writeln!(&mut xml, "\t").unwrap(); + result.common_prefixes.push(s3_xml::CommonPrefix { + prefix: uriencode_maybe(pfx, query.urlencode_resp), + }); } - writeln!(&mut xml, "").unwrap(); - debug!("{}", xml); + let xml = s3_xml::to_xml_with_header(&result)?; Ok(Response::builder() .header("Content-Type", "application/xml") .body(Body::from(xml.into_bytes()))?) } + +fn uriencode_maybe(s: &str, yes: bool) -> s3_xml::Value { + if yes { + s3_xml::Value(uri_encode(s, true)) + } else { + s3_xml::Value(s.to_string()) + } +} diff --git a/src/api/s3_put.rs b/src/api/s3_put.rs index bb6cf579..aa285232 100644 --- a/src/api/s3_put.rs +++ b/src/api/s3_put.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, VecDeque}; -use std::fmt::Write; use std::sync::Arc; use futures::stream::*; @@ -18,8 +17,8 @@ use garage_model::garage::Garage; use garage_model::object_table::*; use garage_model::version_table::*; -use crate::encoding::*; use crate::error::*; +use crate::s3_xml; use crate::signature::verify_signed_content; pub async fn handle_put( @@ -339,22 +338,13 @@ pub async fn handle_create_multipart_upload( garage.version_table.insert(&version).await?; // Send success response - let mut xml = String::new(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!( - &mut xml, - r#""# - ) - .unwrap(); - writeln!(&mut xml, "\t{}", bucket).unwrap(); - writeln!(&mut xml, "\t{}", xml_escape(key)).unwrap(); - writeln!( - &mut xml, - "\t{}", - hex::encode(version_uuid) - ) - .unwrap(); - writeln!(&mut xml, "").unwrap(); + let result = s3_xml::InitiateMultipartUploadResult { + xmlns: (), + bucket: s3_xml::Value(bucket.to_string()), + key: s3_xml::Value(key.to_string()), + upload_id: s3_xml::Value(hex::encode(version_uuid)), + }; + let xml = s3_xml::to_xml_with_header(&result)?; Ok(Response::new(Body::from(xml.into_bytes()))) } @@ -520,7 +510,7 @@ pub async fn handle_complete_multipart_upload( ObjectVersionMeta { headers, size: total_size, - etag, + etag: etag.clone(), }, version.blocks.items()[0].1.hash, )); @@ -529,22 +519,14 @@ pub async fn handle_complete_multipart_upload( garage.object_table.insert(&final_object).await?; // Send response saying ok we're done - let mut xml = String::new(); - writeln!(&mut xml, r#""#).unwrap(); - writeln!( - &mut xml, - r#""# - ) - .unwrap(); - writeln!( - &mut xml, - "\t{}", - garage.config.s3_api.s3_region - ) - .unwrap(); - writeln!(&mut xml, "\t{}", bucket).unwrap(); - writeln!(&mut xml, "\t{}", xml_escape(&key)).unwrap(); - writeln!(&mut xml, "").unwrap(); + let result = s3_xml::CompleteMultipartUploadResult { + xmlns: (), + location: None, + bucket: s3_xml::Value(bucket), + key: s3_xml::Value(key), + etag: s3_xml::Value(etag), + }; + let xml = s3_xml::to_xml_with_header(&result)?; Ok(Response::new(Body::from(xml.into_bytes()))) } diff --git a/src/api/s3_xml.rs b/src/api/s3_xml.rs new file mode 100644 index 00000000..ba9b9083 --- /dev/null +++ b/src/api/s3_xml.rs @@ -0,0 +1,597 @@ +use quick_xml::se::to_string; +use serde::{Serialize, Serializer}; + +use crate::Error as ApiError; + +pub fn to_xml_with_header(x: &T) -> Result { + let mut xml = r#""#.to_string(); + xml.push_str(&to_string(x)?); + Ok(xml) +} + +fn xmlns_tag(_v: &(), s: S) -> Result { + s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/") +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct Value(#[serde(rename = "$value")] pub String); + +#[derive(Debug, Serialize, PartialEq)] +pub struct IntValue(#[serde(rename = "$value")] pub i64); + +#[derive(Debug, Serialize, PartialEq)] +pub struct Bucket { + #[serde(rename = "CreationDate")] + pub creation_date: Value, + #[serde(rename = "Name")] + pub name: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct DisplayName(#[serde(rename = "$value")] pub String); + +#[derive(Debug, Serialize, PartialEq)] +pub struct Id(#[serde(rename = "$value")] pub String); + +#[derive(Debug, Serialize, PartialEq)] +pub struct Owner { + #[serde(rename = "DisplayName")] + pub display_name: Value, + #[serde(rename = "ID")] + pub id: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct BucketList { + #[serde(rename = "Bucket")] + pub entries: Vec, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct ListAllMyBucketsResult { + #[serde(rename = "Buckets")] + pub buckets: BucketList, + #[serde(rename = "Owner")] + pub owner: Owner, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct LocationConstraint { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "$value")] + pub region: String, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct Deleted { + #[serde(rename = "Key")] + pub key: Value, + #[serde(rename = "VersionId")] + pub version_id: Value, + #[serde(rename = "DeleteMarkerVersionId")] + pub delete_marker_version_id: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct Error { + #[serde(rename = "Code")] + pub code: Value, + #[serde(rename = "Message")] + pub message: Value, + #[serde(rename = "Resource")] + pub resource: Option, + #[serde(rename = "Region")] + pub region: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct DeleteError { + #[serde(rename = "Code")] + pub code: Value, + #[serde(rename = "Key")] + pub key: Option, + #[serde(rename = "Message")] + pub message: Value, + #[serde(rename = "VersionId")] + pub version_id: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct DeleteResult { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Deleted")] + pub deleted: Vec, + #[serde(rename = "Error")] + pub errors: Vec, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct CopyObjectResult { + #[serde(rename = "LastModified")] + pub last_modified: Value, + #[serde(rename = "ETag")] + pub etag: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct InitiateMultipartUploadResult { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Bucket")] + pub bucket: Value, + #[serde(rename = "Key")] + pub key: Value, + #[serde(rename = "UploadId")] + pub upload_id: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct CompleteMultipartUploadResult { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Location")] + pub location: Option, + #[serde(rename = "Bucket")] + pub bucket: Value, + #[serde(rename = "Key")] + pub key: Value, + #[serde(rename = "ETag")] + pub etag: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct ListBucketItem { + #[serde(rename = "Key")] + pub key: Value, + #[serde(rename = "LastModified")] + pub last_modified: Value, + #[serde(rename = "ETag")] + pub etag: Value, + #[serde(rename = "Size")] + pub size: IntValue, + #[serde(rename = "StorageClass")] + pub storage_class: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct CommonPrefix { + #[serde(rename = "Prefix")] + pub prefix: Value, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct ListBucketResult { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Name")] + pub name: Value, + #[serde(rename = "Prefix")] + pub prefix: Value, + #[serde(rename = "Marker")] + pub marker: Option, + #[serde(rename = "NextMarker")] + pub next_marker: Option, + #[serde(rename = "StartAfter")] + pub start_after: Option, + #[serde(rename = "ContinuationToken")] + pub continuation_token: Option, + #[serde(rename = "NextContinuationToken")] + pub next_continuation_token: Option, + #[serde(rename = "KeyCount")] + pub key_count: Option, + #[serde(rename = "MaxKeys")] + pub max_keys: IntValue, + #[serde(rename = "Delimiter")] + pub delimiter: Option, + #[serde(rename = "EncodingType")] + pub encoding_type: Option, + #[serde(rename = "IsTruncated")] + pub is_truncated: Value, + #[serde(rename = "Contents")] + pub contents: Vec, + #[serde(rename = "CommonPrefixes")] + pub common_prefixes: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + use garage_util::time::*; + + #[test] + fn error_message() -> Result<(), ApiError> { + let error = Error { + code: Value("TestError".to_string()), + message: Value("A dummy error message".to_string()), + resource: Some(Value("/bucket/a/plop".to_string())), + region: Some(Value("garage".to_string())), + }; + assert_eq!( + to_xml_with_header(&error)?, + "\ +\ + TestError\ + A dummy error message\ + /bucket/a/plop\ + garage\ +" + ); + Ok(()) + } + + #[test] + fn list_all_my_buckets_result() -> Result<(), ApiError> { + let list_buckets = ListAllMyBucketsResult { + owner: Owner { + display_name: Value("owner_name".to_string()), + id: Value("qsdfjklm".to_string()), + }, + buckets: BucketList { + entries: vec![ + Bucket { + creation_date: Value(msec_to_rfc3339(0)), + name: Value("bucket_A".to_string()), + }, + Bucket { + creation_date: Value(msec_to_rfc3339(3600 * 24 * 1000)), + name: Value("bucket_B".to_string()), + }, + ], + }, + }; + assert_eq!( + to_xml_with_header(&list_buckets)?, + "\ +\ + \ + \ + 1970-01-01T00:00:00.000Z\ + bucket_A\ + \ + \ + 1970-01-02T00:00:00.000Z\ + bucket_B\ + \ + \ + \ + owner_name\ + qsdfjklm\ + \ +" + ); + Ok(()) + } + + #[test] + fn get_bucket_location_result() -> Result<(), ApiError> { + let get_bucket_location = LocationConstraint { + xmlns: (), + region: "garage".to_string(), + }; + assert_eq!( + to_xml_with_header(&get_bucket_location)?, + "\ +garage" + ); + Ok(()) + } + + #[test] + fn delete_result() -> Result<(), ApiError> { + let delete_result = DeleteResult { + xmlns: (), + deleted: vec![ + Deleted { + key: Value("a/plop".to_string()), + version_id: Value("qsdfjklm".to_string()), + delete_marker_version_id: Value("wxcvbn".to_string()), + }, + Deleted { + key: Value("b/plip".to_string()), + version_id: Value("1234".to_string()), + delete_marker_version_id: Value("4321".to_string()), + }, + ], + errors: vec![ + DeleteError { + code: Value("NotFound".to_string()), + key: Some(Value("c/plap".to_string())), + message: Value("Object c/plap not found".to_string()), + version_id: None, + }, + DeleteError { + code: Value("Forbidden".to_string()), + key: Some(Value("d/plep".to_string())), + message: Value("Not authorized".to_string()), + version_id: Some(Value("789".to_string())), + }, + ], + }; + assert_eq!( + to_xml_with_header(&delete_result)?, + "\ +\ + \ + a/plop\ + qsdfjklm\ + wxcvbn\ + \ + \ + b/plip\ + 1234\ + 4321\ + \ + \ + NotFound\ + c/plap\ + Object c/plap not found\ + \ + \ + Forbidden\ + d/plep\ + Not authorized\ + 789\ + \ +" + ); + Ok(()) + } + + #[test] + fn copy_object_result() -> Result<(), ApiError> { + let copy_result = CopyObjectResult { + last_modified: Value(msec_to_rfc3339(0)), + etag: Value("9b2cf535f27731c974343645a3985328".to_string()), + }; + assert_eq!( + to_xml_with_header(©_result)?, + "\ +\ + 1970-01-01T00:00:00.000Z\ + 9b2cf535f27731c974343645a3985328\ +\ + " + ); + Ok(()) + } + + #[test] + fn initiate_multipart_upload_result() -> Result<(), ApiError> { + let result = InitiateMultipartUploadResult { + xmlns: (), + bucket: Value("mybucket".to_string()), + key: Value("a/plop".to_string()), + upload_id: Value("azerty".to_string()), + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + mybucket\ + a/plop\ + azerty\ +" + ); + Ok(()) + } + + #[test] + fn complete_multipart_upload_result() -> Result<(), ApiError> { + let result = CompleteMultipartUploadResult { + xmlns: (), + location: Some(Value("https://garage.tld/mybucket/a/plop".to_string())), + bucket: Value("mybucket".to_string()), + key: Value("a/plop".to_string()), + etag: Value("3858f62230ac3c915f300c664312c11f-9".to_string()), + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + https://garage.tld/mybucket/a/plop\ + mybucket\ + a/plop\ + 3858f62230ac3c915f300c664312c11f-9\ +" + ); + Ok(()) + } + + #[test] + fn list_objects_v1_1() -> Result<(), ApiError> { + let result = ListBucketResult { + xmlns: (), + name: Value("example-bucket".to_string()), + prefix: Value("".to_string()), + marker: Some(Value("".to_string())), + next_marker: None, + start_after: None, + continuation_token: None, + next_continuation_token: None, + key_count: None, + max_keys: IntValue(1000), + encoding_type: None, + delimiter: Some(Value("/".to_string())), + is_truncated: Value("false".to_string()), + contents: vec![ListBucketItem { + key: Value("sample.jpg".to_string()), + last_modified: Value(msec_to_rfc3339(0)), + etag: Value("bf1d737a4d46a19f3bced6905cc8b902".to_string()), + size: IntValue(142863), + storage_class: Value("STANDARD".to_string()), + }], + common_prefixes: vec![CommonPrefix { + prefix: Value("photos/".to_string()), + }], + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + example-bucket\ + \ + \ + 1000\ + /\ + false\ + \ + sample.jpg\ + 1970-01-01T00:00:00.000Z\ + bf1d737a4d46a19f3bced6905cc8b902\ + 142863\ + STANDARD\ + \ + \ + photos/\ + \ +" + ); + Ok(()) + } + + #[test] + fn list_objects_v1_2() -> Result<(), ApiError> { + let result = ListBucketResult { + xmlns: (), + name: Value("example-bucket".to_string()), + prefix: Value("photos/2006/".to_string()), + marker: Some(Value("".to_string())), + next_marker: None, + start_after: None, + continuation_token: None, + next_continuation_token: None, + key_count: None, + max_keys: IntValue(1000), + delimiter: Some(Value("/".to_string())), + encoding_type: None, + is_truncated: Value("false".to_string()), + contents: vec![], + common_prefixes: vec![ + CommonPrefix { + prefix: Value("photos/2006/February/".to_string()), + }, + CommonPrefix { + prefix: Value("photos/2006/January/".to_string()), + }, + ], + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + example-bucket\ + photos/2006/\ + \ + 1000\ + /\ + false\ + \ + photos/2006/February/\ + \ + \ + photos/2006/January/\ + \ +" + ); + Ok(()) + } + + #[test] + fn list_objects_v2_1() -> Result<(), ApiError> { + let result = ListBucketResult { + xmlns: (), + name: Value("quotes".to_string()), + prefix: Value("E".to_string()), + marker: None, + next_marker: None, + start_after: Some(Value("ExampleGuide.pdf".to_string())), + continuation_token: None, + next_continuation_token: None, + key_count: None, + max_keys: IntValue(3), + delimiter: None, + encoding_type: None, + is_truncated: Value("false".to_string()), + contents: vec![ListBucketItem { + key: Value("ExampleObject.txt".to_string()), + last_modified: Value(msec_to_rfc3339(0)), + etag: Value("599bab3ed2c697f1d26842727561fd94".to_string()), + size: IntValue(857), + storage_class: Value("REDUCED_REDUNDANCY".to_string()), + }], + common_prefixes: vec![], + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + quotes\ + E\ + ExampleGuide.pdf\ + 3\ + false\ + \ + ExampleObject.txt\ + 1970-01-01T00:00:00.000Z\ + 599bab3ed2c697f1d26842727561fd94\ + 857\ + REDUCED_REDUNDANCY\ + \ +" + ); + Ok(()) + } + + #[test] + fn list_objects_v2_2() -> Result<(), ApiError> { + let result = ListBucketResult { + xmlns: (), + name: Value("bucket".to_string()), + prefix: Value("".to_string()), + marker: None, + next_marker: None, + start_after: None, + continuation_token: Some(Value( + "1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=".to_string(), + )), + next_continuation_token: Some(Value("qsdfjklm".to_string())), + key_count: Some(IntValue(112)), + max_keys: IntValue(1000), + delimiter: None, + encoding_type: None, + is_truncated: Value("false".to_string()), + contents: vec![ListBucketItem { + key: Value("happyfacex.jpg".to_string()), + last_modified: Value(msec_to_rfc3339(0)), + etag: Value("70ee1738b6b21e2c8a43f3a5ab0eee71".to_string()), + size: IntValue(1111), + storage_class: Value("STANDARD".to_string()), + }], + common_prefixes: vec![], + }; + assert_eq!( + to_xml_with_header(&result)?, + "\ +\ + bucket\ + \ + 1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=\ + qsdfjklm\ + 112\ + 1000\ + false\ + \ + happyfacex.jpg\ + 1970-01-01T00:00:00.000Z\ + 70ee1738b6b21e2c8a43f3a5ab0eee71\ + 1111\ + STANDARD\ + \ +" + ); + Ok(()) + } +} diff --git a/src/util/time.rs b/src/util/time.rs index dfedcb26..238db2c3 100644 --- a/src/util/time.rs +++ b/src/util/time.rs @@ -16,5 +16,5 @@ pub fn msec_to_rfc3339(msecs: u64) -> String { let secs = msecs as i64 / 1000; let nanos = (msecs as i64 % 1000) as u32 * 1_000_000; let timestamp = Utc.timestamp(secs, nanos); - timestamp.to_rfc3339_opts(SecondsFormat::Secs, true) + timestamp.to_rfc3339_opts(SecondsFormat::Millis, true) }