mirror of
https://github.com/versity/versitygw.git
synced 2026-05-13 07:21:28 +00:00
Rewrite the posix Walk implementation to avoid the extra ReadDir per directory that was noted as a TODO in the old code. The new algorithm holds all traversal state in a walkState struct and uses processDir to interleave sibling entries in correct S3 lexicographic order without a second syscall. Key changes: prefix optimisation: jump directly into the deepest matching directory rather than scanning from the root on every call marker short-circuit: skip entire subtrees that are lexically before the marker, making paginated listing faster Co-authored-by: Ben McClelland <ben.mcclelland@versity.com>
1500 lines
41 KiB
Go
1500 lines
41 KiB
Go
// Copyright 2023 Versity Software
|
||
// This file is licensed under the Apache License, Version 2.0
|
||
// (the "License"); you may not use this file except in compliance
|
||
// with the License. You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing,
|
||
// software distributed under the License is distributed on an
|
||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||
// KIND, either express or implied. See the License for the
|
||
// specific language governing permissions and limitations
|
||
// under the License.
|
||
|
||
package backend_test
|
||
|
||
import (
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io/fs"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"testing/fstest"
|
||
"time"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||
"github.com/versity/versitygw/backend"
|
||
"github.com/versity/versitygw/s3response"
|
||
)
|
||
|
||
type walkTest struct {
|
||
fsys fs.FS
|
||
getobj backend.GetObjFunc
|
||
cases []testcase
|
||
}
|
||
|
||
type testcase struct {
|
||
name string
|
||
prefix string
|
||
delimiter string
|
||
marker string
|
||
maxObjs int32
|
||
expected backend.WalkResults
|
||
}
|
||
|
||
func getObj(path string, d fs.DirEntry) (s3response.Object, error) {
|
||
if d.IsDir() {
|
||
etag := getMD5(path)
|
||
|
||
fi, err := d.Info()
|
||
if err != nil {
|
||
return s3response.Object{}, fmt.Errorf("get fileinfo: %w", err)
|
||
}
|
||
mtime := fi.ModTime()
|
||
|
||
return s3response.Object{
|
||
ETag: &etag,
|
||
Key: &path,
|
||
LastModified: &mtime,
|
||
}, nil
|
||
}
|
||
|
||
etag := getMD5(path)
|
||
|
||
fi, err := d.Info()
|
||
if err != nil {
|
||
return s3response.Object{}, fmt.Errorf("get fileinfo: %w", err)
|
||
}
|
||
|
||
size := fi.Size()
|
||
mtime := fi.ModTime()
|
||
|
||
return s3response.Object{
|
||
ETag: &etag,
|
||
Key: &path,
|
||
LastModified: &mtime,
|
||
Size: &size,
|
||
}, nil
|
||
}
|
||
|
||
func getMD5(text string) string {
|
||
hash := md5.Sum([]byte(text))
|
||
return hex.EncodeToString(hash[:])
|
||
}
|
||
|
||
// getObjSkipDirs mimics the POSIX backend's FileToObj behavior for implicitly
|
||
// created directories: it returns ErrSkipObj so that directories which exist
|
||
// only as filesystem artefacts of slash-separated paths are not surfaced as
|
||
// S3 objects. This is necessary when testing non-"/" delimiters where
|
||
// fstest.MapFS creates intermediate directories from the "/" path separators
|
||
// embedded in object keys.
|
||
func getObjSkipDirs(path string, d fs.DirEntry) (s3response.Object, error) {
|
||
if d.IsDir() {
|
||
return s3response.Object{}, backend.ErrSkipObj
|
||
}
|
||
return getObj(path, d)
|
||
}
|
||
|
||
func TestWalk(t *testing.T) {
|
||
tests := []walkTest{
|
||
{
|
||
// test case from
|
||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-prefixes.html
|
||
// All directories in this fsys are implicit filesystem artefacts (no S3
|
||
// object was explicitly PUT at any directory path), so getObjSkipDirs is
|
||
// used to mirror the real POSIX backend behaviour.
|
||
fsys: fstest.MapFS{
|
||
"sample.jpg": {},
|
||
"photos/2006/January/sample.jpg": {},
|
||
"photos/2006/February/sample2.jpg": {},
|
||
"photos/2006/February/sample3.jpg": {},
|
||
"photos/2006/February/sample4.jpg": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []testcase{
|
||
{
|
||
name: "aws example",
|
||
delimiter: "/",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photos/"),
|
||
}},
|
||
Objects: []s3response.Object{{
|
||
Key: backend.GetPtrFromString("sample.jpg"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "max objs",
|
||
delimiter: "/",
|
||
prefix: "photos/2006/February/",
|
||
maxObjs: 2,
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("photos/2006/February/sample2.jpg"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("photos/2006/February/sample3.jpg"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// test case single dir/single file
|
||
fsys: fstest.MapFS{
|
||
"test/file": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "single dir single file",
|
||
delimiter: "/",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("test/"),
|
||
}},
|
||
Objects: []s3response.Object{},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// non-standard delimiter
|
||
// fstest.MapFS implicitly creates filesystem directories for each
|
||
// "/"-separated path component (e.g. "photo|s", "200|6", ...) even
|
||
// though these are not S3 objects. On a real POSIX filesystem the
|
||
// backend's FileToObj returns ErrSkipObj for such implicit
|
||
// directories (they have no stored etag), so we use getObjSkipDirs
|
||
// here to reproduce that behaviour.
|
||
fsys: fstest.MapFS{
|
||
"photo|s/200|6/Januar|y/sampl|e1.jpg": {},
|
||
"photo|s/200|6/Januar|y/sampl|e2.jpg": {},
|
||
"photo|s/200|6/Januar|y/sampl|e3.jpg": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []testcase{
|
||
{
|
||
name: "different delimiter 1",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photo|"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "different delimiter 2",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
prefix: "photo|",
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photo|s/200|"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "different delimiter 3",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
prefix: "photo|s/200|",
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photo|s/200|6/Januar|"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "different delimiter 4",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
prefix: "photo|s/200|",
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photo|s/200|6/Januar|"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "different delimiter 5",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
prefix: "photo|s/200|6/Januar|",
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{{
|
||
Prefix: backend.GetPtrFromString("photo|s/200|6/Januar|y/sampl|"),
|
||
}},
|
||
},
|
||
},
|
||
{
|
||
name: "different delimiter 6",
|
||
delimiter: "|",
|
||
maxObjs: 1000,
|
||
prefix: "photo|s/200|6/Januar|y/sampl|",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("photo|s/200|6/Januar|y/sampl|e1.jpg"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("photo|s/200|6/Januar|y/sampl|e2.jpg"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("photo|s/200|6/Januar|y/sampl|e3.jpg"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// test case prefix is a top level directory object that
|
||
// should be returned with the listing results
|
||
fsys: fstest.MapFS{
|
||
"a/file.txt": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "prefix is a directory object",
|
||
maxObjs: 1000,
|
||
prefix: "a/",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("a/"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("a/file.txt"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"f3": {},
|
||
"f4": {},
|
||
"f5": {},
|
||
"f6": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "custom delimiter with marker filtering",
|
||
maxObjs: 1000,
|
||
delimiter: "5",
|
||
marker: "f3",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("f4"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("f6"),
|
||
},
|
||
},
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("f5"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// nested-path variant of delimiter+marker behavior.
|
||
fsys: fstest.MapFS{
|
||
"top/alpha/f3": {},
|
||
"top/bravo/f4": {},
|
||
"top/charlie/f5": {},
|
||
"top/zulu/f6": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []testcase{
|
||
{
|
||
name: "delimiter and marker across nested directories",
|
||
maxObjs: 1000,
|
||
prefix: "top/",
|
||
delimiter: "5",
|
||
marker: "top/alpha/f3",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("top/bravo/f4"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("top/zulu/f6"),
|
||
},
|
||
},
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("top/charlie/f5"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// When the fast-path jumps into a prefix directory
|
||
// (e.g. prefix="a/"), the prefix-directory object "a/" must itself be
|
||
// offered to getObj even when the directory contains subdirectories.
|
||
// AWS S3 behavior: if "a/" was explicitly PUT as a zero-byte object it
|
||
// appears in ListObjects results regardless of what children it has.
|
||
fsys: fstest.MapFS{
|
||
"a/b/file.txt": {},
|
||
"a/file1.txt": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "prefix dir with subdirs is offered to getObj",
|
||
prefix: "a/",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("a/")},
|
||
{Key: backend.GetPtrFromString("a/b/")},
|
||
{Key: backend.GetPtrFromString("a/b/file.txt")},
|
||
{Key: backend.GetPtrFromString("a/file1.txt")},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// When the fast-path jumps into a prefix directory,
|
||
// "a/" must still be offered to getObj when a marker is present but
|
||
// the marker sorts before "a/" — so "a/" is a valid result on this page.
|
||
// AWS S3 behavior: ListObjects(prefix="a/", marker="a") must return
|
||
// "a/" as the first key because "a" < "a/".
|
||
fsys: fstest.MapFS{
|
||
"a/file.txt": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
// marker "a" < "a/" → "a/" belongs on this page
|
||
name: "prefix dir included when marker is before dir key",
|
||
prefix: "a/",
|
||
marker: "a",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("a/")},
|
||
{Key: backend.GetPtrFromString("a/file.txt")},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// marker "a/" == "a/" → "a/" itself must be excluded
|
||
// (pagination: previous page ended at "a/", results start after it)
|
||
name: "prefix dir excluded when marker equals dir key",
|
||
prefix: "a/",
|
||
marker: "a/",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("a/file.txt")},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// marker "a/b" > "a/" → "a/" belongs on a previous page, must be excluded
|
||
name: "prefix dir excluded when marker is after dir key",
|
||
prefix: "a/",
|
||
marker: "a/b",
|
||
maxObjs: 1000,
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("a/file.txt")},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
for _, tc := range tt.cases {
|
||
res, err := backend.Walk(context.Background(),
|
||
tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs,
|
||
tt.getobj, []string{})
|
||
if err != nil {
|
||
t.Errorf("%v: walk: %v", tc.name, err)
|
||
}
|
||
|
||
compareResults(tc.name, res, tc.expected, t)
|
||
}
|
||
}
|
||
}
|
||
|
||
func compareResults(name string, got, wanted backend.WalkResults, t *testing.T) {
|
||
if !compareCommonPrefix(got.CommonPrefixes, wanted.CommonPrefixes) {
|
||
t.Errorf("%v: unexpected common prefix, got %v wanted %v",
|
||
name,
|
||
printCommonPrefixes(got.CommonPrefixes),
|
||
printCommonPrefixes(wanted.CommonPrefixes))
|
||
}
|
||
|
||
if !compareObjects(got.Objects, wanted.Objects) {
|
||
t.Errorf("%v: unexpected object, got %v wanted %v",
|
||
name,
|
||
printObjects(got.Objects),
|
||
printObjects(wanted.Objects))
|
||
}
|
||
}
|
||
|
||
func compareCommonPrefix(a, b []types.CommonPrefix) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
|
||
for _, cp := range a {
|
||
if !containsCommonPrefix(cp, b) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func containsCommonPrefix(c types.CommonPrefix, list []types.CommonPrefix) bool {
|
||
for _, cp := range list {
|
||
if *c.Prefix == *cp.Prefix {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func printCommonPrefixes(list []types.CommonPrefix) string {
|
||
res := "["
|
||
for _, cp := range list {
|
||
if res == "[" {
|
||
res = res + *cp.Prefix
|
||
} else {
|
||
res = res + ", " + *cp.Prefix
|
||
}
|
||
}
|
||
return res + "]"
|
||
}
|
||
|
||
func compareObjects(a, b []s3response.Object) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
|
||
for _, cp := range a {
|
||
if !containsObject(cp, b) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func containsObject(c s3response.Object, list []s3response.Object) bool {
|
||
for _, cp := range list {
|
||
if *c.Key == *cp.Key {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func printObjects(list []s3response.Object) string {
|
||
res := "["
|
||
for _, cp := range list {
|
||
var key string
|
||
if cp.Key == nil {
|
||
key = "<nil>"
|
||
} else {
|
||
key = *cp.Key
|
||
}
|
||
if res == "[" {
|
||
res = res + key
|
||
} else {
|
||
res = res + ", " + key
|
||
}
|
||
}
|
||
return res + "]"
|
||
}
|
||
|
||
type slowFS struct {
|
||
fstest.MapFS
|
||
}
|
||
|
||
const (
|
||
readDirPause = 100 * time.Millisecond
|
||
|
||
// walkTimeOut should be less than the tree traversal time
|
||
// which is the readdirPause time * the number of directories
|
||
walkTimeOut = 500 * time.Millisecond
|
||
)
|
||
|
||
func (s *slowFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||
time.Sleep(readDirPause)
|
||
return s.MapFS.ReadDir(name)
|
||
}
|
||
|
||
func TestWalkStop(t *testing.T) {
|
||
// Path must not start with "/" (invalid for fstest.MapFS) and must be deep
|
||
// enough that reading every level takes longer than walkTimeOut.
|
||
// readDirPause (100 ms) × 14 levels = 1.4 s > walkTimeOut (500 ms).
|
||
// An empty delimiter is used so that the walk recurses into every directory
|
||
// level rather than stopping at the first common-prefix boundary (which is
|
||
// what a "/" delimiter would do, completing in a single ReadDir call).
|
||
s := &slowFS{MapFS: fstest.MapFS{
|
||
"a/b/c/d/e/f/g/h/i/j/k/l/m/n": &fstest.MapFile{},
|
||
}}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), walkTimeOut)
|
||
defer cancel()
|
||
|
||
var err error
|
||
var wg sync.WaitGroup
|
||
wg.Go(func() {
|
||
_, err = backend.Walk(ctx, s, "", "", "", 1000,
|
||
func(path string, d fs.DirEntry) (s3response.Object, error) {
|
||
return s3response.Object{}, nil
|
||
}, []string{})
|
||
})
|
||
|
||
select {
|
||
case <-time.After(1 * time.Second):
|
||
t.Fatalf("walk is not terminated in time")
|
||
case <-ctx.Done():
|
||
}
|
||
wg.Wait()
|
||
if err != ctx.Err() {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestWalkDirectoryErrorPropagates(t *testing.T) {
|
||
fSys := fstest.MapFS{
|
||
"dir/file.txt": {},
|
||
}
|
||
|
||
_, err := backend.Walk(context.Background(), fSys, "", "", "", 1000,
|
||
func(path string, d fs.DirEntry) (s3response.Object, error) {
|
||
if d.IsDir() {
|
||
return s3response.Object{}, fmt.Errorf("boom")
|
||
}
|
||
return getObj(path, d)
|
||
}, []string{})
|
||
if err == nil {
|
||
t.Fatal("expected error, got nil")
|
||
}
|
||
if !strings.Contains(err.Error(), "directory to object \"dir/\": boom") {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestWalkDirectoryErrSkipObjContinues(t *testing.T) {
|
||
fSys := fstest.MapFS{
|
||
"a/file1.txt": {},
|
||
"b.txt": {},
|
||
}
|
||
|
||
res, err := backend.Walk(context.Background(), fSys, "", "", "", 1000,
|
||
func(path string, d fs.DirEntry) (s3response.Object, error) {
|
||
if d.IsDir() {
|
||
return s3response.Object{}, backend.ErrSkipObj
|
||
}
|
||
return getObj(path, d)
|
||
}, []string{})
|
||
if err != nil {
|
||
t.Fatalf("walk: %v", err)
|
||
}
|
||
|
||
expected := backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("a/file1.txt")},
|
||
{Key: backend.GetPtrFromString("b.txt")},
|
||
},
|
||
}
|
||
|
||
compareResultsOrdered("dir errskipobj continues", res, expected, t)
|
||
}
|
||
|
||
// TestOrderWalk tests the lexicographic ordering of the object names
|
||
// for the case where readdir sort order of a directory is different
|
||
// than the lexicographic ordering of the full paths. The below has
|
||
// a readdir sort order for dir1/:
|
||
// a, a.b
|
||
// but if you consider the character that comes after a is "/", then
|
||
// the "." should come before "/" in the lexicographic ordering:
|
||
// a.b/, a/
|
||
//
|
||
// getObjSkipDirs is used to mirror the real POSIX backend's behaviour:
|
||
// all directories here are implicit filesystem artefacts (no S3 object
|
||
// was explicitly PUT at any of these paths), so FileToObj returns
|
||
// ErrSkipObj for them. The ordering is still exercised through the leaf
|
||
// files: a.b/* sorts before a/*.
|
||
func TestOrderWalk(t *testing.T) {
|
||
tests := []walkTest{
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"dir1/a/file1": {},
|
||
"dir1/a/file2": {},
|
||
"dir1/a/file3": {},
|
||
"dir1/a.b/file1": {},
|
||
"dir1/a.b/file2": {},
|
||
"dir1/b": {},
|
||
"dir1/b./a": {},
|
||
"dir1/b..": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []testcase{
|
||
{
|
||
name: "order test",
|
||
maxObjs: 1000,
|
||
prefix: "dir1/",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file3")},
|
||
{Key: backend.GetPtrFromString("dir1/b")},
|
||
{Key: backend.GetPtrFromString("dir1/b..")},
|
||
{Key: backend.GetPtrFromString("dir1/b./a")},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"dir1/a/file1": {},
|
||
"dir1/a/file2": {},
|
||
"dir1/a/file3": {},
|
||
"dir1/a.b/file1": {},
|
||
"dir1/a.b/file2": {},
|
||
"dir1/b": {},
|
||
"dir1/b./a": {},
|
||
"dir1/b..": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "order test",
|
||
maxObjs: 1000,
|
||
prefix: "dir1/",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{Key: backend.GetPtrFromString("dir1/")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file3")},
|
||
{Key: backend.GetPtrFromString("dir1/b")},
|
||
{Key: backend.GetPtrFromString("dir1/b..")},
|
||
{Key: backend.GetPtrFromString("dir1/b./")},
|
||
{Key: backend.GetPtrFromString("dir1/b./a")},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// The same fstest.MapFS implicit-directory problem as the
|
||
// non-standard delimiter tests in TestWalk: "|" is the S3
|
||
// delimiter but "/" is the filesystem separator, so fstest
|
||
// creates implicit real directories (dir|1, dir|1/a, …) that
|
||
// should not appear as S3 objects. Use getObjSkipDirs to
|
||
// mirror the POSIX backend's behaviour.
|
||
fsys: fstest.MapFS{
|
||
"dir|1/a/file1": {},
|
||
"dir|1/a/file2": {},
|
||
"dir|1/a/file3": {},
|
||
"dir|1/a.b/file1": {},
|
||
"dir|1/a.b/file2": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []testcase{
|
||
{
|
||
name: "order test delim",
|
||
maxObjs: 1000,
|
||
delimiter: "|",
|
||
prefix: "dir|",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("dir|1/a.b/file1"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir|1/a.b/file2"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir|1/a/file1"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir|1/a/file2"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir|1/a/file3"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"a": &fstest.MapFile{Mode: fs.ModeDir},
|
||
},
|
||
getobj: getObj,
|
||
cases: []testcase{
|
||
{
|
||
name: "single dir obj",
|
||
maxObjs: 1000,
|
||
delimiter: "/",
|
||
prefix: "a",
|
||
expected: backend.WalkResults{
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("a/"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "single dir obj",
|
||
maxObjs: 1000,
|
||
delimiter: "/",
|
||
prefix: "a/",
|
||
expected: backend.WalkResults{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("a/"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
for _, tc := range tt.cases {
|
||
res, err := backend.Walk(context.Background(),
|
||
tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs,
|
||
tt.getobj, []string{})
|
||
if err != nil {
|
||
t.Errorf("%v: walk: %v", tc.name, err)
|
||
}
|
||
|
||
compareResultsOrdered(tc.name, res, tc.expected, t)
|
||
}
|
||
}
|
||
}
|
||
|
||
type markerTest struct {
|
||
fsys fs.FS
|
||
getobj backend.GetObjFunc
|
||
cases []markertestcase
|
||
}
|
||
|
||
type markertestcase struct {
|
||
name string
|
||
prefix string
|
||
delimiter string
|
||
marker string
|
||
maxObjs int32
|
||
expected []backend.WalkResults
|
||
}
|
||
|
||
func TestMarker(t *testing.T) {
|
||
tests := []markerTest{
|
||
{
|
||
// dir is an implicit filesystem directory; use getObjSkipDirs to
|
||
// match the real POSIX backend (no etag stored → ErrSkipObj).
|
||
fsys: fstest.MapFS{
|
||
"dir/sample2.jpg": {},
|
||
"dir/sample3.jpg": {},
|
||
"dir/sample4.jpg": {},
|
||
"dir/sample5.jpg": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []markertestcase{
|
||
{
|
||
name: "multi page marker",
|
||
delimiter: "/",
|
||
prefix: "dir/",
|
||
maxObjs: 2,
|
||
expected: []backend.WalkResults{
|
||
{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("dir/sample2.jpg"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir/sample3.jpg"),
|
||
},
|
||
},
|
||
Truncated: true,
|
||
},
|
||
{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("dir/sample4.jpg"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir/sample5.jpg"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
// dir1 is an implicit filesystem directory; use getObjSkipDirs to
|
||
// match the real POSIX backend (no etag stored → ErrSkipObj).
|
||
fsys: fstest.MapFS{
|
||
"dir1/subdir/file.txt": {},
|
||
"dir1/subdir.ext": {},
|
||
"dir1/subdir1.ext": {},
|
||
"dir1/subdir2.ext": {},
|
||
},
|
||
getobj: getObjSkipDirs,
|
||
cases: []markertestcase{
|
||
{
|
||
name: "integration test case 1",
|
||
maxObjs: 2,
|
||
delimiter: "/",
|
||
prefix: "dir1/",
|
||
expected: []backend.WalkResults{
|
||
{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("dir1/subdir.ext"),
|
||
},
|
||
},
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("dir1/subdir/"),
|
||
},
|
||
},
|
||
Truncated: true,
|
||
},
|
||
{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("dir1/subdir1.ext"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("dir1/subdir2.ext"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"asdf": {},
|
||
"boo/bar": {},
|
||
"boo/baz/xyzzy": {},
|
||
"cquux/thud": {},
|
||
"cquux/bla": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []markertestcase{
|
||
{
|
||
name: "integration test case2",
|
||
maxObjs: 1,
|
||
delimiter: "/",
|
||
marker: "boo/",
|
||
expected: []backend.WalkResults{
|
||
{
|
||
Objects: []s3response.Object{},
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("cquux/"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"bar": {},
|
||
"baz": {},
|
||
"foo": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []markertestcase{
|
||
{
|
||
name: "exact limit count",
|
||
maxObjs: 3,
|
||
expected: []backend.WalkResults{
|
||
{
|
||
Objects: []s3response.Object{
|
||
{
|
||
Key: backend.GetPtrFromString("bar"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("baz"),
|
||
},
|
||
{
|
||
Key: backend.GetPtrFromString("foo"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
fsys: fstest.MapFS{
|
||
"d1/f1": {},
|
||
"d2/f2": {},
|
||
"d3/f3": {},
|
||
"d4/f4": {},
|
||
},
|
||
getobj: getObj,
|
||
cases: []markertestcase{
|
||
{
|
||
name: "limited common prefix",
|
||
maxObjs: 3,
|
||
delimiter: "/",
|
||
expected: []backend.WalkResults{
|
||
{
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("d1/"),
|
||
},
|
||
{
|
||
Prefix: backend.GetPtrFromString("d2/"),
|
||
},
|
||
{
|
||
Prefix: backend.GetPtrFromString("d3/"),
|
||
},
|
||
},
|
||
Truncated: true,
|
||
},
|
||
{
|
||
CommonPrefixes: []types.CommonPrefix{
|
||
{
|
||
Prefix: backend.GetPtrFromString("d4/"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
for _, tc := range tt.cases {
|
||
marker := tc.marker
|
||
for i, page := range tc.expected {
|
||
res, err := backend.Walk(context.Background(),
|
||
tt.fsys, tc.prefix, tc.delimiter, marker, tc.maxObjs,
|
||
tt.getobj, []string{})
|
||
if err != nil {
|
||
t.Errorf("%v: walk: %v", tc.name, err)
|
||
}
|
||
marker = res.NextMarker
|
||
|
||
compareResultsOrdered(tc.name, res, page, t)
|
||
|
||
if res.Truncated != page.Truncated {
|
||
t.Errorf("%v page %v expected truncated %v, got %v",
|
||
tc.name, i, page.Truncated, res.Truncated)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func compareResultsOrdered(name string, got, wanted backend.WalkResults, t *testing.T) {
|
||
if !compareObjectsOrdered(got.Objects, wanted.Objects) {
|
||
t.Errorf("%v: unexpected object, got %v wanted %v",
|
||
name,
|
||
printObjects(got.Objects),
|
||
printObjects(wanted.Objects))
|
||
}
|
||
if !comparePrefixesOrdered(got.CommonPrefixes, wanted.CommonPrefixes) {
|
||
t.Errorf("%v: unexpected prefix, got %v wanted %v",
|
||
name,
|
||
printCommonPrefixes(got.CommonPrefixes),
|
||
printCommonPrefixes(wanted.CommonPrefixes))
|
||
}
|
||
}
|
||
|
||
func compareObjectsOrdered(a, b []s3response.Object) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
|
||
for i, obj := range a {
|
||
if *obj.Key != *b[i].Key {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func comparePrefixesOrdered(a, b []types.CommonPrefix) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
|
||
for i, cp := range a {
|
||
if *cp.Prefix != *b[i].Prefix {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ---- Versioning Tests ----
|
||
|
||
// getVersionsTestFunc is a simple GetVersionsFunc implementation for tests that
|
||
// returns a single latest version for each file or directory encountered.
|
||
// Directories are reported with a trailing delimiter in the key to match the
|
||
// behavior of the non-versioned Walk tests where directory objects are listed.
|
||
func getVersionsTestFunc(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) {
|
||
// If we have no available slots left, signal truncation (should be rare in these tests)
|
||
if availableObjCount <= 0 {
|
||
return &backend.ObjVersionFuncResult{Truncated: true, NextVersionIdMarker: ""}, nil
|
||
}
|
||
|
||
key := path
|
||
if d.IsDir() {
|
||
key = key + "/"
|
||
}
|
||
ver := "v1"
|
||
latest := true
|
||
ov := s3response.ObjectVersion{Key: &key, VersionId: &ver, IsLatest: &latest}
|
||
return &backend.ObjVersionFuncResult{ObjectVersions: []s3response.ObjectVersion{ov}}, nil
|
||
}
|
||
|
||
// TestWalkVersions mirrors TestWalk but exercises WalkVersions and validates
|
||
// common prefixes and object versions for typical delimiter/prefix scenarios.
|
||
func TestWalkVersions(t *testing.T) {
|
||
fsys := fstest.MapFS{
|
||
"dir1/a/file1": {},
|
||
"dir1/a/file2": {},
|
||
"dir1/b/file3": {},
|
||
"rootfile": {},
|
||
}
|
||
|
||
// Without a delimiter, every directory and file becomes an object version
|
||
// via the test GetVersionsFunc (directories have trailing '/').
|
||
expected := backend.WalkVersioningResults{
|
||
ObjectVersions: []s3response.ObjectVersion{
|
||
{Key: backend.GetPtrFromString("dir1/")},
|
||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/b/")},
|
||
{Key: backend.GetPtrFromString("dir1/b/file3")},
|
||
{Key: backend.GetPtrFromString("rootfile")},
|
||
},
|
||
}
|
||
|
||
res, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", 1000, getVersionsTestFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("walk versions: %v", err)
|
||
}
|
||
compareVersionResultsOrdered("simple versions no delimiter", res, expected, t)
|
||
}
|
||
|
||
// TestOrderWalkVersions mirrors TestOrderWalk, exercising ordering semantics for
|
||
// version listings (lexicographic ordering of directory and file version keys).
|
||
func TestOrderWalkVersions(t *testing.T) {
|
||
fsys := fstest.MapFS{
|
||
"dir1/a/file1": {},
|
||
"dir1/a/file2": {},
|
||
"dir1/a/file3": {},
|
||
"dir1/a.b/file1": {},
|
||
"dir1/a.b/file2": {},
|
||
}
|
||
|
||
// Expect lexicographic ordering similar to non-version walk when no delimiter.
|
||
expected := backend.WalkVersioningResults{
|
||
ObjectVersions: []s3response.ObjectVersion{
|
||
{Key: backend.GetPtrFromString("dir1/")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a.b/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file1")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file2")},
|
||
{Key: backend.GetPtrFromString("dir1/a/file3")},
|
||
},
|
||
}
|
||
|
||
res, err := backend.WalkVersions(context.Background(), fsys, "dir1/", "", "", "", 1000, getVersionsTestFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("order walk versions: %v", err)
|
||
}
|
||
compareVersionResultsOrdered("order versions no delimiter", res, expected, t)
|
||
}
|
||
|
||
// compareVersionResults compares unordered sets of common prefixes and object versions
|
||
// compareVersionResultsOrdered compares ordered slices
|
||
func compareVersionResultsOrdered(name string, got, wanted backend.WalkVersioningResults, t *testing.T) {
|
||
if !compareObjectVersionsOrdered(got.ObjectVersions, wanted.ObjectVersions) {
|
||
t.Errorf("%v: unexpected object versions, got %v wanted %v", name, printVersionObjects(got.ObjectVersions), printVersionObjects(wanted.ObjectVersions))
|
||
}
|
||
if !comparePrefixesOrdered(got.CommonPrefixes, wanted.CommonPrefixes) {
|
||
t.Errorf("%v: unexpected prefix, got %v wanted %v", name, printCommonPrefixes(got.CommonPrefixes), printCommonPrefixes(wanted.CommonPrefixes))
|
||
}
|
||
}
|
||
|
||
func compareObjectVersionsOrdered(a, b []s3response.ObjectVersion) bool {
|
||
if len(a) == 0 && len(b) == 0 {
|
||
return true
|
||
}
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i, ov := range a {
|
||
if ov.Key == nil || b[i].Key == nil {
|
||
return false
|
||
}
|
||
if *ov.Key != *b[i].Key {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func printVersionObjects(list []s3response.ObjectVersion) string {
|
||
res := "["
|
||
for _, ov := range list {
|
||
var key string
|
||
if ov.Key == nil {
|
||
key = "<nil>"
|
||
} else {
|
||
key = *ov.Key
|
||
}
|
||
if res == "[" {
|
||
res = res + key
|
||
} else {
|
||
res = res + ", " + key
|
||
}
|
||
}
|
||
return res + "]"
|
||
}
|
||
|
||
// multiVersionGetVersionsFunc is a more sophisticated test function that simulates
|
||
// multiple versions per object, similar to the integration test behavior.
|
||
// It creates multiple versions for each file with deterministic version IDs.
|
||
func createMultiVersionFunc(files map[string]int) backend.GetVersionsFunc {
|
||
// Pre-generate all versions for deterministic testing
|
||
versionedFiles := make(map[string][]s3response.ObjectVersion)
|
||
|
||
for path, versionCount := range files {
|
||
versions := make([]s3response.ObjectVersion, versionCount)
|
||
for i := range versionCount {
|
||
versionId := fmt.Sprintf("v%d", i+1)
|
||
isLatest := i == versionCount-1 // Last version is latest
|
||
key := path
|
||
|
||
versions[i] = s3response.ObjectVersion{
|
||
Key: &key,
|
||
VersionId: &versionId,
|
||
IsLatest: &isLatest,
|
||
}
|
||
}
|
||
// Reverse slice so latest comes first (reverse chronological order)
|
||
for i, j := 0, len(versions)-1; i < j; i, j = i+1, j-1 {
|
||
versions[i], versions[j] = versions[j], versions[i]
|
||
}
|
||
versionedFiles[path] = versions
|
||
}
|
||
|
||
return func(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) {
|
||
if availableObjCount <= 0 {
|
||
return &backend.ObjVersionFuncResult{Truncated: true}, nil
|
||
}
|
||
|
||
// Handle directories - just return a single directory version
|
||
if d.IsDir() {
|
||
key := path + "/"
|
||
ver := "v1"
|
||
latest := true
|
||
ov := s3response.ObjectVersion{Key: &key, VersionId: &ver, IsLatest: &latest}
|
||
return &backend.ObjVersionFuncResult{ObjectVersions: []s3response.ObjectVersion{ov}}, nil
|
||
}
|
||
|
||
// Get versions for this file
|
||
versions, exists := versionedFiles[path]
|
||
if !exists {
|
||
// No versions for this file, skip it
|
||
return &backend.ObjVersionFuncResult{}, backend.ErrSkipObj
|
||
}
|
||
|
||
// Handle version ID marker pagination
|
||
startIdx := 0
|
||
if versionIdMarker != "" && !*pastVersionIdMarker {
|
||
// Find the starting position after the marker
|
||
for i, version := range versions {
|
||
if *version.VersionId == versionIdMarker {
|
||
startIdx = i + 1
|
||
*pastVersionIdMarker = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Return available versions up to the limit
|
||
endIdx := min(startIdx+availableObjCount, len(versions))
|
||
|
||
result := &backend.ObjVersionFuncResult{
|
||
ObjectVersions: versions[startIdx:endIdx],
|
||
}
|
||
|
||
// Check if we need to truncate
|
||
if endIdx < len(versions) {
|
||
result.Truncated = true
|
||
result.NextVersionIdMarker = *versions[endIdx-1].VersionId
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
}
|
||
|
||
// TestWalkVersionsTruncated tests the pagination behavior of WalkVersions
|
||
// when there are multiple versions per object and the result is truncated.
|
||
// This mirrors the integration test ListObjectVersions_multiple_object_versions_truncated.
|
||
func TestWalkVersionsTruncated(t *testing.T) {
|
||
// Create filesystem with the same files as integration test
|
||
fsys := fstest.MapFS{
|
||
"foo": {},
|
||
"bar": {},
|
||
"baz": {},
|
||
}
|
||
|
||
// Define version counts per file (matching integration test)
|
||
versionCounts := map[string]int{
|
||
"foo": 4, // 4 versions
|
||
"bar": 3, // 3 versions
|
||
"baz": 5, // 5 versions
|
||
}
|
||
|
||
getVersionsFunc := createMultiVersionFunc(versionCounts)
|
||
|
||
// Test first page with limit of 5 (should be truncated)
|
||
maxKeys := 5
|
||
res1, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", maxKeys, getVersionsFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("walk versions first page: %v", err)
|
||
}
|
||
|
||
// Verify first page results
|
||
if !res1.Truncated {
|
||
t.Error("expected first page to be truncated")
|
||
}
|
||
|
||
if len(res1.ObjectVersions) != maxKeys {
|
||
t.Errorf("expected %d versions in first page, got %d", maxKeys, len(res1.ObjectVersions))
|
||
}
|
||
|
||
// Expected order: bar (3 versions), baz (2 versions) - lexicographic order
|
||
expectedFirstPage := []string{"bar", "bar", "bar", "baz", "baz"}
|
||
if len(res1.ObjectVersions) != len(expectedFirstPage) {
|
||
t.Fatalf("first page length mismatch: expected %d, got %d", len(expectedFirstPage), len(res1.ObjectVersions))
|
||
}
|
||
|
||
for i, expected := range expectedFirstPage {
|
||
if res1.ObjectVersions[i].Key == nil || *res1.ObjectVersions[i].Key != expected {
|
||
t.Errorf("first page[%d]: expected key %s, got %v", i, expected, res1.ObjectVersions[i].Key)
|
||
}
|
||
}
|
||
|
||
// Verify next markers are set
|
||
if res1.NextMarker == "" {
|
||
t.Error("expected NextMarker to be set on truncated result")
|
||
}
|
||
if res1.NextVersionIdMarker == "" {
|
||
t.Error("expected NextVersionIdMarker to be set on truncated result")
|
||
}
|
||
|
||
// Test second page using markers
|
||
res2, err := backend.WalkVersions(context.Background(), fsys, "", "", res1.NextMarker, res1.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("walk versions second page: %v", err)
|
||
}
|
||
|
||
t.Logf("Second page: ObjectVersions=%d, Truncated=%v, NextMarker=%s, NextVersionIdMarker=%s",
|
||
len(res2.ObjectVersions), res2.Truncated, res2.NextMarker, res2.NextVersionIdMarker)
|
||
|
||
for i, ov := range res2.ObjectVersions {
|
||
t.Logf(" [%d] Key=%s, VersionId=%s", i, *ov.Key, *ov.VersionId)
|
||
}
|
||
|
||
// Verify second page results
|
||
// With maxKeys=5, we should have 3 pages total: 5 + 5 + 2 = 12
|
||
|
||
// Test third page if needed
|
||
var res3 backend.WalkVersioningResults
|
||
if res2.Truncated {
|
||
res3, err = backend.WalkVersions(context.Background(), fsys, "", "", res2.NextMarker, res2.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("walk versions third page: %v", err)
|
||
}
|
||
|
||
t.Logf("Third page: ObjectVersions=%d, Truncated=%v, NextMarker=%s, NextVersionIdMarker=%s",
|
||
len(res3.ObjectVersions), res3.Truncated, res3.NextMarker, res3.NextVersionIdMarker)
|
||
|
||
for i, ov := range res3.ObjectVersions {
|
||
t.Logf(" [%d] Key=%s, VersionId=%s", i, *ov.Key, *ov.VersionId)
|
||
}
|
||
}
|
||
|
||
// Verify total count across all pages
|
||
totalVersions := len(res1.ObjectVersions) + len(res2.ObjectVersions) + len(res3.ObjectVersions)
|
||
expectedTotal := versionCounts["foo"] + versionCounts["bar"] + versionCounts["baz"]
|
||
if totalVersions != expectedTotal {
|
||
t.Errorf("total versions mismatch: expected %d, got %d", expectedTotal, totalVersions)
|
||
}
|
||
}
|
||
|
||
// TestWalkVersionsPrefixSkipsParents reproduces the bug (#1864) where
|
||
// ListObjectVersions with a deep prefix and delimiter="/" was incorrectly
|
||
// returning version entries for ancestor directories (e.g. "vendor/",
|
||
// "vendor/Backup/") in addition to the the prefix directory itself.
|
||
// Only "vendor/Backup/vendor/Clients/" should appear in ObjectVersions; all
|
||
// deeper entries should become CommonPrefixes.
|
||
func TestWalkVersionsPrefixSkipsParents(t *testing.T) {
|
||
fsys := fstest.MapFS{
|
||
"vendor": {Mode: fs.ModeDir},
|
||
"vendor/Backup": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor/Clients": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor/Clients/abc": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor/Clients/abc/backup.vbm": {},
|
||
}
|
||
|
||
prefix := "vendor/Backup/vendor/Clients/"
|
||
delimiter := "/"
|
||
|
||
res, err := backend.WalkVersions(context.Background(), fsys, prefix, delimiter, "", "", 1000, getVersionsTestFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("WalkVersions: %v", err)
|
||
}
|
||
|
||
// Only the exact prefix directory should appear as an ObjectVersion.
|
||
expectedVersionKeys := []string{"vendor/Backup/vendor/Clients/"}
|
||
if !compareObjectVersionsOrdered(res.ObjectVersions, makeObjectVersions(expectedVersionKeys)) {
|
||
t.Errorf("unexpected ObjectVersions: got %v, want %v",
|
||
printVersionObjects(res.ObjectVersions), expectedVersionKeys)
|
||
}
|
||
|
||
// The child directory should be a CommonPrefix.
|
||
expectedPrefixes := []string{"vendor/Backup/vendor/Clients/abc/"}
|
||
if !comparePrefixesUnordered(res.CommonPrefixes, expectedPrefixes) {
|
||
t.Errorf("unexpected CommonPrefixes: got %v, want %v",
|
||
printCommonPrefixes(res.CommonPrefixes), expectedPrefixes)
|
||
}
|
||
}
|
||
|
||
// TestWalkVersionsPrefixNoDelimiterSkipsParents verifies that even without a
|
||
// delimiter, ancestor directories of the prefix are not returned as versions.
|
||
func TestWalkVersionsPrefixNoDelimiterSkipsParents(t *testing.T) {
|
||
fsys := fstest.MapFS{
|
||
"vendor": {Mode: fs.ModeDir},
|
||
"vendor/Backup": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor/Clients": {Mode: fs.ModeDir},
|
||
"vendor/Backup/vendor/Clients/file.vbm": {},
|
||
}
|
||
|
||
prefix := "vendor/Backup/vendor/Clients/"
|
||
|
||
res, err := backend.WalkVersions(context.Background(), fsys, prefix, "", "", "", 1000, getVersionsTestFunc, []string{})
|
||
if err != nil {
|
||
t.Fatalf("WalkVersions: %v", err)
|
||
}
|
||
|
||
// Ancestor keys must not appear. every returned key must start with prefix.
|
||
for _, ov := range res.ObjectVersions {
|
||
if ov.Key == nil {
|
||
t.Error("nil key in ObjectVersions")
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(*ov.Key, prefix) {
|
||
t.Errorf("ancestor key leaked into results: %q", *ov.Key)
|
||
}
|
||
}
|
||
|
||
// The prefix dir itself and its file should be the only versions.
|
||
expectedVersionKeys := []string{
|
||
"vendor/Backup/vendor/Clients/",
|
||
"vendor/Backup/vendor/Clients/file.vbm",
|
||
}
|
||
if !compareObjectVersionsOrdered(res.ObjectVersions, makeObjectVersions(expectedVersionKeys)) {
|
||
t.Errorf("unexpected ObjectVersions: got %v, want %v",
|
||
printVersionObjects(res.ObjectVersions), expectedVersionKeys)
|
||
}
|
||
}
|
||
|
||
// makeObjectVersions builds a []s3response.ObjectVersion slice with just keys
|
||
// set, for use in test comparisons.
|
||
func makeObjectVersions(keys []string) []s3response.ObjectVersion {
|
||
ovs := make([]s3response.ObjectVersion, len(keys))
|
||
for i, k := range keys {
|
||
key := k
|
||
ovs[i] = s3response.ObjectVersion{Key: &key}
|
||
}
|
||
return ovs
|
||
}
|
||
|
||
// comparePrefixesUnordered checks that got contains exactly the expected
|
||
// prefix strings regardless of order.
|
||
func comparePrefixesUnordered(got []types.CommonPrefix, want []string) bool {
|
||
if len(got) != len(want) {
|
||
return false
|
||
}
|
||
wantSet := make(map[string]bool, len(want))
|
||
for _, w := range want {
|
||
wantSet[w] = true
|
||
}
|
||
for _, cp := range got {
|
||
if cp.Prefix == nil || !wantSet[*cp.Prefix] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|