diff --git a/test/s3/versioning/s3_listing_parity_test.go b/test/s3/versioning/s3_listing_parity_test.go new file mode 100644 index 000000000..fc8181003 --- /dev/null +++ b/test/s3/versioning/s3_listing_parity_test.go @@ -0,0 +1,270 @@ +package s3api + +import ( + "bytes" + "context" + "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" +) + +// ListObjects and ListObjectVersions walk the same namespace and must describe it +// the same way. They are separate code paths, so a client that navigates by +// versioned listings can see a different tree than one navigating by plain +// listings — and concludes keys are missing that are plainly there. Testing the +// two paths independently never catches that; only comparing them does. + +// listingParityTree is shaped after a backup repository: nested prefixes, a file +// beside a prefix at the same level, and a key that is both an object and the +// parent of other keys. +var listingParityTree = []string{ + "backup/archive/001/data.blk", + "backup/archive/002/data.blk", + "backup/archive/summary.xml", + "backup/meta", + "backup/meta/nested.txt", + "backup/index", +} + +func currentKeysAndPrefixes(t *testing.T, client *s3.Client, bucket, prefix, delimiter string) ([]string, []string) { + t.Helper() + in := &s3.ListObjectsV2Input{Bucket: aws.String(bucket)} + if prefix != "" { + in.Prefix = aws.String(prefix) + } + if delimiter != "" { + in.Delimiter = aws.String(delimiter) + } + resp, err := client.ListObjectsV2(context.TODO(), in) + require.NoError(t, err) + // One page only. A truncated response would silently compare partial results + // against partial results and report parity that was never checked. + require.False(t, aws.ToBool(resp.IsTruncated), "test data must fit one page") + + var keys, prefixes []string + for _, o := range resp.Contents { + require.NotNil(t, o.Key) + keys = append(keys, *o.Key) + } + for _, p := range resp.CommonPrefixes { + require.NotNil(t, p.Prefix) + prefixes = append(prefixes, *p.Prefix) + } + sort.Strings(keys) + sort.Strings(prefixes) + return keys, prefixes +} + +func currentKeysAndPrefixesFromVersions(t *testing.T, client *s3.Client, bucket, prefix, delimiter string) ([]string, []string) { + t.Helper() + in := &s3.ListObjectVersionsInput{Bucket: aws.String(bucket)} + if prefix != "" { + in.Prefix = aws.String(prefix) + } + if delimiter != "" { + in.Delimiter = aws.String(delimiter) + } + resp, err := client.ListObjectVersions(context.TODO(), in) + require.NoError(t, err) + require.False(t, aws.ToBool(resp.IsTruncated), "test data must fit one page") + + // Reduce the version view to the same thing ListObjects reports: keys whose + // current version is real content, not a delete marker. + // + // Delete markers are only visible here as individual entries. Under a + // delimiter, keys grouped into a common prefix do not appear individually, so + // this cannot tell that every key beneath a prefix is delete-marked — such a + // prefix stays in the returned list. Comparisons that need that case must + // query the prefix directly rather than relying on the grouped view. + deleted := make(map[string]bool) + for _, m := range resp.DeleteMarkers { + if m.Key != nil && m.IsLatest != nil && *m.IsLatest { + deleted[*m.Key] = true + } + } + var keys, prefixes []string + for _, v := range resp.Versions { + if v.Key == nil || v.IsLatest == nil || !*v.IsLatest { + continue + } + if deleted[*v.Key] { + continue + } + keys = append(keys, *v.Key) + } + for _, p := range resp.CommonPrefixes { + require.NotNil(t, p.Prefix) + prefixes = append(prefixes, *p.Prefix) + } + sort.Strings(keys) + sort.Strings(prefixes) + return keys, prefixes +} + +func TestListingParityAcrossPrefixesAndDelimiter(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + for _, key := range listingParityTree { + _, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: bytes.NewReader([]byte("x")), + }) + require.NoError(t, err) + } + + cases := []struct { + name string + prefix string + delimiter string + }{ + {"root delimited", "", "/"}, + {"root undelimited", "", ""}, + {"one level delimited", "backup/", "/"}, + {"one level undelimited", "backup/", ""}, + {"two levels delimited", "backup/archive/", "/"}, + {"leaf prefix delimited", "backup/archive/001/", "/"}, + // The prefix names an object exactly. Both listings must return that + // object and must not descend into anything below it. + {"prefix is exactly an object", "backup/archive/summary.xml", "/"}, + {"prefix is exactly an object undelimited", "backup/archive/summary.xml", ""}, + // A key that is also a parent: "backup/meta" is an object and + // "backup/meta/" is a prefix. The two listings must not disagree about + // which of those the client is looking at. + {"object that is also a prefix", "backup/meta", "/"}, + {"prefix form of that object", "backup/meta/", "/"}, + {"partial key fragment", "backup/ind", "/"}, + {"prefix matching nothing", "backup/nothing/", "/"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, tc.prefix, tc.delimiter) + versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, tc.prefix, tc.delimiter) + + assert.Equal(t, plainKeys, versionKeys, + "prefix=%q delimiter=%q: ListObjectVersions reports a different set of current keys than ListObjects", tc.prefix, tc.delimiter) + assert.Equal(t, plainPrefixes, versionPrefixes, + "prefix=%q delimiter=%q: ListObjectVersions reports different common prefixes than ListObjects", tc.prefix, tc.delimiter) + }) + } +} + +// Deleting every key under a prefix must retire the prefix from both listings +// together. A prefix that lingers in one view sends a client looking for keys the +// other view says are gone. +func TestListingParityAfterPrefixEmptied(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + keys := []string{"backup/archive/001/data.blk", "backup/archive/002/data.blk"} + versionIDs := make([]string, 0, len(keys)) + for _, key := range keys { + put, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: bytes.NewReader([]byte("x")), + }) + require.NoError(t, err) + require.NotNil(t, put.VersionId) + versionIDs = append(versionIDs, *put.VersionId) + } + + plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, "backup/archive/", "/") + versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, "backup/archive/", "/") + require.Equal(t, plainKeys, versionKeys) + require.Equal(t, plainPrefixes, versionPrefixes) + require.Len(t, plainPrefixes, 2, "both leaf prefixes should be visible while populated") + + // Remove the versions outright rather than layering delete markers, so the + // containers are left genuinely empty. + for i, key := range keys { + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: aws.String(versionIDs[i]), + }) + require.NoError(t, err) + } + + plainKeys, plainPrefixes = currentKeysAndPrefixes(t, client, bucketName, "backup/archive/", "/") + versionKeys, versionPrefixes = currentKeysAndPrefixesFromVersions(t, client, bucketName, "backup/archive/", "/") + + // Both views must still describe the same namespace. Whether an emptied + // prefix is retired promptly is a separate question from whether the two + // listings agree about it, and only the latter is under test here. + assert.Equal(t, plainKeys, versionKeys, "listings disagree on remaining keys after the prefix was emptied") + assert.Equal(t, plainPrefixes, versionPrefixes, "listings disagree on remaining common prefixes after the prefix was emptied") + // Asserted on both sides rather than leaning on the equality above to catch a + // divergence in whichever direction it happens. + assert.Empty(t, plainKeys, "no current keys should remain once every version is gone") + assert.Empty(t, versionKeys, "no current keys should remain once every version is gone") +} + +// The other way a key stops being current: a delete marker rather than removal of +// the version. Both listings must drop the key while its history stays intact. +func TestListingParityAfterDeleteMarker(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + const marked = "backup/archive/001/data.blk" + const kept = "backup/archive/002/data.blk" + for _, key := range []string{marked, kept} { + _, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: bytes.NewReader([]byte("x")), + }) + require.NoError(t, err) + } + + // No version id: this layers a delete marker instead of removing the version. + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(marked), + }) + require.NoError(t, err) + + for _, tc := range []struct{ prefix, delimiter string }{ + {"backup/archive/", ""}, + {"backup/archive/001/", "/"}, + {"backup/archive/001/", ""}, + } { + t.Run(tc.prefix+"|"+tc.delimiter, func(t *testing.T) { + plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, tc.prefix, tc.delimiter) + versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, tc.prefix, tc.delimiter) + + assert.Equal(t, plainKeys, versionKeys, + "prefix=%q delimiter=%q: listings disagree on current keys after a delete marker", tc.prefix, tc.delimiter) + assert.Equal(t, plainPrefixes, versionPrefixes, + "prefix=%q delimiter=%q: listings disagree on common prefixes after a delete marker", tc.prefix, tc.delimiter) + assert.NotContains(t, plainKeys, marked, "a delete-marked key is not current") + }) + } + + // The history the marker hides must still be there. + versions, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucketName), + Prefix: aws.String(marked), + }) + require.NoError(t, err) + assert.Len(t, versions.Versions, 1, "the superseded version must survive its delete marker") + assert.Len(t, versions.DeleteMarkers, 1) +}