Skip to content

Commit 1d61dfc

Browse files
committed
feat(core): support ListObjectsV1
1 parent a5c5b9f commit 1d61dfc

File tree

5 files changed

+375
-30
lines changed

5 files changed

+375
-30
lines changed

crates/core/src/api/list.rs

Lines changed: 219 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Extracted from `proxy.rs` to keep the gateway focused on orchestration.
44
55
use crate::api::list_rewrite::ListRewrite;
6-
use crate::api::response::{ListBucketResult, ListCommonPrefix, ListContents};
6+
use crate::api::response::{ListBucketResult, ListBucketResultV1, ListCommonPrefix, ListContents};
77
use crate::error::ProxyError;
88
use crate::types::BucketConfig;
99

@@ -29,12 +29,16 @@ pub struct ListQueryParams {
2929
pub delimiter: String,
3030
/// Maximum number of keys to return per page (capped at 1000).
3131
pub max_keys: usize,
32-
/// Opaque token for fetching the next page of results.
32+
/// Opaque token for fetching the next page of results (V2 only).
3333
pub continuation_token: Option<String>,
34-
/// Return keys lexicographically after this value.
34+
/// Return keys lexicographically after this value (V2 only).
3535
pub start_after: Option<String>,
3636
/// Encoding type for keys/prefixes in the response (e.g. "url").
3737
pub encoding_type: Option<String>,
38+
/// V1 pagination marker — return keys after this value.
39+
pub marker: Option<String>,
40+
/// Whether this is a V2 list request (`list-type=2`).
41+
pub is_v2: bool,
3842
}
3943

4044
/// Parse prefix, delimiter, and pagination params from a LIST query string in one pass.
@@ -45,6 +49,8 @@ pub fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams {
4549
let mut continuation_token = None;
4650
let mut start_after = None;
4751
let mut encoding_type = None;
52+
let mut marker = None;
53+
let mut is_v2 = false;
4854

4955
if let Some(q) = raw_query {
5056
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
@@ -55,6 +61,12 @@ pub fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams {
5561
"continuation-token" => continuation_token = Some(v.into_owned()),
5662
"start-after" => start_after = Some(v.into_owned()),
5763
"encoding-type" => encoding_type = Some(v.into_owned()),
64+
"marker" => marker = Some(v.into_owned()),
65+
"list-type" => {
66+
if v.as_ref() == "2" {
67+
is_v2 = true;
68+
}
69+
}
5870
_ => {}
5971
}
6072
}
@@ -70,6 +82,8 @@ pub fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams {
7082
continuation_token,
7183
start_after,
7284
encoding_type,
85+
marker,
86+
is_v2,
7387
}
7488
}
7589

@@ -194,6 +208,112 @@ pub(crate) fn build_list_xml(
194208
.to_xml())
195209
}
196210

