diff --git a/test/s3/versioning/s3_hidden_version_listing_test.go b/test/s3/versioning/s3_hidden_version_listing_test.go new file mode 100644 index 000000000..965f18cf2 --- /dev/null +++ b/test/s3/versioning/s3_hidden_version_listing_test.go @@ -0,0 +1,179 @@ +package s3api + +import ( + "bytes" + "context" + "fmt" + "sort" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A listing walks entries and drops the ones whose current version is a delete +// marker. When a whole run of consecutive entries drops out, the page it was +// filling can come back empty — and an empty page is easily mistaken for the end +// of the listing. Everything after the run then never appears, and the caller is +// told the objects do not exist. Backup repositories hit this shape constantly: +// they retract a batch of keys under one prefix and keep writing under the next. + +func putObjectVersioned(t *testing.T, client *s3.Client, bucket, key string) { + t.Helper() + _, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader([]byte("x")), + }) + require.NoError(t, err) +} + +func listAllKeys(t *testing.T, client *s3.Client, bucket string, maxKeys int32) []string { + t.Helper() + var keys []string + var token *string + for { + in := &s3.ListObjectsV2Input{Bucket: aws.String(bucket), ContinuationToken: token} + if maxKeys > 0 { + in.MaxKeys = aws.Int32(maxKeys) + } + resp, err := client.ListObjectsV2(context.TODO(), in) + require.NoError(t, err) + for _, o := range resp.Contents { + require.NotNil(t, o.Key) + keys = append(keys, *o.Key) + } + if resp.IsTruncated == nil || !*resp.IsTruncated { + break + } + require.NotNil(t, resp.NextContinuationToken, "a truncated listing must carry a continuation token") + token = resp.NextContinuationToken + } + sort.Strings(keys) + return keys +} + +func TestListingSurvivesRunOfHiddenVersions(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // A long run of retracted keys, sorting before the live ones. + const hidden = 25 + for i := 0; i < hidden; i++ { + key := fmt.Sprintf("aretracted/%03d.lock", i) + putObjectVersioned(t, client, bucketName, key) + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), Key: aws.String(key), + }) + require.NoError(t, err) + } + + var live []string + for i := 0; i < 5; i++ { + key := fmt.Sprintf("blive/%03d.blk", i) + putObjectVersioned(t, client, bucketName, key) + live = append(live, key) + } + sort.Strings(live) + + // Unpaginated: the retracted run must not hide what follows it. + assert.Equal(t, live, listAllKeys(t, client, bucketName, 0), + "a run of retracted keys must not truncate the listing") + + // Paginated with pages smaller than the retracted run, so at least one page + // is filled entirely from entries that get dropped. + for _, pageSize := range []int32{1, 2, 3, 5, 10} { + t.Run(fmt.Sprintf("maxKeys=%d", pageSize), func(t *testing.T) { + assert.Equal(t, live, listAllKeys(t, client, bucketName, pageSize), + "pagination across a retracted run must not lose the keys after it") + }) + } +} + +// The same shape with the retracted run in the middle, so both a preceding and a +// following key have to survive it. +func TestListingSurvivesHiddenVersionsBetweenLiveKeys(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + putObjectVersioned(t, client, bucketName, "a-first.blk") + + for i := 0; i < 20; i++ { + key := fmt.Sprintf("m-retracted/%03d.lock", i) + putObjectVersioned(t, client, bucketName, key) + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), Key: aws.String(key), + }) + require.NoError(t, err) + } + + putObjectVersioned(t, client, bucketName, "z-last.blk") + + want := []string{"a-first.blk", "z-last.blk"} + for _, pageSize := range []int32{0, 1, 2, 3, 5, 10} { + t.Run(fmt.Sprintf("maxKeys=%d", pageSize), func(t *testing.T) { + assert.Equal(t, want, listAllKeys(t, client, bucketName, pageSize), + "keys on both sides of a retracted run must both be listed") + }) + } +} + +// ListObjectVersions sees the same namespace from the other side: the retracted +// keys are still there as versions plus delete markers, and must all be reported. +func TestListObjectVersionsReportsRetractedKeys(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + const retracted = 10 + for i := 0; i < retracted; i++ { + key := fmt.Sprintf("retracted/%03d.lock", i) + putObjectVersioned(t, client, bucketName, key) + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), Key: aws.String(key), + }) + require.NoError(t, err) + } + + // Paginated with a page far smaller than the run, so the continuation markers + // have to carry the walk across it — the same path the plain listing exercises + // above, but on the version side. + var versions, markers int + var keyMarker, versionIDMarker *string + for { + resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucketName), + MaxKeys: aws.Int32(3), + KeyMarker: keyMarker, + VersionIdMarker: versionIDMarker, + }) + require.NoError(t, err) + versions += len(resp.Versions) + markers += len(resp.DeleteMarkers) + if resp.IsTruncated == nil || !*resp.IsTruncated { + break + } + require.NotNil(t, resp.NextKeyMarker, "a truncated version listing must carry a key marker") + keyMarker, versionIDMarker = resp.NextKeyMarker, resp.NextVersionIdMarker + } + + assert.Equal(t, retracted, versions, "every written version must still be reported") + assert.Equal(t, retracted, markers, "every retraction must be reported as a delete marker") + + // And the current-version view of the same namespace is empty. + assert.Empty(t, listAllKeys(t, client, bucketName, 0), + "no key should be current once every one has been retracted") +}