1use std::borrow::Cow;
2use std::io::{BufReader, Read};
3use std::time::Duration;
4
5use jiff::Timestamp;
6use serde::Deserialize;
7use url::Url;
8
9use crate::actions::Method;
10use crate::actions::S3Action;
11use crate::signing::sign;
12use crate::{Bucket, Credentials, Map};
13
14#[allow(clippy::module_name_repetitions)]
25#[derive(Debug, Clone)]
26pub struct ListObjectsV2<'a> {
27 bucket: &'a Bucket,
28 credentials: Option<&'a Credentials>,
29
30 query: Map<'a>,
31 headers: Map<'a>,
32}
33
34#[allow(clippy::module_name_repetitions)]
35#[derive(Debug, Clone, Deserialize)]
36pub struct ListObjectsV2Response {
37 #[serde(rename = "Contents")]
40 #[serde(default)]
41 pub contents: Vec<ListObjectsContent>,
42
43 #[serde(rename = "MaxKeys")]
50 pub max_keys: Option<u16>,
51 #[serde(rename = "CommonPrefixes", default)]
52 pub common_prefixes: Vec<CommonPrefixes>,
53 #[serde(rename = "NextContinuationToken")]
60 pub next_continuation_token: Option<String>,
61 #[serde(rename = "StartAfter")]
62 pub start_after: Option<String>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct ListObjectsContent {
67 #[serde(rename = "ETag")]
68 pub etag: String,
69 #[serde(rename = "Key")]
70 pub key: String,
71 #[serde(rename = "LastModified")]
72 pub last_modified: String,
73 #[serde(rename = "Owner")]
74 pub owner: Option<ListObjectsOwner>,
75 #[serde(rename = "Size")]
76 pub size: u64,
77 #[serde(rename = "StorageClass")]
78 pub storage_class: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82pub struct ListObjectsOwner {
83 #[serde(rename = "ID")]
84 pub id: String,
85 #[serde(rename = "DisplayName")]
86 pub display_name: String,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90pub struct CommonPrefixes {
91 #[serde(rename = "Prefix")]
92 pub prefix: String,
93}
94
95impl<'a> ListObjectsV2<'a> {
96 #[must_use]
97 pub fn new(bucket: &'a Bucket, credentials: Option<&'a Credentials>) -> Self {
98 let mut query = Map::new();
99 query.insert("list-type", "2");
100 query.insert("encoding-type", "url");
101
102 Self {
103 bucket,
104 credentials,
105
106 query,
107 headers: Map::new(),
108 }
109 }
110
111 pub fn with_prefix(&mut self, prefix: impl Into<Cow<'a, str>>) {
121 self.query_mut().insert("prefix", prefix);
122 }
123
124 pub fn with_delimiter(&mut self, delimiter: impl Into<Cow<'a, str>>) {
134 self.query_mut().insert("delimiter", delimiter);
135 }
136
137 pub fn with_start_after(&mut self, start_after: impl Into<Cow<'a, str>>) {
149 self.query_mut().insert("start-after", start_after);
150 }
151
152 pub fn with_continuation_token(&mut self, continuation_token: impl Into<Cow<'a, str>>) {
163 self.query_mut()
164 .insert("continuation-token", continuation_token);
165 }
166
167 pub fn with_max_keys(&mut self, max_keys: usize) {
179 self.query_mut().insert("max-keys", max_keys.to_string());
180 }
181
182 pub fn parse_response(
188 s: impl AsRef<[u8]>,
189 ) -> Result<ListObjectsV2Response, quick_xml::DeError> {
190 Self::parse_response_from_reader(&mut s.as_ref())
191 }
192
193 pub fn parse_response_from_reader(
199 s: impl Read,
200 ) -> Result<ListObjectsV2Response, quick_xml::DeError> {
201 let mut parsed: ListObjectsV2Response = quick_xml::de::from_reader(BufReader::new(s))?;
202
203 for content in &mut parsed.contents {
205 if let Some(owner) = &content.owner {
206 if owner.id.is_empty() && owner.display_name.is_empty() {
207 content.owner = None;
208 }
209 }
210 }
211
212 Ok(parsed)
213 }
214}
215
216impl<'a> S3Action<'a> for ListObjectsV2<'a> {
217 const METHOD: Method = Method::Get;
218
219 fn query_mut(&mut self) -> &mut Map<'a> {
220 &mut self.query
221 }
222
223 fn headers_mut(&mut self) -> &mut Map<'a> {
224 &mut self.headers
225 }
226
227 fn sign_with_time(&self, expires_in: Duration, time: &Timestamp) -> Url {
228 let url = self.bucket.base_url().clone();
229
230 match self.credentials {
231 Some(credentials) => sign(
232 time,
233 Self::METHOD,
234 url,
235 credentials.key(),
236 credentials.secret(),
237 credentials.token(),
238 self.bucket.region(),
239 expires_in.as_secs(),
240 self.query.iter(),
241 self.headers.iter(),
242 ),
243 None => crate::signing::util::add_query_params(url, self.query.iter()),
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use pretty_assertions::assert_eq;
251
252 use super::*;
253 use crate::{Bucket, Credentials, UrlStyle};
254
255 #[test]
256 fn aws_example() {
257 let date = Timestamp::from_second(1369353600).unwrap();
259 let expires_in = Duration::from_secs(86400);
260
261 let endpoint = "https://s3.amazonaws.com".parse().unwrap();
262 let bucket = Bucket::new(
263 endpoint,
264 UrlStyle::VirtualHost,
265 "examplebucket",
266 "us-east-1",
267 )
268 .unwrap();
269 let credentials = Credentials::new(
270 "AKIAIOSFODNN7EXAMPLE",
271 "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
272 );
273
274 let action = ListObjectsV2::new(&bucket, Some(&credentials));
275
276 let url = action.sign_with_time(expires_in, &date);
277 let expected = "https://examplebucket.s3.amazonaws.com/?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&encoding-type=url&list-type=2&X-Amz-Signature=58e7f65928710f045f6a7e1f7a32b3426b4895900fad799db66faa3ff8b18bd5";
278
279 assert_eq!(expected, url.as_str());
280 }
281
282 #[test]
283 fn anonymous_custom_query() {
284 let expires_in = Duration::from_secs(86400);
285
286 let endpoint = "https://s3.amazonaws.com".parse().unwrap();
287 let bucket = Bucket::new(
288 endpoint,
289 UrlStyle::VirtualHost,
290 "examplebucket",
291 "us-east-1",
292 )
293 .unwrap();
294
295 let mut action = ListObjectsV2::new(&bucket, None);
296 action.query_mut().insert("continuation-token", "duck");
297
298 let url = action.sign(expires_in);
299 let expected = "https://examplebucket.s3.amazonaws.com/?continuation-token=duck&encoding-type=url&list-type=2";
300
301 assert_eq!(expected, url.as_str());
302 }
303
304 #[test]
305 fn parse() {
306 let input = r#"
307 <?xml version="1.0" encoding="UTF-8"?>
308 <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
309 <Name>test</Name>
310 <Prefix></Prefix>
311 <KeyCount>3</KeyCount>
312 <MaxKeys>4500</MaxKeys>
313 <Delimiter></Delimiter>
314 <IsTruncated>false</IsTruncated>
315 <Contents>
316 <Key>duck.jpg</Key>
317 <LastModified>2020-12-01T20:43:11.794Z</LastModified>
318 <ETag>"bfd537a51d15208163231b0711e0b1f3"</ETag>
319 <Size>4274</Size>
320 <Owner>
321 <ID></ID>
322 <DisplayName></DisplayName>
323 </Owner>
324 <StorageClass>STANDARD</StorageClass>
325 </Contents>
326 <Contents>
327 <Key>idk.txt</Key>
328 <LastModified>2020-12-05T08:23:52.215Z</LastModified>
329 <ETag>"5927c5d64d94a5786f90003aa26d0159-1"</ETag>
330 <Size>9</Size>
331 <Owner>
332 <ID></ID>
333 <DisplayName></DisplayName>
334 </Owner>
335 <StorageClass>STANDARD</StorageClass>
336 </Contents>
337 <Contents>
338 <Key>img.jpg</Key>
339 <LastModified>2020-11-26T20:21:35.858Z</LastModified>
340 <ETag>"f7dbec93a0932ccb4d0f4e512eb1a443"</ETag>
341 <Size>41259</Size>
342 <Owner>
343 <ID></ID>
344 <DisplayName></DisplayName>
345 </Owner>
346 <StorageClass>STANDARD</StorageClass>
347 </Contents>
348 <EncodingType>url</EncodingType>
349 </ListBucketResult>
350 "#;
351
352 let parsed = ListObjectsV2::parse_response(input).unwrap();
353 assert_eq!(parsed.contents.len(), 3);
354
355 let item_1 = &parsed.contents[0];
356 assert_eq!(item_1.etag, "\"bfd537a51d15208163231b0711e0b1f3\"");
357 assert_eq!(item_1.key, "duck.jpg");
358 assert_eq!(item_1.last_modified, "2020-12-01T20:43:11.794Z");
359 assert!(item_1.owner.is_none());
360 assert_eq!(item_1.size, 4274);
361 assert_eq!(item_1.storage_class, Some("STANDARD".to_string()));
362
363 let item_2 = &parsed.contents[1];
364 assert_eq!(item_2.etag, "\"5927c5d64d94a5786f90003aa26d0159-1\"");
365 assert_eq!(item_2.key, "idk.txt");
366 assert_eq!(item_2.last_modified, "2020-12-05T08:23:52.215Z");
367 assert!(item_2.owner.is_none());
368 assert_eq!(item_2.size, 9);
369 assert_eq!(item_2.storage_class, Some("STANDARD".to_string()));
370
371 let item_3 = &parsed.contents[2];
372 assert_eq!(item_3.etag, "\"f7dbec93a0932ccb4d0f4e512eb1a443\"");
373 assert_eq!(item_3.key, "img.jpg");
374 assert_eq!(item_3.last_modified, "2020-11-26T20:21:35.858Z");
375 assert!(item_3.owner.is_none());
376 assert_eq!(item_3.size, 41259);
377 assert_eq!(item_3.storage_class, Some("STANDARD".to_string()));
378
379 assert_eq!(parsed.max_keys, Some(4500));
380 assert!(parsed.common_prefixes.is_empty());
381 assert!(parsed.next_continuation_token.is_none());
382 assert!(parsed.start_after.is_none());
383 }
384
385 #[test]
386 fn parse_no_contents() {
387 let input = r#"
388 <?xml version="1.0" encoding="UTF-8"?>
389 <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
390 <Name>test</Name>
391 <Prefix></Prefix>
392 <KeyCount>0</KeyCount>
393 <MaxKeys>4500</MaxKeys>
394 <Delimiter></Delimiter>
395 <IsTruncated>false</IsTruncated>
396 <EncodingType>url</EncodingType>
397 </ListBucketResult>
398 "#;
399
400 let parsed = ListObjectsV2::parse_response(input).unwrap();
401 assert_eq!(parsed.contents.is_empty(), true);
402
403 assert_eq!(parsed.max_keys, Some(4500));
404 assert!(parsed.common_prefixes.is_empty());
405 assert!(parsed.next_continuation_token.is_none());
406 assert!(parsed.start_after.is_none());
407 }
408}