diff options
author | Alex Auvolat <alex@adnab.me> | 2021-05-03 22:45:42 +0200 |
---|---|---|
committer | Alex Auvolat <alex@adnab.me> | 2021-05-06 22:37:15 +0200 |
commit | 6ccffc316228bb056fa135cc5fceb2ed75f545f5 (patch) | |
tree | 109e9334aaee5b7865a5d29fcfaea23010da0cc4 /src/api/s3_xml.rs | |
parent | e4b9e4e24d006373a20cfcbdac9dba5e399ee182 (diff) | |
download | garage-better_xml.tar.gz garage-better_xml.zip |
Improved XML serializationbetter_xml
- 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
Diffstat (limited to 'src/api/s3_xml.rs')
-rw-r--r-- | src/api/s3_xml.rs | 597 |
1 files changed, 597 insertions, 0 deletions
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<T: Serialize>(x: &T) -> Result<String, ApiError> { + let mut xml = r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(); + xml.push_str(&to_string(x)?); + Ok(xml) +} + +fn xmlns_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> { + 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<Bucket>, +} + +#[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<Value>, + #[serde(rename = "Region")] + pub region: Option<Value>, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct DeleteError { + #[serde(rename = "Code")] + pub code: Value, + #[serde(rename = "Key")] + pub key: Option<Value>, + #[serde(rename = "Message")] + pub message: Value, + #[serde(rename = "VersionId")] + pub version_id: Option<Value>, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct DeleteResult { + #[serde(serialize_with = "xmlns_tag")] + pub xmlns: (), + #[serde(rename = "Deleted")] + pub deleted: Vec<Deleted>, + #[serde(rename = "Error")] + pub errors: Vec<DeleteError>, +} + +#[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<Value>, + #[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<Value>, + #[serde(rename = "NextMarker")] + pub next_marker: Option<Value>, + #[serde(rename = "StartAfter")] + pub start_after: Option<Value>, + #[serde(rename = "ContinuationToken")] + pub continuation_token: Option<Value>, + #[serde(rename = "NextContinuationToken")] + pub next_continuation_token: Option<Value>, + #[serde(rename = "KeyCount")] + pub key_count: Option<IntValue>, + #[serde(rename = "MaxKeys")] + pub max_keys: IntValue, + #[serde(rename = "Delimiter")] + pub delimiter: Option<Value>, + #[serde(rename = "EncodingType")] + pub encoding_type: Option<Value>, + #[serde(rename = "IsTruncated")] + pub is_truncated: Value, + #[serde(rename = "Contents")] + pub contents: Vec<ListBucketItem>, + #[serde(rename = "CommonPrefixes")] + pub common_prefixes: Vec<CommonPrefix>, +} + +#[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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<Error>\ + <Code>TestError</Code>\ + <Message>A dummy error message</Message>\ + <Resource>/bucket/a/plop</Resource>\ + <Region>garage</Region>\ +</Error>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<ListAllMyBucketsResult>\ + <Buckets>\ + <Bucket>\ + <CreationDate>1970-01-01T00:00:00.000Z</CreationDate>\ + <Name>bucket_A</Name>\ + </Bucket>\ + <Bucket>\ + <CreationDate>1970-01-02T00:00:00.000Z</CreationDate>\ + <Name>bucket_B</Name>\ + </Bucket>\ + </Buckets>\ + <Owner>\ + <DisplayName>owner_name</DisplayName>\ + <ID>qsdfjklm</ID>\ + </Owner>\ +</ListAllMyBucketsResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<LocationConstraint xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">garage</LocationConstraint>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<DeleteResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Deleted>\ + <Key>a/plop</Key>\ + <VersionId>qsdfjklm</VersionId>\ + <DeleteMarkerVersionId>wxcvbn</DeleteMarkerVersionId>\ + </Deleted>\ + <Deleted>\ + <Key>b/plip</Key>\ + <VersionId>1234</VersionId>\ + <DeleteMarkerVersionId>4321</DeleteMarkerVersionId>\ + </Deleted>\ + <Error>\ + <Code>NotFound</Code>\ + <Key>c/plap</Key>\ + <Message>Object c/plap not found</Message>\ + </Error>\ + <Error>\ + <Code>Forbidden</Code>\ + <Key>d/plep</Key>\ + <Message>Not authorized</Message>\ + <VersionId>789</VersionId>\ + </Error>\ +</DeleteResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<CopyObjectResult>\ + <LastModified>1970-01-01T00:00:00.000Z</LastModified>\ + <ETag>9b2cf535f27731c974343645a3985328</ETag>\ +</CopyObjectResult>\ + " + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<InitiateMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Bucket>mybucket</Bucket>\ + <Key>a/plop</Key>\ + <UploadId>azerty</UploadId>\ +</InitiateMultipartUploadResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<CompleteMultipartUploadResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Location>https://garage.tld/mybucket/a/plop</Location>\ + <Bucket>mybucket</Bucket>\ + <Key>a/plop</Key>\ + <ETag>3858f62230ac3c915f300c664312c11f-9</ETag>\ +</CompleteMultipartUploadResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Name>example-bucket</Name>\ + <Prefix></Prefix>\ + <Marker></Marker>\ + <MaxKeys>1000</MaxKeys>\ + <Delimiter>/</Delimiter>\ + <IsTruncated>false</IsTruncated>\ + <Contents>\ + <Key>sample.jpg</Key>\ + <LastModified>1970-01-01T00:00:00.000Z</LastModified>\ + <ETag>bf1d737a4d46a19f3bced6905cc8b902</ETag>\ + <Size>142863</Size>\ + <StorageClass>STANDARD</StorageClass>\ + </Contents>\ + <CommonPrefixes>\ + <Prefix>photos/</Prefix>\ + </CommonPrefixes>\ +</ListBucketResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Name>example-bucket</Name>\ + <Prefix>photos/2006/</Prefix>\ + <Marker></Marker>\ + <MaxKeys>1000</MaxKeys>\ + <Delimiter>/</Delimiter>\ + <IsTruncated>false</IsTruncated>\ + <CommonPrefixes>\ + <Prefix>photos/2006/February/</Prefix>\ + </CommonPrefixes>\ + <CommonPrefixes>\ + <Prefix>photos/2006/January/</Prefix>\ + </CommonPrefixes>\ +</ListBucketResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Name>quotes</Name>\ + <Prefix>E</Prefix>\ + <StartAfter>ExampleGuide.pdf</StartAfter>\ + <MaxKeys>3</MaxKeys>\ + <IsTruncated>false</IsTruncated>\ + <Contents>\ + <Key>ExampleObject.txt</Key>\ + <LastModified>1970-01-01T00:00:00.000Z</LastModified>\ + <ETag>599bab3ed2c697f1d26842727561fd94</ETag>\ + <Size>857</Size>\ + <StorageClass>REDUCED_REDUNDANCY</StorageClass>\ + </Contents>\ +</ListBucketResult>" + ); + 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)?, + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\ +<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\ + <Name>bucket</Name>\ + <Prefix></Prefix>\ + <ContinuationToken>1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=</ContinuationToken>\ + <NextContinuationToken>qsdfjklm</NextContinuationToken>\ + <KeyCount>112</KeyCount>\ + <MaxKeys>1000</MaxKeys>\ + <IsTruncated>false</IsTruncated>\ + <Contents>\ + <Key>happyfacex.jpg</Key>\ + <LastModified>1970-01-01T00:00:00.000Z</LastModified>\ + <ETag>70ee1738b6b21e2c8a43f3a5ab0eee71</ETag>\ + <Size>1111</Size>\ + <StorageClass>STANDARD</StorageClass>\ + </Contents>\ +</ListBucketResult>" + ); + Ok(()) + } +} |