Files
seaweedfs/test/s3/versioning/s3_preversioning_delete_test.go
T
Chris LuandGitHub 7c87d78ea2 s3: a key deleted after enabling versioning must leave the listing (#10684)
* s3: a null object wins over a rescan when the latest-version pointer is absent

The read path already resolves an absent pointer this way; the listing-path
counterpart scanned .versions/ first and could surface an old version or
delete marker over the current suspended-versioning null object.

* s3: dedup a key against its .versions sibling in suspended buckets too

A suspended bucket keeps its .versions directories, so a suspended-versioning
null object and its .versions sibling emitted the same key twice.

* s3: retract a null object from the listing when a delete marker shadows it

Deleting a key whose null version predates versioning leaves the base-path
entry in place and records the delete marker under <key>.versions. The
listing appended the base-path entry and relied on the .versions sibling to
replace it, but a delete-marker current version emitted nothing, so the
deleted key stayed visible to ListObjects while GET and HEAD returned 404.

* s3: keep a key's .versions sibling on the same page as the key

When the page quota ran out between a base-path entry and its .versions
directory, the page ended with the stale entry and the next page skipped the
directory as a marker echo, so the replacement or retraction never happened.

* s3: the null version is not latest when the .versions pointer names a newer one

ListObjectVersions stamped IsLatest on every base-path null object, so a key
deleted after enabling versioning reported IsLatest on both the delete marker
and the null version.

* s3: test listing after a pre-versioning null object is delete-marked

* s3: find a key's earlier page entry by scan, not by adjacency

A key such as k.bak sorts between k and k.versions, so the entry a .versions
sibling replaces or retracts is not always the last one on the page. Scan
back through the page for the key, and insert a late resolution in sorted
position instead of at the end.

* s3: settle trailing null objects by lookup when a page fills

The quota can run out while keys still sit between a null object and its
.versions sibling, and the sibling-adjacent page-boundary exception never
fires for those. Track the trailing null objects whose sibling has not been
ruled out and look each one up before declaring the page full; a retraction
reopens the quota.

* s3: do not resolve a .versions sibling its page has already moved past

A page resuming from a marker inside the base key's extension region has
already listed and settled the base null object on an earlier page, so
resolving the .versions directory again re-emitted the key.

* s3: test listing with keys between a null object and its .versions sibling

* s3: pick the newer of the null object and the scanned versions

Making the null object win outright whenever the pointer is absent misread
multi-filer pointer lag: version files replicate ahead of the pointer, and a
key overwritten or delete-marked after pre-versioning days would list its
stale null again. The suspended-versioning write that legitimately makes the
null current is also the newer entry, so mtime tells the two apart.

* s3: a delete-marked null object no longer keeps its prefix alive

The hidden-entries probe took any plain file as proof of a listable key, but
a null object shadowed by its .versions sibling's delete marker is not one.
Hold plain files pending until the sibling settles them either way.

* s3: settle an evicted pending null instead of dropping it

Nested keys like k, k!, k!! can hold more pending nulls than the cap. A
silently evicted one could close the page unsettled, and the resume skip
would then keep the stale entry for good.

* s3: test deleted-prefix hiding and the pending-null cap

* s3: cover the reported '!' intervening key with a live version

* s3: an unstamped same-second version outranks the null object

Second-resolution mtimes cannot order same-second writes, so the tie went to
the stale null when the pointer lagged. The suspended write that makes a null
current stamps the version it displaces before clearing the pointer, so the
stamp is the authoritative signal and a tie without it goes to the version.

* s3: a pointer-less versions listing still checks what replicated

ListObjectVersions took a missing pointer as proof the null object is latest,
but under pointer lag the sibling can already hold newer replicated versions
or markers. Apply the same nullObjectWins rule as the listing recovery.

* s3: a failed null-object settlement fails the listing

Every getEntry error read as a missing sibling, so a transient filer error at
a page boundary committed the unsettled null and the next page skipped its
sibling for good. Only a definitive not-found means the null is live; other
failures are retained on eviction and fail the request at page close.

* s3: retract a CommonPrefix whose only backers were delete-marked nulls

The directory probe settles this for the / delimiter, but any other delimiter
derives prefixes from base-path keys directly, and a prefix built solely from
null objects survived their delete markers. Count the unsettled null backers
behind the newest prefix and retract it when the last one settles as a marker;
a live resolution or any listable contributor confirms the prefix instead.

* s3: test custom-delimiter prefix retraction

* s3: an explicit signal marks the null object current, not the demotion stamp

The NoncurrentSinceNs stamp survives promotion: delete the version that
demoted another and the promoted one is current yet still stamped, so a
lagging replica would resurrect the stale null. A suspended-versioning write
now records Seaweed-X-Amz-Null-Version-Is-Latest on the .versions directory
when it clears the pointer, every pointer update removes it, and the
recovery paths trust the signal instead of the stamp.

* s3: a filer failover retry rebuilds the listing page from scratch

The failover wrapper reruns the callback on another filer after a transport
error, and the partially built page, spent quota, and advanced marker leaked
into the retry, which could then return a stale or duplicated page as
success.

* s3: only a prefix's own backers can debit it

A delete marker for a version-only key (no base object) derived the same
prefix as its neighbors and decremented backing it never contributed,
retracting a prefix that a live null object still backed. Track backers by
key so settlement is idempotent and only debits what was counted.

* s3: test a version-only marker against a null-backed prefix

* s3: a pointer recompute clears the null-current signal

The routed finalize for delete markers, COPY, and multipart rewrites the
.versions pointer through RECOMPUTE_LATEST, which left a suspended-era
null-current signal in place. Version files never carry the signal, so
mapping it in CopyExtended deletes it whenever the pointer recomputes.

* s3: the pointer outranks the null-current signal in the versions listing

The signal check guarded the pointer check, so a stale signal a recompute
had not cleared yet would have let the null claim IsLatest alongside the
pointed-at version.
2026-08-10 11:04:06 -07:00

294 lines
11 KiB
Go

package s3api
import (
"context"
"sort"
"strings"
"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"
)
// Deleting a key whose null version predates versioning records the delete
// marker under <key>.versions but leaves the null object at the base path.
// The listing must retract that stale entry, on whichever page it lands.
func deleteObject(t *testing.T, client *s3.Client, bucket, key string) {
t.Helper()
resp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err)
require.True(t, resp.DeleteMarker != nil && *resp.DeleteMarker, "the delete must record a delete marker")
}
func TestPreVersioningNullObjectDeleteHidesKey(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "k.txt", "pre-versioning")
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "k.txt")
assert.Empty(t, listAllKeys(t, client, bucketName, 0), "the deleted key must leave the listing")
_, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName), Key: aws.String("k.txt"),
})
assert.Error(t, err, "the deleted key must not be readable")
versions, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
require.Len(t, versions.DeleteMarkers, 1)
assert.True(t, *versions.DeleteMarkers[0].IsLatest, "the delete marker is the current version")
require.Len(t, versions.Versions, 1)
assert.Equal(t, "null", *versions.Versions[0].VersionId)
assert.False(t, *versions.Versions[0].IsLatest, "the null version is shadowed by the marker")
}
func TestPreVersioningNullObjectDeleteAfterOverwriteHidesKey(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "k.txt", "pre-versioning")
enableVersioning(t, client, bucketName)
putObject(t, client, bucketName, "k.txt", "versioned overwrite")
deleteObject(t, client, bucketName, "k.txt")
assert.Empty(t, listAllKeys(t, client, bucketName, 0), "the deleted key must leave the listing")
}
// The retraction and the replacement must both survive a page boundary landing
// between a key and its .versions sibling.
func TestPreVersioningNullObjectAcrossPageBoundary(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "a.txt", "old")
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "a.txt")
putObjectVersioned(t, client, bucketName, "b.txt")
assert.Equal(t, []string{"b.txt"}, listAllKeys(t, client, bucketName, 1),
"a page ending on the stale null object must still retract it")
}
func TestPreVersioningNullObjectMetadataAcrossPageBoundary(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
overwrite := "versioned overwrite"
putObject(t, client, bucketName, "a.txt", "old")
enableVersioning(t, client, bucketName)
putObject(t, client, bucketName, "a.txt", overwrite)
putObjectVersioned(t, client, bucketName, "b.txt")
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName), MaxKeys: aws.Int32(1),
})
require.NoError(t, err)
require.Len(t, page.Contents, 1)
assert.Equal(t, "a.txt", *page.Contents[0].Key)
assert.Equal(t, int64(len(overwrite)), *page.Contents[0].Size,
"a page ending on the null object must still pick up the current version's metadata")
}
// A key such as "a.txt.bak" sorts between "a.txt" and "a.txt.versions", so the
// sibling's outcome arrives entries later, possibly on a later page.
func TestPreVersioningInterveningKeyRetraction(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "a.txt", "pre-versioning")
putObject(t, client, bucketName, "a.txt!between", "pre-versioning")
putObject(t, client, bucketName, "a.txt.bak", "pre-versioning")
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "a.txt")
for _, maxKeys := range []int32{0, 1, 2} {
assert.Equal(t, []string{"a.txt!between", "a.txt.bak"}, listAllKeys(t, client, bucketName, maxKeys),
"maxKeys=%d must not list the deleted key", maxKeys)
}
}
func TestPreVersioningInterveningKeyMetadata(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
overwrite := "versioned overwrite"
putObject(t, client, bucketName, "a.txt", "old")
putObject(t, client, bucketName, "a.txt!between", "pre-versioning")
putObject(t, client, bucketName, "a.txt.bak", "pre-versioning")
enableVersioning(t, client, bucketName)
putObject(t, client, bucketName, "a.txt", overwrite)
for _, maxKeys := range []int32{1, 2} {
var keys []string
var token *string
for {
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName), MaxKeys: aws.Int32(maxKeys), ContinuationToken: token,
})
require.NoError(t, err)
for _, o := range page.Contents {
keys = append(keys, *o.Key)
if *o.Key == "a.txt" {
assert.Equal(t, int64(len(overwrite)), *o.Size,
"maxKeys=%d must list the current version's metadata", maxKeys)
}
}
if page.IsTruncated == nil || !*page.IsTruncated {
break
}
token = page.NextContinuationToken
}
assert.Equal(t, []string{"a.txt", "a.txt!between", "a.txt.bak"}, keys, "maxKeys=%d", maxKeys)
}
}
// CommonPrefixes derive from listable keys, so deleting the only pre-versioning
// object under a prefix takes the prefix with it.
func TestPreVersioningDeletedPrefixHidesCommonPrefix(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "p/k.txt", "pre-versioning")
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "p/k.txt")
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName), Delimiter: aws.String("/"),
})
require.NoError(t, err)
assert.Empty(t, page.Contents)
assert.Empty(t, page.CommonPrefixes, "no listable key remains under p/")
}
// A non-slash delimiter derives prefixes straight from base-path keys, so a
// delete-marked null object must take its prefix along; a live key under the
// same prefix brings it back.
func TestPreVersioningDeletedKeyHidesCustomDelimiterPrefix(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "group-item", "pre-versioning")
putObject(t, client, bucketName, "solo", "pre-versioning")
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "group-item")
listDashed := func() (keys, prefixes []string) {
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName), Delimiter: aws.String("-"),
})
require.NoError(t, err)
for _, o := range page.Contents {
keys = append(keys, *o.Key)
}
for _, p := range page.CommonPrefixes {
prefixes = append(prefixes, *p.Prefix)
}
return
}
keys, prefixes := listDashed()
assert.Equal(t, []string{"solo"}, keys)
assert.Empty(t, prefixes, "the deleted key was the prefix's only backer")
putObject(t, client, bucketName, "group-live", "versioned")
keys, prefixes = listDashed()
assert.Equal(t, []string{"solo"}, keys)
assert.Equal(t, []string{"group-"}, prefixes, "a live key restores the prefix")
}
// A delete marker for a version-only key (never a base object) must not debit
// the backers of a prefix a live null object still stands behind.
func TestVersionOnlyMarkerLeavesForeignPrefixAlone(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
putObject(t, client, bucketName, "group-a", "pre-versioning")
enableVersioning(t, client, bucketName)
putObjectVersioned(t, client, bucketName, "group-b")
deleteObject(t, client, bucketName, "group-b")
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName), Delimiter: aws.String("-"),
})
require.NoError(t, err)
assert.Empty(t, page.Contents)
require.Len(t, page.CommonPrefixes, 1, "group-a still backs the prefix")
assert.Equal(t, "group-", *page.CommonPrefixes[0].Prefix)
}
// Nested names (k, k!, k!!, ...) can hold more unresolved null objects than the
// pending cap; the evicted key must still be settled, on any page size.
func TestPreVersioningNestedNullObjectsBeyondCap(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
var live []string
for i := 0; i < 10; i++ {
key := "k" + strings.Repeat("!", i)
putObject(t, client, bucketName, key, "pre-versioning")
if i > 0 {
live = append(live, key)
}
}
enableVersioning(t, client, bucketName)
deleteObject(t, client, bucketName, "k")
sort.Strings(live)
for _, maxKeys := range []int32{0, 3, 9} {
assert.Equal(t, live, listAllKeys(t, client, bucketName, maxKeys),
"maxKeys=%d must not list the deleted key", maxKeys)
}
}
// A suspended-versioning write is the current null version; its .versions
// sibling (whose latest pointer the write cleared) must not list it a second
// time or resurrect an older version's metadata.
func TestSuspendedNullObjectListsOnce(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
putObject(t, client, bucketName, "k.txt", "versioned")
suspendVersioning(t, client, bucketName)
current := "suspended current"
putObject(t, client, bucketName, "k.txt", current)
page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
require.Len(t, page.Contents, 1, "one key must list exactly once")
assert.Equal(t, int64(len(current)), *page.Contents[0].Size, "the null version is current")
}