Skip to main content

rustack_s3_core/ops/
list.rs

1//! List operation handlers.
2//!
3//! Implements `list_objects` (v1), `list_objects_v2`, and `list_object_versions`.
4
5use rustack_s3_model::{
6    error::S3Error,
7    input::{ListObjectVersionsInput, ListObjectsInput, ListObjectsV2Input},
8    output::{ListObjectVersionsOutput, ListObjectsOutput, ListObjectsV2Output},
9    types::{
10        CommonPrefix, DeleteMarkerEntry, Object, ObjectStorageClass, ObjectVersion,
11        ObjectVersionStorageClass, Owner,
12    },
13};
14use tracing::debug;
15
16use crate::{
17    error::S3ServiceError,
18    provider::RustackS3,
19    state::{keystore::VersionListEntry, object::Owner as InternalOwner},
20    utils::{decode_continuation_token, encode_continuation_token},
21};
22
23/// Default maximum number of keys returned in a single listing response.
24const DEFAULT_MAX_KEYS: i32 = 1000;
25
26/// Validate the `max_keys` parameter, rejecting negative values.
27///
28/// # Errors
29///
30/// Returns [`S3Error`] with [`S3ErrorCode::InvalidArgument`] if `max_keys` is negative.
31#[allow(clippy::result_large_err)]
32fn validate_max_keys(max_keys: Option<i32>) -> Result<i32, S3Error> {
33    let value = max_keys.unwrap_or(DEFAULT_MAX_KEYS);
34    if value < 0 {
35        return Err(S3ServiceError::InvalidArgument {
36            message: format!(
37                "Argument max-keys must be an integer between 0 and {DEFAULT_MAX_KEYS}"
38            ),
39        }
40        .into_s3_error());
41    }
42    Ok(value)
43}
44
45/// Convert an internal [`crate::state::object::S3Object`] to a model [`Object`].
46#[allow(clippy::cast_possible_wrap)]
47fn to_model_object(obj: &crate::state::object::S3Object) -> Object {
48    let owner = Owner {
49        display_name: Some(obj.owner.display_name.clone()),
50        id: Some(obj.owner.id.clone()),
51    };
52    Object {
53        checksum_algorithm: Vec::new(),
54        checksum_type: None,
55        e_tag: Some(obj.etag.clone()),
56        key: Some(obj.key.clone()),
57        last_modified: Some(obj.last_modified),
58        owner: Some(owner),
59        restore_status: None,
60        size: Some(obj.size as i64),
61        storage_class: Some(ObjectStorageClass::from(obj.storage_class.as_str())),
62    }
63}
64
65/// Convert an internal [`InternalOwner`] to a model [`Owner`].
66fn to_model_owner(owner: &InternalOwner) -> Owner {
67    Owner {
68        display_name: Some(owner.display_name.clone()),
69        id: Some(owner.id.clone()),
70    }
71}
72
73/// Convert common prefix strings to model [`CommonPrefix`] values.
74fn to_common_prefixes(prefixes: &[String]) -> Vec<CommonPrefix> {
75    prefixes
76        .iter()
77        .map(|p| CommonPrefix {
78            prefix: Some(p.clone()),
79        })
80        .collect()
81}
82
83// AWS S3 DTOs use signed integers (i32/i64) for inherently non-negative values.
84// These handler methods must remain async for consistency with other handlers.
85#[allow(
86    clippy::cast_possible_wrap,
87    clippy::cast_possible_truncation,
88    clippy::cast_sign_loss,
89    clippy::unused_async
90)]
91impl RustackS3 {
92    /// List objects (v1 API).
93    pub async fn handle_list_objects(
94        &self,
95        input: ListObjectsInput,
96    ) -> Result<ListObjectsOutput, S3Error> {
97        let bucket_name = input.bucket;
98
99        let bucket = self
100            .state
101            .get_bucket(&bucket_name)
102            .map_err(S3ServiceError::into_s3_error)?;
103
104        let prefix = input.prefix.as_deref().unwrap_or("");
105        let delimiter = input.delimiter.as_deref().unwrap_or("");
106        let marker = input.marker.as_deref().unwrap_or("");
107        let max_keys = validate_max_keys(input.max_keys)?;
108        let max_keys_usize = usize::try_from(max_keys).unwrap_or(1000);
109
110        let store = bucket.objects.read();
111        let result = store.list_objects(prefix, delimiter, marker, max_keys_usize);
112        drop(store);
113        drop(bucket);
114
115        let contents: Vec<Object> = result.objects.iter().map(to_model_object).collect();
116        let common_prefixes = to_common_prefixes(&result.common_prefixes);
117
118        let next_marker = if result.is_truncated {
119            result.next_marker.clone()
120        } else {
121            None
122        };
123
124        debug!(
125            bucket = %bucket_name,
126            prefix = %prefix,
127            count = contents.len(),
128            is_truncated = result.is_truncated,
129            "list_objects completed"
130        );
131
132        Ok(ListObjectsOutput {
133            common_prefixes,
134            contents,
135            delimiter: input.delimiter,
136            encoding_type: input.encoding_type,
137            is_truncated: Some(result.is_truncated),
138            marker: input.marker,
139            max_keys: Some(max_keys),
140            name: Some(bucket_name),
141            next_marker,
142            prefix: input.prefix,
143            request_charged: None,
144        })
145    }
146
147    /// List objects (v2 API with continuation tokens).
148    pub async fn handle_list_objects_v2(
149        &self,
150        input: ListObjectsV2Input,
151    ) -> Result<ListObjectsV2Output, S3Error> {
152        let bucket_name = input.bucket;
153
154        let bucket = self
155            .state
156            .get_bucket(&bucket_name)
157            .map_err(S3ServiceError::into_s3_error)?;
158
159        let prefix = input.prefix.as_deref().unwrap_or("");
160        let delimiter = input.delimiter.as_deref().unwrap_or("");
161        let max_keys = validate_max_keys(input.max_keys)?;
162        let max_keys_usize = usize::try_from(max_keys).unwrap_or(1000);
163        let fetch_owner = input.fetch_owner.unwrap_or(false);
164
165        // Determine start_after: either from continuation token or start_after param.
166        let decoded_token = if let Some(token) = &input.continuation_token {
167            Some(decode_continuation_token(token).map_err(S3ServiceError::into_s3_error)?)
168        } else {
169            None
170        };
171        let start_after = decoded_token
172            .as_deref()
173            .or(input.start_after.as_deref())
174            .unwrap_or("");
175
176        let store = bucket.objects.read();
177        let result = store.list_objects(prefix, delimiter, start_after, max_keys_usize);
178        drop(store);
179        drop(bucket);
180
181        let contents: Vec<Object> = result
182            .objects
183            .iter()
184            .map(|obj| {
185                let mut s3_obj = to_model_object(obj);
186                if !fetch_owner {
187                    s3_obj.owner = None;
188                }
189                s3_obj
190            })
191            .collect();
192        let common_prefixes = to_common_prefixes(&result.common_prefixes);
193
194        let next_continuation_token = if result.is_truncated {
195            result
196                .next_marker
197                .as_ref()
198                .map(|m| encode_continuation_token(m))
199        } else {
200            None
201        };
202
203        let key_count = contents.len() as i32;
204
205        debug!(
206            bucket = %bucket_name,
207            prefix = %prefix,
208            count = key_count,
209            is_truncated = result.is_truncated,
210            "list_objects_v2 completed"
211        );
212
213        Ok(ListObjectsV2Output {
214            common_prefixes,
215            contents,
216            continuation_token: input.continuation_token,
217            delimiter: input.delimiter,
218            encoding_type: input.encoding_type,
219            is_truncated: Some(result.is_truncated),
220            key_count: Some(key_count),
221            max_keys: Some(max_keys),
222            name: Some(bucket_name),
223            next_continuation_token,
224            prefix: input.prefix,
225            request_charged: None,
226            start_after: input.start_after,
227        })
228    }
229
230    /// List object versions.
231    pub async fn handle_list_object_versions(
232        &self,
233        input: ListObjectVersionsInput,
234    ) -> Result<ListObjectVersionsOutput, S3Error> {
235        // S3 requires KeyMarker when VersionIdMarker is specified.
236        if input.version_id_marker.is_some() && input.key_marker.is_none() {
237            return Err(S3Error::invalid_argument(
238                "A version-id marker cannot be specified without a key marker",
239            ));
240        }
241
242        let bucket_name = input.bucket;
243
244        let bucket = self
245            .state
246            .get_bucket(&bucket_name)
247            .map_err(S3ServiceError::into_s3_error)?;
248
249        let prefix = input.prefix.as_deref().unwrap_or("");
250        let delimiter = input.delimiter.as_deref().unwrap_or("");
251        let key_marker = input.key_marker.as_deref().unwrap_or("");
252        let version_id_marker = input.version_id_marker.as_deref().unwrap_or("");
253        let max_keys = validate_max_keys(input.max_keys)?;
254        let max_keys_usize = usize::try_from(max_keys).unwrap_or(1000);
255
256        let store = bucket.objects.read();
257        let result = store.list_object_versions(
258            prefix,
259            delimiter,
260            key_marker,
261            version_id_marker,
262            max_keys_usize,
263        );
264        drop(store);
265        drop(bucket);
266
267        // Separate versions and delete markers.
268        let (versions, delete_markers) = partition_version_list_entries(&result.versions);
269
270        let common_prefixes = to_common_prefixes(&result.common_prefixes);
271
272        debug!(
273            bucket = %bucket_name,
274            prefix = %prefix,
275            versions = versions.len(),
276            delete_markers = delete_markers.len(),
277            is_truncated = result.is_truncated,
278            "list_object_versions completed"
279        );
280
281        Ok(ListObjectVersionsOutput {
282            common_prefixes,
283            delete_markers,
284            delimiter: input.delimiter,
285            encoding_type: input.encoding_type,
286            is_truncated: Some(result.is_truncated),
287            key_marker: input.key_marker,
288            max_keys: Some(max_keys),
289            name: Some(bucket_name),
290            next_key_marker: result.next_key_marker,
291            next_version_id_marker: result.next_version_id_marker,
292            prefix: input.prefix,
293            request_charged: None,
294            version_id_marker: input.version_id_marker,
295            versions,
296        })
297    }
298}
299
300/// Partition a list of [`VersionListEntry`] into model [`ObjectVersion`] and
301/// [`DeleteMarkerEntry`] values.
302#[allow(clippy::cast_possible_wrap)]
303fn partition_version_list_entries(
304    entries: &[VersionListEntry],
305) -> (Vec<ObjectVersion>, Vec<DeleteMarkerEntry>) {
306    let mut versions = Vec::new();
307    let mut delete_markers = Vec::new();
308
309    for entry in entries {
310        match &entry.version {
311            crate::state::object::ObjectVersion::Object(obj) => {
312                let owner = to_model_owner(&obj.owner);
313                versions.push(ObjectVersion {
314                    checksum_algorithm: Vec::new(),
315                    checksum_type: None,
316                    e_tag: Some(obj.etag.clone()),
317                    is_latest: Some(entry.is_latest),
318                    key: Some(obj.key.clone()),
319                    last_modified: Some(obj.last_modified),
320                    owner: Some(owner),
321                    restore_status: None,
322                    size: Some(obj.size as i64),
323                    storage_class: Some(ObjectVersionStorageClass::from(
324                        obj.storage_class.as_str(),
325                    )),
326                    version_id: Some(obj.version_id.clone()),
327                });
328            }
329            crate::state::object::ObjectVersion::DeleteMarker(dm) => {
330                let owner = to_model_owner(&dm.owner);
331                delete_markers.push(DeleteMarkerEntry {
332                    is_latest: Some(entry.is_latest),
333                    key: Some(dm.key.clone()),
334                    last_modified: Some(dm.last_modified),
335                    owner: Some(owner),
336                    version_id: Some(dm.version_id.clone()),
337                });
338            }
339        }
340    }
341
342    (versions, delete_markers)
343}