fix: ignore implicit directories for Get/HeadObject

Directories that exist on the filesystem but were not explicitly created
via S3 (put-object with a key ending in '/') do not have an etag
value. ListObjectsV2 already uses the presence of this attribute to
decide whether to include a directory as an object. GetObject and
HeadObject were not performing this check, so they would successfully
return directories that ListObjectsV2 would not list.

Add the etag attribute check in GetObject and HeadObject: if a
directory path is requested but has no etag xattr, return 404. This
makes all three operations agree on which directories are S3 objects.

Fixes #2130
This commit is contained in:
Ben McClelland
2026-05-12 18:56:00 -07:00
parent 67c50ffa0e
commit 2c0844ad88
4 changed files with 86 additions and 0 deletions
+26
View File
@@ -4496,6 +4496,19 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge
if !strings.HasSuffix(object, "/") && fid.IsDir() {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if fid.IsDir() {
// Only directories explicitly created via S3 (put-object with key ending
// in '/') have an etag attribute. Directories created incidentally on the
// filesystem or as parent directories during object upload should not be
// accessible via get-object.
_, derr := p.meta.RetrieveAttribute(nil, bucket, object, etagkey)
if errors.Is(derr, meta.ErrNoSuchKey) || errors.Is(derr, fs.ErrNotExist) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if derr != nil {
return nil, fmt.Errorf("get dir etag: %w", derr)
}
}
if p.versioningEnabled() {
isDelMarker, err := p.isObjDeleteMarker(bucket, object)
@@ -4821,6 +4834,19 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.
if !strings.HasSuffix(object, "/") && fi.IsDir() {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if fi.IsDir() {
// Only directories explicitly created via S3 (put-object with key ending
// in '/') have an etag attribute. Directories created incidentally on the
// filesystem or as parent directories during object upload should not be
// accessible via head-object.
_, derr := p.meta.RetrieveAttribute(nil, bucket, object, etagkey)
if errors.Is(derr, meta.ErrNoSuchKey) || errors.Is(derr, fs.ErrNotExist) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if derr != nil {
return nil, fmt.Errorf("get dir etag: %w", derr)
}
}
if p.versioningEnabled() {
isDelMarker, err := p.isObjDeleteMarker(bucket, object)
+28
View File
@@ -829,6 +829,34 @@ func GetObject_not_enabled_checksum_mode(s *S3Conf) error {
})
}
// GetObject_incidental_dir_object verifies that a directory created incidentally
// as a parent during object upload (i.e. never explicitly PUT via S3 with a
// trailing-slash key) is not accessible via GetObject.
func GetObject_incidental_dir_object(s *S3Conf) error {
testName := "GetObject_incidental_dir_object"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// Upload an object under a prefix; this creates the parent directory
// incidentally on posix but the directory was never PUT as an S3 object.
obj := "my-dir/my-obj"
_, err := putObjectWithData(int64(64), &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
dir := "my-dir/"
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &dir,
})
cancel()
return checkSdkApiErr(err, "NoSuchKey")
})
}
func GetObject_non_existing_dir_object(s *S3Conf) error {
testName := "GetObject_non_existing_dir_object"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+28
View File
@@ -62,6 +62,34 @@ func HeadObject_invalid_part_number(s *S3Conf) error {
})
}
// HeadObject_incidental_dir_object verifies that a directory created incidentally
// as a parent during object upload (i.e. never explicitly PUT via S3 with a
// trailing-slash key) is not accessible via HeadObject.
func HeadObject_incidental_dir_object(s *S3Conf) error {
testName := "HeadObject_incidental_dir_object"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// Upload an object under a prefix; this creates the parent directory
// incidentally on posix but the directory was never PUT as an S3 object.
obj := "my-dir/my-obj"
_, err := putObjectWithData(int64(64), &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
dir := "my-dir/"
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &dir,
})
cancel()
return checkSdkApiErr(err, "NotFound")
})
}
func HeadObject_non_existing_dir_object(s *S3Conf) error {
testName := "HeadObject_non_existing_dir_object"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+4
View File
@@ -205,6 +205,7 @@ func TestHeadObject(ts *TestState) {
ts.Run(HeadObject_invalid_part_number)
ts.Run(HeadObject_directory_object_noslash)
ts.Run(HeadObject_non_existing_dir_object)
ts.Run(HeadObject_incidental_dir_object)
ts.Run(HeadObject_invalid_parent_dir)
ts.Run(HeadObject_with_range)
ts.Run(HeadObject_by_range_resp_status)
@@ -263,6 +264,7 @@ func TestGetObject(ts *TestState) {
ts.Run(GetObject_directory_success)
ts.Run(GetObject_by_range_resp_status)
ts.Run(GetObject_non_existing_dir_object)
ts.Run(GetObject_incidental_dir_object)
ts.Run(GetObject_overrides_success)
ts.Run(GetObject_overrides_presign_success)
ts.Run(GetObject_overrides_fail_public)
@@ -1412,6 +1414,7 @@ func GetIntTests() IntTests {
"HeadObject_invalid_part_number": HeadObject_invalid_part_number,
"HeadObject_directory_object_noslash": HeadObject_directory_object_noslash,
"HeadObject_non_existing_dir_object": HeadObject_non_existing_dir_object,
"HeadObject_incidental_dir_object": HeadObject_incidental_dir_object,
"HeadObject_name_too_long": HeadObject_name_too_long,
"HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir,
"HeadObject_with_range": HeadObject_with_range,
@@ -1456,6 +1459,7 @@ func GetIntTests() IntTests {
"GetObject_directory_success": GetObject_directory_success,
"GetObject_by_range_resp_status": GetObject_by_range_resp_status,
"GetObject_non_existing_dir_object": GetObject_non_existing_dir_object,
"GetObject_incidental_dir_object": GetObject_incidental_dir_object,
"GetObject_overrides_success": GetObject_overrides_success,
"GetObject_overrides_presign_success": GetObject_overrides_presign_success,
"GetObject_overrides_fail_public": GetObject_overrides_fail_public,