211+
/// Parameters for building the S3 ListObjectsV1 XML response.
212+
pub(crate) struct ListXmlParamsV1<'a> {
213+
pub bucket_name: &'a str,
214+
pub client_prefix: &'a str,
215+
pub delimiter: &'a str,
216+
pub max_keys: usize,
217+
pub is_truncated: bool,
218+
pub marker: &'a str,
219+
pub next_marker: Option<String>,
220+
pub encoding_type: &'a Option<String>,
221+
}
222+
223+
/// Build S3 ListObjectsV1 XML from an object_store ListResult.
224+
pub(crate) fn build_list_xml_v1(
225+
params: &ListXmlParamsV1<'_>,
226+
list_result: &object_store::ListResult,
227+
config: &BucketConfig,
228+
list_rewrite: Option<&ListRewrite>,
229+
) -> Result<String, ProxyError> {
230+
let backend_prefix = config
231+
.backend_prefix
232+
.as_deref()
233+
.unwrap_or("")
234+
.trim_end_matches('/');
235+
let strip_prefix = if backend_prefix.is_empty() {
236+
String::new()
237+
} else {
238+
format!("{}/", backend_prefix)
239+
};
240+
241+
let mut contents: Vec<ListContents> = list_result
242+
.objects
243+
.iter()
244+
.map(|obj| {
245+
let raw_key = obj.location.to_string();
246+
ListContents {
247+
key: rewrite_key(&raw_key, &strip_prefix, list_rewrite),
248+
last_modified: obj
249+
.last_modified
250+
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
251+
.to_string(),
252+
etag: obj.e_tag.as_deref().unwrap_or("\"\"").to_string(),
253+
size: obj.size,
254+
storage_class: "STANDARD",
255+
}
256+
})
257+
.collect();
258+
259+
let mut common_prefixes: Vec<ListCommonPrefix> = list_result
260+
.common_prefixes
261+
.iter()
262+
.map(|p| {
263+
let raw_prefix = format!("{}/", p);
264+
ListCommonPrefix {
265+
prefix: rewrite_key(&raw_prefix, &strip_prefix, list_rewrite),
266+
}
267+
})
268+
.collect();
269+
270+
let url_encode = matches!(params.encoding_type, Some(ref t) if t == "url");
271+
let encode = |s: String| -> String {
272+
if url_encode {
273+
const S3_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
274+
.remove(b'-')
275+
.remove(b'.')
276+
.remove(b'_')
277+
.remove(b'~')
278+
.remove(b'/');
279+
percent_encoding::utf8_percent_encode(&s, S3_ENCODE_SET).to_string()
280+
} else {
281+
s
282+
}
283+
};
284+
285+
let prefix_value = match list_rewrite {
286+
Some(rewrite) if !rewrite.add_prefix.is_empty() => {
287+
format!("{}{}", rewrite.add_prefix, params.client_prefix)
288+
}
289+
_ => params.client_prefix.to_string(),
290+
};
291+
292+
if url_encode {
293+
for item in &mut contents {
294+
item.key = encode(std::mem::take(&mut item.key));
295+
}
296+
for cp in &mut common_prefixes {
297+
cp.prefix = encode(std::mem::take(&mut cp.prefix));
298+
}
299+
}
300+
301+
Ok(ListBucketResultV1 {
302+
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
303+
name: params.bucket_name.to_string(),
304+
prefix: encode(prefix_value),
305+
delimiter: encode(params.delimiter.to_string()),
306+
encoding_type: params.encoding_type.clone(),
307+
max_keys: params.max_keys,
308+
is_truncated: params.is_truncated,
309+
marker: params.marker.to_string(),
310+
next_marker: params.next_marker.clone(),
311+
contents,
312+
common_prefixes,
313+
}
314+
.to_xml())
315+
}
316+
197317
/// Apply strip/add prefix rewriting to a key or prefix value.
198318
///
199319
/// Works with `&str` slices to avoid intermediate allocations — only allocates
@@ -478,4 +598,100 @@ mod tests {
478598
let params = parse_list_query_params(Some("list-type=2&prefix=test/"));
479599
assert_eq!(params.encoding_type, None);
480600
}
601+
602+
#[test]
603+
fn test_parse_list_query_params_v1_vs_v2() {
604+
// V2: list-type=2 present
605+
let params = parse_list_query_params(Some("list-type=2&prefix=test/"));
606+
assert!(params.is_v2);
607+
assert_eq!(params.marker, None);
608+
609+
// V1: no list-type param
610+
let params = parse_list_query_params(Some("prefix=test/&marker=key123"));
611+
assert!(!params.is_v2);
612+
assert_eq!(params.marker, Some("key123".to_string()));
613+
614+
// V1: list-type=1 (not 2)
615+
let params = parse_list_query_params(Some("list-type=1&marker=abc"));
616+
assert!(!params.is_v2);
617+
assert_eq!(params.marker, Some("abc".to_string()));
618+
619+
// No query string at all → V1 default
620+
let params = parse_list_query_params(None);
621+
assert!(!params.is_v2);
622+
assert_eq!(params.marker, None);
623+
}
624+
625+
#[test]
626+
fn test_build_list_xml_v1_basic() {
627+
let config = make_config(None);
628+
let list_result = make_list_result(&["photos/image.jpg"], &["photos/thumbs"]);
629+
630+
let params = ListXmlParamsV1 {
631+
bucket_name: "my-bucket",
632+
client_prefix: "photos/",
633+
delimiter: "/",
634+
max_keys: 1000,
635+
is_truncated: false,
636+
marker: "photos/abc.jpg",
637+
next_marker: None,
638+
encoding_type: &None,
639+
};
640+
641+
let xml = build_list_xml_v1(&params, &list_result, &config, None).unwrap();
642+
643+
// V1 elements present
644+
assert!(
645+
xml.contains("<Marker>photos/abc.jpg</Marker>"),
646+
"Missing Marker: {xml}"
647+
);
648+
assert!(xml.contains("<Name>my-bucket</Name>"));
649+
assert!(xml.contains("<Prefix>photos/</Prefix>"));
650+
assert!(xml.contains("<Key>photos/image.jpg</Key>"));
651+
assert!(xml.contains("<CommonPrefixes><Prefix>photos/thumbs/</Prefix></CommonPrefixes>"));
652+
653+
// V2 elements must NOT be present
654+
assert!(
655+
!xml.contains("<KeyCount>"),
656+
"V1 should not have KeyCount: {xml}"
657+
);
658+
assert!(
659+
!xml.contains("<StartAfter>"),
660+
"V1 should not have StartAfter: {xml}"
661+
);
662+
assert!(
663+
!xml.contains("<ContinuationToken>"),
664+
"V1 should not have ContinuationToken: {xml}"
665+
);
666+
assert!(
667+
!xml.contains("<NextMarker>"),
668+
"NextMarker should be absent when not truncated: {xml}"
669+
);
670+
}
671+
672+
#[test]
673+
fn test_build_list_xml_v1_truncated_with_next_marker() {
674+
let config = make_config(None);
675+
let list_result = make_list_result(&["a.txt", "b.txt"], &[]);
676+
677+
let params = ListXmlParamsV1 {
678+
bucket_name: "bucket",
679+
client_prefix: "",
680+
delimiter: "/",
681+
max_keys: 2,
682+
is_truncated: true,
683+
marker: "",
684+
next_marker: Some("b.txt".to_string()),
685+
encoding_type: &None,
686+
};
687+
688+
let xml = build_list_xml_v1(&params, &list_result, &config, None).unwrap();
689+
690+
assert!(xml.contains("<IsTruncated>true</IsTruncated>"));
691+
assert!(
692+
xml.contains("<NextMarker>b.txt</NextMarker>"),
693+
"Expected NextMarker: {xml}"
694+
);
695+
assert!(xml.contains("<Marker></Marker>") || xml.contains("<Marker/>"));
696+
}
481697
}

