33//! Extracted from `proxy.rs` to keep the gateway focused on orchestration.
44
55use crate :: api:: list_rewrite:: ListRewrite ;
6- use crate :: api:: response:: { ListBucketResult , ListCommonPrefix , ListContents } ;
6+ use crate :: api:: response:: { ListBucketResult , ListBucketResultV1 , ListCommonPrefix , ListContents } ;
77use crate :: error:: ProxyError ;
88use 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}
0 commit comments