Skip to main content

rusty_s3/actions/multipart_upload/
list_parts.rs

1use std::iter;
2use std::time::Duration;
3
4use instant_xml::FromXml;
5use jiff::Timestamp;
6use url::Url;
7
8use crate::actions::{Method, S3_XML_NS, S3Action};
9use crate::signing::sign;
10use crate::sorting_iter::SortingIterator;
11use crate::{Bucket, Credentials, Map};
12
13/// Lists the parts that have been uploaded for a specific multipart upload.
14///
15/// If `next_part_number_marker` is `Some` the response is truncated, and the
16/// rest of the list can be retrieved by reusing the `ListParts` action
17/// but with `part_number_marker` set to the value of `next_part_number_marker`
18/// received in the previous response.
19///
20/// Find out more about `ListParts` from the [AWS API Reference][api]
21///
22/// [api]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html
23#[derive(Debug, Clone)]
24pub struct ListParts<'a> {
25    bucket: &'a Bucket,
26    credentials: Option<&'a Credentials>,
27    object: &'a str,
28    upload_id: &'a str,
29
30    query: Map<'a>,
31    headers: Map<'a>,
32}
33
34#[allow(clippy::module_name_repetitions)]
35#[derive(Debug, Clone, FromXml)]
36#[xml(rename = "ListPartsResult", ns(S3_XML_NS))]
37pub struct ListPartsResponse {
38    pub parts: Vec<PartsContent>,
39    #[xml(rename = "MaxParts")]
40    pub max_parts: u16,
41    #[xml(rename = "IsTruncated")]
42    is_truncated: bool,
43    #[xml(rename = "NextPartNumberMarker")]
44    pub next_part_number_marker: Option<u16>,
45}
46
47#[derive(Debug, Clone, FromXml)]
48#[xml(rename = "Part", ns(S3_XML_NS))]
49pub struct PartsContent {
50    #[xml(rename = "PartNumber")]
51    pub number: u16,
52    #[xml(rename = "ETag")]
53    pub etag: String,
54    #[xml(rename = "LastModified")]
55    pub last_modified: String,
56    #[xml(rename = "Size")]
57    pub size: u64,
58}
59
60impl<'a> ListParts<'a> {
61    #[must_use]
62    pub const fn new(
63        bucket: &'a Bucket,
64        credentials: Option<&'a Credentials>,
65        object: &'a str,
66        upload_id: &'a str,
67    ) -> Self {
68        Self {
69            bucket,
70            credentials,
71            object,
72            upload_id,
73
74            query: Map::new(),
75            headers: Map::new(),
76        }
77    }
78
79    pub fn set_max_parts(&mut self, max_parts: u16) {
80        self.query.insert("max-parts", max_parts.to_string());
81    }
82
83    pub fn set_part_number_marker(&mut self, part_number_marker: u16) {
84        self.query
85            .insert("part-number-marker", part_number_marker.to_string());
86    }
87
88    /// Parse the XML response from S3 into a struct
89    ///
90    /// # Errors
91    ///
92    /// Will return an error if the XML cannot be deserialized
93    pub fn parse_response(s: &str) -> Result<ListPartsResponse, instant_xml::Error> {
94        let mut parts: ListPartsResponse = instant_xml::from_str(s)?;
95        if !parts.is_truncated {
96            parts.next_part_number_marker = None;
97        }
98        Ok(parts)
99    }
100}
101
102impl<'a> S3Action<'a> for ListParts<'a> {
103    const METHOD: Method = Method::Get;
104
105    fn query_mut(&mut self) -> &mut Map<'a> {
106        &mut self.query
107    }
108
109    fn headers_mut(&mut self) -> &mut Map<'a> {
110        &mut self.headers
111    }
112
113    fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url {
114        let url = self.bucket.object_url(self.object).unwrap();
115        let query =
116            SortingIterator::new(iter::once(("uploadId", self.upload_id)), self.query.iter());
117
118        match self.credentials {
119            Some(credentials) => sign(
120                time,
121                Self::METHOD,
122                url,
123                credentials.key(),
124                credentials.secret(),
125                credentials.token(),
126                self.bucket.region(),
127                expires_in.as_secs(),
128                query,
129                self.headers.iter(),
130            ),
131            None => crate::signing::util::add_query_params(url, query),
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use pretty_assertions::assert_eq;
139
140    use crate::{Bucket, Credentials, UrlStyle};
141
142    use super::*;
143
144    #[test]
145    fn aws_example() {
146        // Fri, 24 May 2013 00:00:00 GMT
147        let date = Timestamp::from_second(1369353600).unwrap();
148        let expires_in = Duration::from_secs(86400);
149
150        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
151        let bucket = Bucket::new(
152            endpoint,
153            UrlStyle::VirtualHost,
154            "examplebucket",
155            "us-east-1",
156        )
157        .unwrap();
158        let credentials = Credentials::new(
159            "AKIAIOSFODNN7EXAMPLE",
160            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
161        );
162
163        let mut action = ListParts::new(&bucket, Some(&credentials), "test.txt", "abcd");
164        action.set_max_parts(100);
165        let url = action.sign_with_time(expires_in, &date);
166        let expected = "https://examplebucket.s3.amazonaws.com/test.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&max-parts=100&uploadId=abcd&X-Amz-Signature=10a814258808a79054a80e2aff66e95faba686648eb50bd143fe7fe7d6d7b6ce";
167        assert_eq!(expected, url.as_str());
168
169        let mut action = ListParts::new(&bucket, Some(&credentials), "test.txt", "abcd");
170        action.set_max_parts(50);
171        action.set_part_number_marker(100);
172        let url = action.sign_with_time(expires_in, &date);
173        let expected = "https://examplebucket.s3.amazonaws.com/test.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&max-parts=50&part-number-marker=100&uploadId=abcd&X-Amz-Signature=ea8eecb4f2534d606474497e6088ceb262081bf7c5a289ff0598aafdd66055da";
174        assert_eq!(expected, url.as_str());
175    }
176
177    #[test]
178    fn anonymous_custom_query() {
179        let expires_in = Duration::from_secs(86400);
180
181        let endpoint = "https://s3.amazonaws.com".parse().unwrap();
182        let bucket = Bucket::new(
183            endpoint,
184            UrlStyle::VirtualHost,
185            "examplebucket",
186            "us-east-1",
187        )
188        .unwrap();
189
190        let mut action = ListParts::new(&bucket, None, "test.txt", "abcd");
191        action.set_max_parts(100);
192        let url = action.sign(expires_in);
193        let expected =
194            "https://examplebucket.s3.amazonaws.com/test.txt?max-parts=100&uploadId=abcd";
195        assert_eq!(expected, url.as_str());
196
197        let mut action = ListParts::new(&bucket, None, "test.txt", "abcd");
198        action.set_max_parts(50);
199        action.set_part_number_marker(100);
200        let url = action.sign(expires_in);
201        let expected = "https://examplebucket.s3.amazonaws.com/test.txt?max-parts=50&part-number-marker=100&uploadId=abcd";
202        assert_eq!(expected, url.as_str());
203    }
204
205    #[test]
206    fn parse() {
207        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
208        <ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
209          <Bucket>example-bucket</Bucket>
210          <Key>example-object</Key>
211          <UploadId>XXBsb2FkIElEIGZvciBlbHZpbmcncyVcdS1tb3ZpZS5tMnRzEEEwbG9hZA</UploadId>
212          <Initiator>
213              <ID>arn:aws:iam::111122223333:user/some-user-11116a31-17b5-4fb7-9df5-b288870f11xx</ID>
214              <DisplayName>umat-user-11116a31-17b5-4fb7-9df5-b288870f11xx</DisplayName>
215          </Initiator>
216          <Owner>
217            <ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>
218            <DisplayName>someName</DisplayName>
219          </Owner>
220          <StorageClass>STANDARD</StorageClass>
221          <PartNumberMarker>1</PartNumberMarker>
222          <NextPartNumberMarker>3</NextPartNumberMarker>
223          <MaxParts>2</MaxParts>
224          <IsTruncated>true</IsTruncated>
225          <Part>
226            <PartNumber>2</PartNumber>
227            <LastModified>2010-11-10T20:48:34.000Z</LastModified>
228            <ETag>"7778aef83f66abc1fa1e8477f296d394"</ETag>
229            <Size>10485760</Size>
230          </Part>
231          <Part>
232            <PartNumber>3</PartNumber>
233            <LastModified>2010-11-10T20:48:33.000Z</LastModified>
234            <ETag>"aaaa18db4cc2f85cedef654fccc4a4x8"</ETag>
235            <Size>10485760</Size>
236          </Part>
237        </ListPartsResult>
238        "#;
239
240        let parsed = ListParts::parse_response(input).unwrap();
241        assert_eq!(parsed.parts.len(), 2);
242
243        let part_1 = &parsed.parts[0];
244        assert_eq!(part_1.etag, "\"7778aef83f66abc1fa1e8477f296d394\"");
245        assert_eq!(part_1.number, 2);
246        assert_eq!(part_1.last_modified, "2010-11-10T20:48:34.000Z");
247        assert_eq!(part_1.size, 10485760);
248
249        let part_2 = &parsed.parts[1];
250        assert_eq!(part_2.etag, "\"aaaa18db4cc2f85cedef654fccc4a4x8\"");
251        assert_eq!(part_2.number, 3);
252        assert_eq!(part_2.last_modified, "2010-11-10T20:48:33.000Z");
253        assert_eq!(part_2.size, 10485760);
254
255        assert_eq!(parsed.max_parts, 2);
256        assert_eq!(parsed.next_part_number_marker, Some(3));
257    }
258
259    #[test]
260    fn parse_no_parts() {
261        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
262        <ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
263          <Bucket>example-bucket</Bucket>
264          <Key>example-object</Key>
265          <UploadId>XXBsb2FkIElEIGZvciBlbHZpbmcncyVcdS1tb3ZpZS5tMnRzEEEwbG9hZA</UploadId>
266          <Initiator>
267              <ID>arn:aws:iam::111122223333:user/some-user-11116a31-17b5-4fb7-9df5-b288870f11xx</ID>
268              <DisplayName>umat-user-11116a31-17b5-4fb7-9df5-b288870f11xx</DisplayName>
269          </Initiator>
270          <Owner>
271            <ID>75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a</ID>
272            <DisplayName>someName</DisplayName>
273          </Owner>
274          <StorageClass>STANDARD</StorageClass>
275          <PartNumberMarker>1</PartNumberMarker>
276          <MaxParts>2</MaxParts>
277          <IsTruncated>false</IsTruncated>
278        </ListPartsResult>
279        "#;
280
281        let parsed = ListParts::parse_response(input).unwrap();
282        assert!(parsed.parts.is_empty());
283        assert_eq!(parsed.max_parts, 2);
284        assert!(parsed.next_part_number_marker.is_none());
285    }
286}