fix(s3api): drop ancestor directory markers from prefixed ListObjectVersions (#9885)

processExplicitDirectory appended a directory-key object as a version
without checking it against the prefix. A versioned listing descends
through ancestor markers to reach a deeper prefix, so every ancestor
(Veeam/, Veeam/Backup/, ...) leaked into Versions even though none of
them match the prefix - which makes Veeam's immutable repository scan
abort on an unexpected key. Guard on the prefix so only keys at or under
it surface, matching ListObjectsV2 and AWS.
This commit is contained in:
Chris Lu
2026-06-09 00:01:06 -07:00
committed by GitHub
parent d569dd686f
commit 2871e6552a
2 changed files with 88 additions and 0 deletions
@@ -159,6 +159,85 @@ func TestListObjectVersionsIncludesDirectories(t *testing.T) {
assert.Equal(t, len(testFiles), fileCount, "Should find exactly %d files", len(testFiles))
}
// TestListObjectVersionsDeepPrefixExcludesAncestorDirectories verifies that a
// prefix+delimiter version listing does not leak ancestor directory markers that
// the listing only descends through to reach the prefix. Veeam's immutable
// backup repository (versioning + object lock) issues exactly this request from
// Cloud.FindLastCheckpointId and aborts when it sees an unexpected key like
// "Veeam/" that does not match the deep prefix it asked for. The version listing
// must match ListObjectsV2 / AWS: only keys at or under the prefix appear.
func TestListObjectVersionsDeepPrefixExcludesAncestorDirectories(t *testing.T) {
bucketName := "test-versioning-deep-prefix"
client := setupS3Client(t)
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
defer cleanupBucket(t, client, bucketName)
_, err = client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{
Bucket: aws.String(bucketName),
VersioningConfiguration: &types.VersioningConfiguration{
Status: types.BucketVersioningStatusEnabled,
},
})
require.NoError(t, err)
// Explicit directory markers for every parent path, as Veeam creates them.
ancestorMarkers := []string{
"Veeam/",
"Veeam/Backup/",
"Veeam/Backup/job1/",
"Veeam/Backup/job1/Clients/",
"Veeam/Backup/job1/Clients/aaaa/",
"Veeam/Backup/job1/Clients/aaaa/bbbb/",
}
prefix := "Veeam/Backup/job1/Clients/aaaa/bbbb/Metadata/"
checkpointKey := prefix + "Checkpoint"
for _, dirKey := range append(ancestorMarkers, prefix) {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(dirKey),
Body: strings.NewReader(""),
})
require.NoError(t, err, "Failed to create directory marker %s", dirKey)
}
_, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(checkpointKey),
Body: strings.NewReader("checkpoint"),
})
require.NoError(t, err)
listResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
Prefix: aws.String(prefix),
Delimiter: aws.String("/"),
})
require.NoError(t, err)
var gotKeys []string
for _, v := range listResp.Versions {
gotKeys = append(gotKeys, *v.Key)
assert.True(t, strings.HasPrefix(*v.Key, prefix),
"version key %q must start with the requested prefix %q", *v.Key, prefix)
}
// Only the prefix's own marker and the real object at/under it may appear.
assert.ElementsMatch(t, []string{prefix, checkpointKey}, gotKeys,
"deep-prefix version listing must not include ancestor directory markers")
// None of the ancestor markers ("Veeam/", ...) may surface as versions.
for _, marker := range ancestorMarkers {
assert.NotContains(t, gotKeys, marker,
"ancestor directory marker %q must not appear in a deep-prefix version listing", marker)
}
}
// TestListObjectVersionsDeleteMarkers tests that delete markers are properly separated from versions
// This test verifies the fix for the issue where delete markers were incorrectly categorized as versions
func TestListObjectVersionsDeleteMarkers(t *testing.T) {
+9
View File
@@ -652,6 +652,15 @@ func (vc *versionCollector) processExplicitDirectory(entryPath string, entry *fi
directoryKey += "/"
}
// Only surface a directory key whose own key matches the prefix. Ancestor
// markers (e.g. "Veeam/") get descended through to reach a deeper prefix but
// don't match it themselves, so they must not appear as version entries -
// this mirrors ListObjectsV2 and AWS, and stops clients like Veeam that
// reject unexpected keys in a listing from aborting.
if !strings.HasPrefix(directoryKey, vc.prefix) {
return
}
// Skip directories at or before keyMarker
if vc.keyMarker != "" && directoryKey <= vc.keyMarker {
return