You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							752 lines
						
					
					
						
							25 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							752 lines
						
					
					
						
							25 KiB
						
					
					
				
								package s3api
							 | 
						|
								
							 | 
						|
								import (
							 | 
						|
									"context"
							 | 
						|
									"encoding/xml"
							 | 
						|
									"fmt"
							 | 
						|
									"io"
							 | 
						|
									"net/http"
							 | 
						|
									"net/url"
							 | 
						|
									"strconv"
							 | 
						|
									"strings"
							 | 
						|
								
							 | 
						|
									"github.com/aws/aws-sdk-go/service/s3"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/glog"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
							 | 
						|
								)
							 | 
						|
								
							 | 
						|
								type OptionalString struct {
							 | 
						|
									string
							 | 
						|
									set bool
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (o OptionalString) MarshalXML(e *xml.Encoder, startElement xml.StartElement) error {
							 | 
						|
									if !o.set {
							 | 
						|
										return nil
							 | 
						|
									}
							 | 
						|
									return e.EncodeElement(o.string, startElement)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								type ListBucketResultV2 struct {
							 | 
						|
									XMLName               xml.Name       `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ListBucketResult"`
							 | 
						|
									Name                  string         `xml:"Name"`
							 | 
						|
									Prefix                string         `xml:"Prefix"`
							 | 
						|
									MaxKeys               uint16         `xml:"MaxKeys"`
							 | 
						|
									Delimiter             string         `xml:"Delimiter,omitempty"`
							 | 
						|
									IsTruncated           bool           `xml:"IsTruncated"`
							 | 
						|
									Contents              []ListEntry    `xml:"Contents,omitempty"`
							 | 
						|
									CommonPrefixes        []PrefixEntry  `xml:"CommonPrefixes,omitempty"`
							 | 
						|
									ContinuationToken     OptionalString `xml:"ContinuationToken,omitempty"`
							 | 
						|
									NextContinuationToken string         `xml:"NextContinuationToken,omitempty"`
							 | 
						|
									EncodingType          string         `xml:"EncodingType,omitempty"`
							 | 
						|
									KeyCount              int            `xml:"KeyCount"`
							 | 
						|
									StartAfter            string         `xml:"StartAfter,omitempty"`
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (s3a *S3ApiServer) ListObjectsV2Handler(w http.ResponseWriter, r *http.Request) {
							 | 
						|
								
							 | 
						|
									// https://docs.aws.amazon.com/AmazonS3/latest/API/v2-RESTBucketGET.html
							 | 
						|
								
							 | 
						|
									// collect parameters
							 | 
						|
									bucket, _ := s3_constants.GetBucketAndObject(r)
							 | 
						|
									glog.V(3).Infof("ListObjectsV2Handler %s", bucket)
							 | 
						|
								
							 | 
						|
									originalPrefix, startAfter, delimiter, continuationToken, encodingTypeUrl, fetchOwner, maxKeys, allowUnordered, errCode := getListObjectsV2Args(r.URL.Query())
							 | 
						|
								
							 | 
						|
									if errCode != s3err.ErrNone {
							 | 
						|
										s3err.WriteErrorResponse(w, r, errCode)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if maxKeys < 0 {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInvalidMaxKeys)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// AWS S3 compatibility: allow-unordered cannot be used with delimiter
							 | 
						|
									if allowUnordered && delimiter != "" {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInvalidUnorderedWithDelimiter)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									marker := continuationToken.string
							 | 
						|
									if !continuationToken.set {
							 | 
						|
										marker = startAfter
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Adjust marker if it ends with delimiter to skip all entries with that prefix
							 | 
						|
									marker = adjustMarkerForDelimiter(marker, delimiter)
							 | 
						|
								
							 | 
						|
									response, err := s3a.listFilerEntries(bucket, originalPrefix, maxKeys, marker, delimiter, encodingTypeUrl, fetchOwner)
							 | 
						|
								
							 | 
						|
									if err != nil {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if len(response.Contents) == 0 {
							 | 
						|
										if exists, existErr := s3a.exists(s3a.option.BucketsPath, bucket, true); existErr == nil && !exists {
							 | 
						|
											s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
							 | 
						|
											return
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									responseV2 := &ListBucketResultV2{
							 | 
						|
										Name:                  response.Name,
							 | 
						|
										CommonPrefixes:        response.CommonPrefixes,
							 | 
						|
										Contents:              response.Contents,
							 | 
						|
										ContinuationToken:     continuationToken,
							 | 
						|
										Delimiter:             response.Delimiter,
							 | 
						|
										IsTruncated:           response.IsTruncated,
							 | 
						|
										KeyCount:              len(response.Contents) + len(response.CommonPrefixes),
							 | 
						|
										MaxKeys:               uint16(response.MaxKeys),
							 | 
						|
										NextContinuationToken: response.NextMarker,
							 | 
						|
										Prefix:                response.Prefix,
							 | 
						|
										StartAfter:            startAfter,
							 | 
						|
									}
							 | 
						|
									if encodingTypeUrl {
							 | 
						|
										responseV2.EncodingType = s3.EncodingTypeUrl
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									writeSuccessResponseXML(w, r, responseV2)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (s3a *S3ApiServer) ListObjectsV1Handler(w http.ResponseWriter, r *http.Request) {
							 | 
						|
								
							 | 
						|
									// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjects.html
							 | 
						|
								
							 | 
						|
									// collect parameters
							 | 
						|
									bucket, _ := s3_constants.GetBucketAndObject(r)
							 | 
						|
									glog.V(3).Infof("ListObjectsV1Handler %s", bucket)
							 | 
						|
								
							 | 
						|
									originalPrefix, marker, delimiter, encodingTypeUrl, maxKeys, allowUnordered, errCode := getListObjectsV1Args(r.URL.Query())
							 | 
						|
								
							 | 
						|
									if errCode != s3err.ErrNone {
							 | 
						|
										s3err.WriteErrorResponse(w, r, errCode)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if maxKeys < 0 {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInvalidMaxKeys)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// AWS S3 compatibility: allow-unordered cannot be used with delimiter
							 | 
						|
									if allowUnordered && delimiter != "" {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInvalidUnorderedWithDelimiter)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Adjust marker if it ends with delimiter to skip all entries with that prefix
							 | 
						|
									marker = adjustMarkerForDelimiter(marker, delimiter)
							 | 
						|
								
							 | 
						|
									response, err := s3a.listFilerEntries(bucket, originalPrefix, uint16(maxKeys), marker, delimiter, encodingTypeUrl, true)
							 | 
						|
								
							 | 
						|
									if err != nil {
							 | 
						|
										s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if len(response.Contents) == 0 {
							 | 
						|
										if exists, existErr := s3a.exists(s3a.option.BucketsPath, bucket, true); existErr == nil && !exists {
							 | 
						|
											s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
							 | 
						|
											return
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									writeSuccessResponseXML(w, r, response)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (s3a *S3ApiServer) listFilerEntries(bucket string, originalPrefix string, maxKeys uint16, originalMarker string, delimiter string, encodingTypeUrl bool, fetchOwner bool) (response ListBucketResult, err error) {
							 | 
						|
									// convert full path prefix into directory name and prefix for entry name
							 | 
						|
									requestDir, prefix, marker := normalizePrefixMarker(originalPrefix, originalMarker)
							 | 
						|
									bucketPrefix := fmt.Sprintf("%s/%s/", s3a.option.BucketsPath, bucket)
							 | 
						|
									reqDir := bucketPrefix[:len(bucketPrefix)-1]
							 | 
						|
									if requestDir != "" {
							 | 
						|
										reqDir = fmt.Sprintf("%s%s", bucketPrefix, requestDir)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									var contents []ListEntry
							 | 
						|
									var commonPrefixes []PrefixEntry
							 | 
						|
									var doErr error
							 | 
						|
									var nextMarker string
							 | 
						|
									cursor := &ListingCursor{
							 | 
						|
										maxKeys:               maxKeys,
							 | 
						|
										prefixEndsOnDelimiter: strings.HasSuffix(originalPrefix, "/") && len(originalMarker) == 0,
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Special case: when maxKeys = 0, return empty results immediately with IsTruncated=false
							 | 
						|
									if maxKeys == 0 {
							 | 
						|
										response = ListBucketResult{
							 | 
						|
											Name:           bucket,
							 | 
						|
											Prefix:         originalPrefix,
							 | 
						|
											Marker:         originalMarker,
							 | 
						|
											NextMarker:     "",
							 | 
						|
											MaxKeys:        int(maxKeys),
							 | 
						|
											Delimiter:      delimiter,
							 | 
						|
											IsTruncated:    false,
							 | 
						|
											Contents:       contents,
							 | 
						|
											CommonPrefixes: commonPrefixes,
							 | 
						|
										}
							 | 
						|
										if encodingTypeUrl {
							 | 
						|
											response.EncodingType = s3.EncodingTypeUrl
							 | 
						|
										}
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// check filer
							 | 
						|
									err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
							 | 
						|
										var lastEntryWasCommonPrefix bool
							 | 
						|
										var lastCommonPrefixName string
							 | 
						|
								
							 | 
						|
										for {
							 | 
						|
											empty := true
							 | 
						|
								
							 | 
						|
											nextMarker, doErr = s3a.doListFilerEntries(client, reqDir, prefix, cursor, marker, delimiter, false, func(dir string, entry *filer_pb.Entry) {
							 | 
						|
												empty = false
							 | 
						|
												dirName, entryName, prefixName := entryUrlEncode(dir, entry.Name, encodingTypeUrl)
							 | 
						|
												if entry.IsDirectory {
							 | 
						|
													// When delimiter is specified, apply delimiter logic to directory key objects too
							 | 
						|
													if delimiter != "" && entry.IsDirectoryKeyObject() {
							 | 
						|
														// Apply the same delimiter logic as for regular files
							 | 
						|
														var delimiterFound bool
							 | 
						|
														undelimitedPath := fmt.Sprintf("%s/%s/", dirName, entryName)[len(bucketPrefix):]
							 | 
						|
								
							 | 
						|
														// take into account a prefix if supplied while delimiting.
							 | 
						|
														undelimitedPath = strings.TrimPrefix(undelimitedPath, originalPrefix)
							 | 
						|
								
							 | 
						|
														delimitedPath := strings.SplitN(undelimitedPath, delimiter, 2)
							 | 
						|
								
							 | 
						|
														if len(delimitedPath) == 2 {
							 | 
						|
															// S3 clients expect the delimited prefix to contain the delimiter and prefix.
							 | 
						|
															delimitedPrefix := originalPrefix + delimitedPath[0] + delimiter
							 | 
						|
								
							 | 
						|
															for i := range commonPrefixes {
							 | 
						|
																if commonPrefixes[i].Prefix == delimitedPrefix {
							 | 
						|
																	delimiterFound = true
							 | 
						|
																	break
							 | 
						|
																}
							 | 
						|
															}
							 | 
						|
								
							 | 
						|
															if !delimiterFound {
							 | 
						|
																commonPrefixes = append(commonPrefixes, PrefixEntry{
							 | 
						|
																	Prefix: delimitedPrefix,
							 | 
						|
																})
							 | 
						|
																cursor.maxKeys--
							 | 
						|
																delimiterFound = true
							 | 
						|
																lastEntryWasCommonPrefix = true
							 | 
						|
																lastCommonPrefixName = delimitedPath[0]
							 | 
						|
															} else {
							 | 
						|
																// This directory object belongs to an existing CommonPrefix, skip it
							 | 
						|
																delimiterFound = true
							 | 
						|
															}
							 | 
						|
														}
							 | 
						|
								
							 | 
						|
														// If no delimiter found in the directory object name, treat it as a regular key
							 | 
						|
														if !delimiterFound {
							 | 
						|
															contents = append(contents, newListEntry(entry, "", dirName, entryName, bucketPrefix, fetchOwner, true, false, s3a.iam))
							 | 
						|
															cursor.maxKeys--
							 | 
						|
															lastEntryWasCommonPrefix = false
							 | 
						|
														}
							 | 
						|
													} else if entry.IsDirectoryKeyObject() {
							 | 
						|
														// No delimiter specified, or delimiter doesn't apply - treat as regular key
							 | 
						|
														contents = append(contents, newListEntry(entry, "", dirName, entryName, bucketPrefix, fetchOwner, true, false, s3a.iam))
							 | 
						|
														cursor.maxKeys--
							 | 
						|
														lastEntryWasCommonPrefix = false
							 | 
						|
														// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
							 | 
						|
													} else if delimiter == "/" { // A response can contain CommonPrefixes only if you specify a delimiter.
							 | 
						|
														commonPrefixes = append(commonPrefixes, PrefixEntry{
							 | 
						|
															Prefix: fmt.Sprintf("%s/%s/", dirName, prefixName)[len(bucketPrefix):],
							 | 
						|
														})
							 | 
						|
														//All of the keys (up to 1,000) rolled up into a common prefix count as a single return when calculating the number of returns.
							 | 
						|
														cursor.maxKeys--
							 | 
						|
														lastEntryWasCommonPrefix = true
							 | 
						|
														lastCommonPrefixName = entry.Name
							 | 
						|
													}
							 | 
						|
												} else {
							 | 
						|
													var delimiterFound bool
							 | 
						|
													if delimiter != "" {
							 | 
						|
														// keys that contain the same string between the prefix and the first occurrence of the delimiter are grouped together as a commonPrefix.
							 | 
						|
														// extract the string between the prefix and the delimiter and add it to the commonPrefixes if it's unique.
							 | 
						|
														undelimitedPath := fmt.Sprintf("%s/%s", dir, entry.Name)[len(bucketPrefix):]
							 | 
						|
								
							 | 
						|
														// take into account a prefix if supplied while delimiting.
							 | 
						|
														undelimitedPath = strings.TrimPrefix(undelimitedPath, originalPrefix)
							 | 
						|
								
							 | 
						|
														delimitedPath := strings.SplitN(undelimitedPath, delimiter, 2)
							 | 
						|
								
							 | 
						|
														if len(delimitedPath) == 2 {
							 | 
						|
															// S3 clients expect the delimited prefix to contain the delimiter and prefix.
							 | 
						|
															delimitedPrefix := originalPrefix + delimitedPath[0] + delimiter
							 | 
						|
								
							 | 
						|
															for i := range commonPrefixes {
							 | 
						|
																if commonPrefixes[i].Prefix == delimitedPrefix {
							 | 
						|
																	delimiterFound = true
							 | 
						|
																	break
							 | 
						|
																}
							 | 
						|
															}
							 | 
						|
								
							 | 
						|
															if !delimiterFound {
							 | 
						|
																commonPrefixes = append(commonPrefixes, PrefixEntry{
							 | 
						|
																	Prefix: delimitedPrefix,
							 | 
						|
																})
							 | 
						|
																cursor.maxKeys--
							 | 
						|
																delimiterFound = true
							 | 
						|
																lastEntryWasCommonPrefix = true
							 | 
						|
																lastCommonPrefixName = delimitedPath[0]
							 | 
						|
															} else {
							 | 
						|
																// This object belongs to an existing CommonPrefix, skip it
							 | 
						|
																// but continue processing to maintain correct flow
							 | 
						|
																delimiterFound = true
							 | 
						|
															}
							 | 
						|
														}
							 | 
						|
													}
							 | 
						|
													if !delimiterFound {
							 | 
						|
														contents = append(contents, newListEntry(entry, "", dirName, entryName, bucketPrefix, fetchOwner, false, false, s3a.iam))
							 | 
						|
														cursor.maxKeys--
							 | 
						|
														lastEntryWasCommonPrefix = false
							 | 
						|
													}
							 | 
						|
												}
							 | 
						|
											})
							 | 
						|
											if doErr != nil {
							 | 
						|
												return doErr
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											// Adjust nextMarker for CommonPrefixes to include trailing slash (AWS S3 compliance)
							 | 
						|
											if cursor.isTruncated && lastEntryWasCommonPrefix && lastCommonPrefixName != "" {
							 | 
						|
												// For CommonPrefixes, NextMarker should include the trailing slash
							 | 
						|
												if requestDir != "" {
							 | 
						|
													nextMarker = requestDir + "/" + lastCommonPrefixName + "/"
							 | 
						|
												} else {
							 | 
						|
													nextMarker = lastCommonPrefixName + "/"
							 | 
						|
												}
							 | 
						|
											} else if cursor.isTruncated {
							 | 
						|
												if requestDir != "" {
							 | 
						|
													nextMarker = requestDir + "/" + nextMarker
							 | 
						|
												}
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											if cursor.isTruncated {
							 | 
						|
												break
							 | 
						|
											} else if empty || strings.HasSuffix(originalPrefix, "/") {
							 | 
						|
												nextMarker = ""
							 | 
						|
												break
							 | 
						|
											} else {
							 | 
						|
												// start next loop
							 | 
						|
												marker = nextMarker
							 | 
						|
											}
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										response = ListBucketResult{
							 | 
						|
											Name:           bucket,
							 | 
						|
											Prefix:         originalPrefix,
							 | 
						|
											Marker:         originalMarker,
							 | 
						|
											NextMarker:     nextMarker,
							 | 
						|
											MaxKeys:        int(maxKeys),
							 | 
						|
											Delimiter:      delimiter,
							 | 
						|
											IsTruncated:    cursor.isTruncated,
							 | 
						|
											Contents:       contents,
							 | 
						|
											CommonPrefixes: commonPrefixes,
							 | 
						|
										}
							 | 
						|
										if encodingTypeUrl {
							 | 
						|
											// Todo used for pass test_bucket_listv2_encoding_basic
							 | 
						|
											// sort.Slice(response.CommonPrefixes, func(i, j int) bool { return response.CommonPrefixes[i].Prefix < response.CommonPrefixes[j].Prefix })
							 | 
						|
											response.EncodingType = s3.EncodingTypeUrl
							 | 
						|
										}
							 | 
						|
										return nil
							 | 
						|
									})
							 | 
						|
								
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								type ListingCursor struct {
							 | 
						|
									maxKeys               uint16
							 | 
						|
									isTruncated           bool
							 | 
						|
									prefixEndsOnDelimiter bool
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// the prefix and marker may be in different directories
							 | 
						|
								// normalizePrefixMarker ensures the prefix and marker both starts from the same directory
							 | 
						|
								func normalizePrefixMarker(prefix, marker string) (alignedDir, alignedPrefix, alignedMarker string) {
							 | 
						|
									// alignedDir should not end with "/"
							 | 
						|
									// alignedDir, alignedPrefix, alignedMarker should only have "/" in middle
							 | 
						|
									if len(marker) == 0 {
							 | 
						|
										prefix = strings.Trim(prefix, "/")
							 | 
						|
									} else {
							 | 
						|
										prefix = strings.TrimLeft(prefix, "/")
							 | 
						|
									}
							 | 
						|
									marker = strings.TrimLeft(marker, "/")
							 | 
						|
									if prefix == "" {
							 | 
						|
										return "", "", marker
							 | 
						|
									}
							 | 
						|
									if marker == "" {
							 | 
						|
										alignedDir, alignedPrefix = toDirAndName(prefix)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
									if !strings.HasPrefix(marker, prefix) {
							 | 
						|
										// something wrong
							 | 
						|
										return "", prefix, marker
							 | 
						|
									}
							 | 
						|
									if strings.HasPrefix(marker, prefix+"/") {
							 | 
						|
										alignedDir = prefix
							 | 
						|
										alignedPrefix = ""
							 | 
						|
										alignedMarker = marker[len(alignedDir)+1:]
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									alignedDir, alignedPrefix = toDirAndName(prefix)
							 | 
						|
									if alignedDir != "" {
							 | 
						|
										alignedMarker = marker[len(alignedDir)+1:]
							 | 
						|
									} else {
							 | 
						|
										alignedMarker = marker
							 | 
						|
									}
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func toDirAndName(dirAndName string) (dir, name string) {
							 | 
						|
									sepIndex := strings.LastIndex(dirAndName, "/")
							 | 
						|
									if sepIndex >= 0 {
							 | 
						|
										dir, name = dirAndName[0:sepIndex], dirAndName[sepIndex+1:]
							 | 
						|
									} else {
							 | 
						|
										name = dirAndName
							 | 
						|
									}
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func toParentAndDescendants(dirAndName string) (dir, name string) {
							 | 
						|
									sepIndex := strings.Index(dirAndName, "/")
							 | 
						|
									if sepIndex >= 0 {
							 | 
						|
										dir, name = dirAndName[0:sepIndex], dirAndName[sepIndex+1:]
							 | 
						|
									} else {
							 | 
						|
										name = dirAndName
							 | 
						|
									}
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, dir, prefix string, cursor *ListingCursor, marker, delimiter string, inclusiveStartFrom bool, eachEntryFn func(dir string, entry *filer_pb.Entry)) (nextMarker string, err error) {
							 | 
						|
									// invariants
							 | 
						|
									//   prefix and marker should be under dir, marker may contain "/"
							 | 
						|
									//   maxKeys should be updated for each recursion
							 | 
						|
									// glog.V(4).Infof("doListFilerEntries dir: %s, prefix: %s, marker %s, maxKeys: %d, prefixEndsOnDelimiter: %+v", dir, prefix, marker, cursor.maxKeys, cursor.prefixEndsOnDelimiter)
							 | 
						|
									if prefix == "/" && delimiter == "/" {
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
									if cursor.maxKeys <= 0 {
							 | 
						|
										return // Don't set isTruncated here - let caller decide based on whether more entries exist
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if strings.Contains(marker, "/") {
							 | 
						|
										subDir, subMarker := toParentAndDescendants(marker)
							 | 
						|
										// println("doListFilerEntries dir", dir+"/"+subDir, "subMarker", subMarker)
							 | 
						|
										subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+subDir, "", cursor, subMarker, delimiter, false, eachEntryFn)
							 | 
						|
										if subErr != nil {
							 | 
						|
											err = subErr
							 | 
						|
											return
							 | 
						|
										}
							 | 
						|
										nextMarker = subDir + "/" + subNextMarker
							 | 
						|
										// finished processing this subdirectory
							 | 
						|
										marker = subDir
							 | 
						|
									}
							 | 
						|
									if cursor.isTruncated {
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// now marker is also a direct child of dir
							 | 
						|
									request := &filer_pb.ListEntriesRequest{
							 | 
						|
										Directory:          dir,
							 | 
						|
										Prefix:             prefix,
							 | 
						|
										Limit:              uint32(cursor.maxKeys + 2), // bucket root directory needs to skip additional s3_constants.MultipartUploadsFolder folder
							 | 
						|
										StartFromFileName:  marker,
							 | 
						|
										InclusiveStartFrom: inclusiveStartFrom,
							 | 
						|
									}
							 | 
						|
									if cursor.prefixEndsOnDelimiter {
							 | 
						|
										request.Limit = uint32(1)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									ctx, cancel := context.WithCancel(context.Background())
							 | 
						|
									defer cancel()
							 | 
						|
									stream, listErr := client.ListEntries(ctx, request)
							 | 
						|
									if listErr != nil {
							 | 
						|
										err = fmt.Errorf("list entires %+v: %v", request, listErr)
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Track .versions directories found in this directory for later processing
							 | 
						|
									var versionsDirs []string
							 | 
						|
								
							 | 
						|
									for {
							 | 
						|
										resp, recvErr := stream.Recv()
							 | 
						|
										if recvErr != nil {
							 | 
						|
											if recvErr == io.EOF {
							 | 
						|
												break
							 | 
						|
											} else {
							 | 
						|
												err = fmt.Errorf("iterating entires %+v: %v", request, recvErr)
							 | 
						|
												return
							 | 
						|
											}
							 | 
						|
										}
							 | 
						|
										entry := resp.Entry
							 | 
						|
								
							 | 
						|
										if cursor.maxKeys <= 0 {
							 | 
						|
											cursor.isTruncated = true
							 | 
						|
											continue
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// Set nextMarker only when we have quota to process this entry
							 | 
						|
										nextMarker = entry.Name
							 | 
						|
										if cursor.prefixEndsOnDelimiter {
							 | 
						|
											if entry.Name == prefix && entry.IsDirectory {
							 | 
						|
												if delimiter != "/" {
							 | 
						|
													cursor.prefixEndsOnDelimiter = false
							 | 
						|
												}
							 | 
						|
											} else {
							 | 
						|
												continue
							 | 
						|
											}
							 | 
						|
										}
							 | 
						|
										if entry.IsDirectory {
							 | 
						|
											// glog.V(4).Infof("List Dir Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
							 | 
						|
											if entry.Name == s3_constants.MultipartUploadsFolder { // FIXME no need to apply to all directories. this extra also affects maxKeys
							 | 
						|
												continue
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											// Skip .versions directories in regular list operations but track them for logical object creation
							 | 
						|
											if strings.HasSuffix(entry.Name, ".versions") {
							 | 
						|
												glog.V(4).Infof("Found .versions directory: %s", entry.Name)
							 | 
						|
												versionsDirs = append(versionsDirs, entry.Name)
							 | 
						|
												continue
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											if delimiter != "/" || cursor.prefixEndsOnDelimiter {
							 | 
						|
												if cursor.prefixEndsOnDelimiter {
							 | 
						|
													cursor.prefixEndsOnDelimiter = false
							 | 
						|
													if entry.IsDirectoryKeyObject() {
							 | 
						|
														eachEntryFn(dir, entry)
							 | 
						|
													}
							 | 
						|
												} else {
							 | 
						|
													eachEntryFn(dir, entry)
							 | 
						|
												}
							 | 
						|
												subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+entry.Name, "", cursor, "", delimiter, false, eachEntryFn)
							 | 
						|
												if subErr != nil {
							 | 
						|
													err = fmt.Errorf("doListFilerEntries2: %w", subErr)
							 | 
						|
													return
							 | 
						|
												}
							 | 
						|
												// println("doListFilerEntries2 dir", dir+"/"+entry.Name, "subNextMarker", subNextMarker)
							 | 
						|
												nextMarker = entry.Name + "/" + subNextMarker
							 | 
						|
												if cursor.isTruncated {
							 | 
						|
													return
							 | 
						|
												}
							 | 
						|
												// println("doListFilerEntries2 nextMarker", nextMarker)
							 | 
						|
											} else {
							 | 
						|
												var isEmpty bool
							 | 
						|
												if !s3a.option.AllowEmptyFolder && entry.IsOlderDir() {
							 | 
						|
													//if isEmpty, err = s3a.ensureDirectoryAllEmpty(client, dir, entry.Name); err != nil {
							 | 
						|
													//	glog.Errorf("check empty folder %s: %v", dir, err)
							 | 
						|
													//}
							 | 
						|
												}
							 | 
						|
												if !isEmpty {
							 | 
						|
													eachEntryFn(dir, entry)
							 | 
						|
												}
							 | 
						|
											}
							 | 
						|
										} else {
							 | 
						|
											eachEntryFn(dir, entry)
							 | 
						|
											// glog.V(4).Infof("List File Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
							 | 
						|
										}
							 | 
						|
										if cursor.prefixEndsOnDelimiter {
							 | 
						|
											cursor.prefixEndsOnDelimiter = false
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// After processing all regular entries, handle versioned objects
							 | 
						|
									// Create logical entries for objects that have .versions directories
							 | 
						|
									for _, versionsDir := range versionsDirs {
							 | 
						|
										if cursor.maxKeys <= 0 {
							 | 
						|
											cursor.isTruncated = true
							 | 
						|
											break
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// Extract object name from .versions directory name (remove .versions suffix)
							 | 
						|
										baseObjectName := strings.TrimSuffix(versionsDir, ".versions")
							 | 
						|
								
							 | 
						|
										// Construct full object path relative to bucket
							 | 
						|
										// dir is something like "/buckets/sea-test-1/Veeam/Backup/vbr/Config"
							 | 
						|
										// we need to get the path relative to bucket: "Veeam/Backup/vbr/Config/Owner"
							 | 
						|
										bucketPath := strings.TrimPrefix(dir, s3a.option.BucketsPath+"/")
							 | 
						|
										bucketName := strings.Split(bucketPath, "/")[0]
							 | 
						|
								
							 | 
						|
										// Remove bucket name from path to get directory within bucket
							 | 
						|
										bucketRelativePath := strings.Join(strings.Split(bucketPath, "/")[1:], "/")
							 | 
						|
								
							 | 
						|
										var fullObjectPath string
							 | 
						|
										if bucketRelativePath == "" {
							 | 
						|
											// Object is at bucket root
							 | 
						|
											fullObjectPath = baseObjectName
							 | 
						|
										} else {
							 | 
						|
											// Object is in subdirectory
							 | 
						|
											fullObjectPath = bucketRelativePath + "/" + baseObjectName
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										glog.V(4).Infof("Processing versioned object: baseObjectName=%s, bucketRelativePath=%s, fullObjectPath=%s",
							 | 
						|
											baseObjectName, bucketRelativePath, fullObjectPath)
							 | 
						|
								
							 | 
						|
										// Get the latest version information for this object
							 | 
						|
										if latestVersionEntry, latestVersionErr := s3a.getLatestVersionEntryForListOperation(bucketName, fullObjectPath); latestVersionErr == nil {
							 | 
						|
											glog.V(4).Infof("Creating logical entry for versioned object: %s", fullObjectPath)
							 | 
						|
											eachEntryFn(dir, latestVersionEntry)
							 | 
						|
										} else {
							 | 
						|
											glog.V(4).Infof("Failed to get latest version for %s: %v", fullObjectPath, latestVersionErr)
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func getListObjectsV2Args(values url.Values) (prefix, startAfter, delimiter string, token OptionalString, encodingTypeUrl bool, fetchOwner bool, maxkeys uint16, allowUnordered bool, errCode s3err.ErrorCode) {
							 | 
						|
									prefix = values.Get("prefix")
							 | 
						|
									token = OptionalString{set: values.Has("continuation-token"), string: values.Get("continuation-token")}
							 | 
						|
									startAfter = values.Get("start-after")
							 | 
						|
									delimiter = values.Get("delimiter")
							 | 
						|
									encodingTypeUrl = values.Get("encoding-type") == s3.EncodingTypeUrl
							 | 
						|
									if values.Get("max-keys") != "" {
							 | 
						|
										if maxKeys, err := strconv.ParseUint(values.Get("max-keys"), 10, 16); err == nil {
							 | 
						|
											maxkeys = uint16(maxKeys)
							 | 
						|
										} else {
							 | 
						|
											// Invalid max-keys value (non-numeric)
							 | 
						|
											errCode = s3err.ErrInvalidMaxKeys
							 | 
						|
											return
							 | 
						|
										}
							 | 
						|
									} else {
							 | 
						|
										maxkeys = maxObjectListSizeLimit
							 | 
						|
									}
							 | 
						|
									fetchOwner = values.Get("fetch-owner") == "true"
							 | 
						|
									allowUnordered = values.Get("allow-unordered") == "true"
							 | 
						|
									errCode = s3err.ErrNone
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func getListObjectsV1Args(values url.Values) (prefix, marker, delimiter string, encodingTypeUrl bool, maxkeys int16, allowUnordered bool, errCode s3err.ErrorCode) {
							 | 
						|
									prefix = values.Get("prefix")
							 | 
						|
									marker = values.Get("marker")
							 | 
						|
									delimiter = values.Get("delimiter")
							 | 
						|
									encodingTypeUrl = values.Get("encoding-type") == "url"
							 | 
						|
									if values.Get("max-keys") != "" {
							 | 
						|
										if maxKeys, err := strconv.ParseInt(values.Get("max-keys"), 10, 16); err == nil {
							 | 
						|
											maxkeys = int16(maxKeys)
							 | 
						|
										} else {
							 | 
						|
											// Invalid max-keys value (non-numeric)
							 | 
						|
											errCode = s3err.ErrInvalidMaxKeys
							 | 
						|
											return
							 | 
						|
										}
							 | 
						|
									} else {
							 | 
						|
										maxkeys = maxObjectListSizeLimit
							 | 
						|
									}
							 | 
						|
									allowUnordered = values.Get("allow-unordered") == "true"
							 | 
						|
									errCode = s3err.ErrNone
							 | 
						|
									return
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (s3a *S3ApiServer) ensureDirectoryAllEmpty(filerClient filer_pb.SeaweedFilerClient, parentDir, name string) (isEmpty bool, err error) {
							 | 
						|
									// println("+ ensureDirectoryAllEmpty", dir, name)
							 | 
						|
									glog.V(4).Infof("+ isEmpty %s/%s", parentDir, name)
							 | 
						|
									defer glog.V(4).Infof("- isEmpty %s/%s %v", parentDir, name, isEmpty)
							 | 
						|
									var fileCounter int
							 | 
						|
									var subDirs []string
							 | 
						|
									currentDir := parentDir + "/" + name
							 | 
						|
									var startFrom string
							 | 
						|
									var isExhausted bool
							 | 
						|
									var foundEntry bool
							 | 
						|
									for fileCounter == 0 && !isExhausted && err == nil {
							 | 
						|
										err = filer_pb.SeaweedList(context.Background(), filerClient, currentDir, "", func(entry *filer_pb.Entry, isLast bool) error {
							 | 
						|
											foundEntry = true
							 | 
						|
											if entry.IsOlderDir() {
							 | 
						|
												subDirs = append(subDirs, entry.Name)
							 | 
						|
											} else {
							 | 
						|
												fileCounter++
							 | 
						|
											}
							 | 
						|
											startFrom = entry.Name
							 | 
						|
											isExhausted = isExhausted || isLast
							 | 
						|
											glog.V(4).Infof("    * %s/%s isLast: %t", currentDir, startFrom, isLast)
							 | 
						|
											return nil
							 | 
						|
										}, startFrom, false, 8)
							 | 
						|
										if !foundEntry {
							 | 
						|
											break
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if err != nil {
							 | 
						|
										return false, err
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									if fileCounter > 0 {
							 | 
						|
										return false, nil
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									for _, subDir := range subDirs {
							 | 
						|
										isSubEmpty, subErr := s3a.ensureDirectoryAllEmpty(filerClient, currentDir, subDir)
							 | 
						|
										if subErr != nil {
							 | 
						|
											return false, subErr
							 | 
						|
										}
							 | 
						|
										if !isSubEmpty {
							 | 
						|
											return false, nil
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									glog.V(1).Infof("deleting empty folder %s", currentDir)
							 | 
						|
									if err = doDeleteEntry(filerClient, parentDir, name, true, false); err != nil {
							 | 
						|
										return
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return true, nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// getLatestVersionEntryForListOperation gets the latest version of an object and creates a logical entry for list operations
							 | 
						|
								// This is used to show versioned objects as logical object names in regular list operations
							 | 
						|
								func (s3a *S3ApiServer) getLatestVersionEntryForListOperation(bucket, object string) (*filer_pb.Entry, error) {
							 | 
						|
									// Get the latest version entry
							 | 
						|
									latestVersionEntry, err := s3a.getLatestObjectVersion(bucket, object)
							 | 
						|
									if err != nil {
							 | 
						|
										return nil, fmt.Errorf("failed to get latest version: %w", err)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Check if this is a delete marker (should not be shown in regular list)
							 | 
						|
									if latestVersionEntry.Extended != nil {
							 | 
						|
										if deleteMarker, exists := latestVersionEntry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" {
							 | 
						|
											return nil, fmt.Errorf("latest version is a delete marker")
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Create a logical entry that appears to be stored at the object path (not the versioned path)
							 | 
						|
									// This allows the list operation to show the logical object name while preserving all metadata
							 | 
						|
									logicalEntry := &filer_pb.Entry{
							 | 
						|
										Name:        strings.TrimPrefix(object, "/"),
							 | 
						|
										IsDirectory: false,
							 | 
						|
										Attributes:  latestVersionEntry.Attributes,
							 | 
						|
										Extended:    latestVersionEntry.Extended,
							 | 
						|
										Chunks:      latestVersionEntry.Chunks,
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return logicalEntry, nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// adjustMarkerForDelimiter handles delimiter-ending markers by incrementing them to skip entries with that prefix.
							 | 
						|
								// For example, when continuation token is "boo/", this returns "boo~" to skip all "boo/*" entries
							 | 
						|
								// but still finds any "bop" or later entries. We add a high ASCII character rather than incrementing
							 | 
						|
								// the last character to avoid skipping potential directory entries.
							 | 
						|
								// This is essential for correct S3 list operations with delimiters and CommonPrefixes.
							 | 
						|
								func adjustMarkerForDelimiter(marker, delimiter string) string {
							 | 
						|
									if delimiter == "" || !strings.HasSuffix(marker, delimiter) {
							 | 
						|
										return marker
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Remove the trailing delimiter and append a high ASCII character
							 | 
						|
									// This ensures we skip all entries under the prefix but don't skip
							 | 
						|
									// potential directory entries that start with a similar prefix
							 | 
						|
									prefix := strings.TrimSuffix(marker, delimiter)
							 | 
						|
									if len(prefix) == 0 {
							 | 
						|
										return marker
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Use tilde (~) which has ASCII value 126, higher than most printable characters
							 | 
						|
									// This skips "prefix/*" entries but still finds "prefix" + any higher character
							 | 
						|
									return prefix + "~"
							 | 
						|
								}
							 |