crates/core/src/api/response.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,55 @@ impl ListBucketResult {
254254
}
255255
}
256256

257+
/// S3 ListObjectsV1 response.
258+
#[derive(Debug, Serialize)]
259+
#[serde(rename = "ListBucketResult")]
260+
pub struct ListBucketResultV1 {
261+
/// XML namespace URI for the S3 ListBucketResult schema.
262+
#[serde(rename = "@xmlns")]
263+
pub xmlns: &'static str,
264+
/// The bucket name.
265+
#[serde(rename = "Name")]
266+
pub name: String,
267+
/// The key prefix used to filter results.
268+
#[serde(rename = "Prefix")]
269+
pub prefix: String,
270+
/// The delimiter used to group common prefixes.
271+
#[serde(rename = "Delimiter", skip_serializing_if = "String::is_empty")]
272+
pub delimiter: String,
273+
/// Encoding type applied to keys and prefixes in this response.
274+
#[serde(rename = "EncodingType", skip_serializing_if = "Option::is_none")]
275+
pub encoding_type: Option<String>,
276+
/// Maximum number of keys returned per page.
277+
#[serde(rename = "MaxKeys")]
278+
pub max_keys: usize,
279+
/// Whether additional pages of results are available.
280+
#[serde(rename = "IsTruncated")]
281+
pub is_truncated: bool,
282+
/// The marker from the request, echoed back.
283+
#[serde(rename = "Marker")]
284+
pub marker: String,
285+
/// When `IsTruncated` is true, the key to use as `marker` in the next request.
286+
#[serde(rename = "NextMarker", skip_serializing_if = "Option::is_none")]
287+
pub next_marker: Option<String>,
288+
/// The object entries matching the list request.
289+
#[serde(rename = "Contents", default)]
290+
pub contents: Vec<ListContents>,
291+
/// Common prefix entries when a delimiter is used.
292+
#[serde(rename = "CommonPrefixes", default)]
293+
pub common_prefixes: Vec<ListCommonPrefix>,
294+
}
295+
296+
impl ListBucketResultV1 {
297+
/// Serialize this result to an S3-compatible XML string.
298+
pub fn to_xml(&self) -> String {
299+
format!(
300+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
301+
xml_to_string(self).unwrap_or_default()
302+
)
303+
}
304+
}
305+
257306
#[cfg(test)]
258307
mod tests {
259308
use super::*;
@@ -318,4 +367,44 @@ mod tests {
318367
assert!(!xml.contains("<Contents>"));
319368
assert!(!xml.contains("<CommonPrefixes>"));
320369
}
370+
371+
#[test]
372+
fn test_list_bucket_result_v1_xml() {
373+
let result = ListBucketResultV1 {
374+
xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
375+
name: "my-bucket".to_string(),
376+
prefix: "photos/".to_string(),
377+
delimiter: "/".to_string(),
378+
encoding_type: None,
379+
max_keys: 1000,
380+
is_truncated: true,
381+
marker: "photos/a.jpg".to_string(),
382+
next_marker: Some("photos/z.jpg".to_string()),
383+
contents: vec![ListContents {
384+
key: "photos/image.jpg".to_string(),
385+
last_modified: "2024-01-01T00:00:00.000Z".to_string(),
386+
etag: "\"abc123\"".to_string(),
387+
size: 1024,
388+
storage_class: "STANDARD",
389+
}],
390+
common_prefixes: vec![ListCommonPrefix {
391+
prefix: "photos/thumbs/".to_string(),
392+
}],
393+
};
394+
395+
let xml = result.to_xml();
396+
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
397+
assert!(xml.contains("<Marker>photos/a.jpg</Marker>"));
398+
assert!(xml.contains("<NextMarker>photos/z.jpg</NextMarker>"));
399+
assert!(xml.contains("<Name>my-bucket</Name>"));
400+
assert!(xml.contains("<Key>photos/image.jpg</Key>"));
401+
402+
// V2-only elements must be absent
403+
assert!(!xml.contains("<KeyCount>"), "V1 must not have KeyCount");
404+
assert!(!xml.contains("<StartAfter>"), "V1 must not have StartAfter");
405+
assert!(
406+
!xml.contains("<ContinuationToken>"),
407+
"V1 must not have ContinuationToken"
408+
);
409+
}
321410
}

0 commit comments

Comments
 (0)