mirror of
https://github.com/versity/versitygw.git
synced 2026-09-24 00:44:23 +00:00
A posix gateway serving a preexisting dataset treats every directory under the root as a bucket. But the bucket checks only stat the path, so a regular file, FIFO or symlink at the root passed for a bucket. Only `ListBuckets` skipped these entries. With a root-level file as the bucket, `HeadBucket`, `ListObjects`, `ListObjectsV2`, `ListMultipartUploads`, `GetBucketAcl`, `GetBucketLocation`, `GetBucketVersioning` and the bucket config getters returned a successful response. `PutBucketAcl`, `PutBucketTagging`, `PutBucketPolicy`, `PutBucketCors`, ... and the admin `ChangeBucketOwner` also succeeded, storing gateway metadata on the file itself or in the sidecar directory. `DeleteObject` and `DeleteObjects` reported success. `CreateMultipartUpload`, `UploadPart`, `UploadPartCopy`, `ListParts`, `CompleteMultipartUpload`, `ListObjectVersions` and `DeleteBucket` returned `InternalError` with not a directory. `PutObject` and `CopyObject` returned `ObjectParentIsFile`. `GetObject`, `HeadObject`, `GetObjectAttributes` and the object tagging, legal hold and retention actions returned `NoSuchKey`, and `AbortMultipartUpload` returned `NoSuchUpload`. A symlink loop at the root returned `InternalError` for every action. Bucket existence is now checked in one place, `doesBucketExist`, and `ScoutFS` uses it too through the exported `DoesBucketExist`. A bucket is a directory under the root, or a symlink to a directory when `--bucketlinks` is enabled. Any other entry returns `NoSuchBucket` for all bucket and object actions. `CreateBucket` on such an entry returns `BucketAlreadyExists` without reading its metadata. This also enforces `--bucketlinks` for all actions. Before, it only affected `ListBuckets`: a symlinked directory was hidden from the listing, but every other action followed the link and used it as a bucket even with the option disabled. Without `--bucketlinks` such symlinks now return `NoSuchBucket`.
359 lines
13 KiB
Go
359 lines
13 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package posix
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/versity/versitygw/s3err"
|
|
"github.com/versity/versitygw/s3response"
|
|
)
|
|
|
|
// TestRootEntriesThatAreNotBuckets checks that only directories under the
|
|
// root, and with BucketLinks symlinks to directories, are buckets. The root of
|
|
// a dataset the gateway did not create may hold files and symlinks next to the
|
|
// bucket directories: every bucket request naming one fails with NoSuchBucket
|
|
// and CreateBucket with BucketAlreadyExists, without changing the entry,
|
|
// storing metadata for it or writing through it.
|
|
func TestRootEntriesThatAreNotBuckets(t *testing.T) {
|
|
for mode, mkMeta := range metaModes(t) {
|
|
for _, bucketlinks := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("%v/bucketlinks=%v", mode, bucketlinks), func(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Chdir(root)
|
|
linked := t.TempDir()
|
|
|
|
const fileData = "not a bucket"
|
|
if err := os.WriteFile(filepath.Join(root, "file"), []byte(fileData), 0644); err != nil {
|
|
t.Fatalf("write root file: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(linked, "obj"), []byte(fileData), 0644); err != nil {
|
|
t.Fatalf("write linked directory file: %v", err)
|
|
}
|
|
for link, target := range map[string]string{
|
|
"linkfile": "file",
|
|
"danglink": "missing",
|
|
"looplink": "looplink",
|
|
"linkdir": linked,
|
|
} {
|
|
if err := os.Symlink(target, filepath.Join(root, link)); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
}
|
|
notBuckets := []string{"file", "linkfile", "danglink", "looplink"}
|
|
if !bucketlinks {
|
|
notBuckets = append(notBuckets, "linkdir")
|
|
}
|
|
|
|
storer, opts := mkMeta(t)
|
|
opts.BucketLinks = bucketlinks
|
|
opts.VersioningDir = t.TempDir()
|
|
p, err := New(root, storer, opts)
|
|
if err != nil {
|
|
t.Fatalf("new posix: %v", err)
|
|
}
|
|
defer p.Shutdown()
|
|
|
|
ctx := context.Background()
|
|
bucket, object := "bucket", "obj"
|
|
createTestBucket(t, p, bucket)
|
|
if _, err := testPut(p, bucket, object, []byte(fileData), nil, nil); err != nil {
|
|
t.Fatalf("put object: %v", err)
|
|
}
|
|
mp, err := p.CreateMultipartUpload(ctx, s3response.CreateMultipartUploadInput{Bucket: &bucket, Key: &object})
|
|
if err != nil {
|
|
t.Fatalf("create multipart upload: %v", err)
|
|
}
|
|
|
|
calls := bucketCalls(ctx, p, bucket, object, mp.UploadId)
|
|
for _, name := range notBuckets {
|
|
for _, call := range calls {
|
|
err := call.run(name)
|
|
if !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) {
|
|
t.Errorf("%v on %q: got %v, want NoSuchBucket", call.name, name, err)
|
|
}
|
|
}
|
|
|
|
err := p.CreateBucket(ctx, &s3.CreateBucketInput{
|
|
Bucket: &name,
|
|
CreateBucketConfiguration: &types.CreateBucketConfiguration{},
|
|
}, []byte{})
|
|
if !errors.Is(err, s3err.GetAPIError(s3err.ErrBucketAlreadyExists)) {
|
|
t.Errorf("CreateBucket on %q: got %v, want BucketAlreadyExists", name, err)
|
|
}
|
|
|
|
if _, err := os.Lstat(filepath.Join(root, name)); err != nil {
|
|
t.Errorf("root entry %q: %v", name, err)
|
|
}
|
|
attrs, err := p.meta.ListAttributes(name, "")
|
|
if err == nil && len(attrs) != 0 {
|
|
t.Errorf("attributes stored for %q: %v", name, attrs)
|
|
}
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(root, "file"))
|
|
if err != nil || string(data) != fileData {
|
|
t.Errorf("root file content = %q, %v; want %q", data, err, fileData)
|
|
}
|
|
if opts.SideCarDir != "" {
|
|
ents, err := os.ReadDir(opts.SideCarDir)
|
|
if err != nil {
|
|
t.Fatalf("read sidecar directory: %v", err)
|
|
}
|
|
for _, ent := range ents {
|
|
if ent.Name() != bucket {
|
|
t.Errorf("sidecar metadata stored for %q", ent.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
if !bucketlinks {
|
|
ents, err := os.ReadDir(linked)
|
|
if err != nil {
|
|
t.Fatalf("read linked directory: %v", err)
|
|
}
|
|
if len(ents) != 1 || ents[0].Name() != "obj" {
|
|
t.Errorf("linked directory entries = %v, want just %q", ents, "obj")
|
|
}
|
|
return
|
|
}
|
|
|
|
// With BucketLinks the symlinked directory is a bucket.
|
|
linkBucket := "linkdir"
|
|
if _, err := p.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &linkBucket}); err != nil {
|
|
t.Fatalf("head symlinked bucket: %v", err)
|
|
}
|
|
if _, err := testPut(p, linkBucket, "new", []byte(fileData), nil, nil); err != nil {
|
|
t.Fatalf("put object in symlinked bucket: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(linked, "new")); err != nil {
|
|
t.Fatalf("object not written to the linked directory: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
type bucketCall struct {
|
|
name string
|
|
run func(bucket string) error
|
|
}
|
|
|
|
// bucketCalls returns a call to every backend method that addresses a bucket,
|
|
// except CreateBucket. A call runs against the bucket it is passed; the copies
|
|
// also run with that bucket as the source, copying object into bucket or into
|
|
// the upload uploadID.
|
|
func bucketCalls(ctx context.Context, p *Posix, bucket, object, uploadID string) []bucketCall {
|
|
key := aws.String(object)
|
|
copySource := aws.String(bucket + "/" + object)
|
|
body := func() *strings.Reader { return strings.NewReader("data") }
|
|
|
|
return []bucketCall{
|
|
{"HeadBucket", func(b string) error {
|
|
_, err := p.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &b})
|
|
return err
|
|
}},
|
|
{"DeleteBucket", func(b string) error {
|
|
return p.DeleteBucket(ctx, b)
|
|
}},
|
|
{"GetBucketAcl", func(b string) error {
|
|
_, err := p.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &b})
|
|
return err
|
|
}},
|
|
{"PutBucketAcl", func(b string) error {
|
|
return p.PutBucketAcl(ctx, b, []byte("{}"))
|
|
}},
|
|
{"ChangeBucketOwner", func(b string) error {
|
|
return p.ChangeBucketOwner(ctx, b, "owner")
|
|
}},
|
|
{"PutBucketOwnershipControls", func(b string) error {
|
|
return p.PutBucketOwnershipControls(ctx, b, types.ObjectOwnershipBucketOwnerEnforced)
|
|
}},
|
|
{"GetBucketOwnershipControls", func(b string) error {
|
|
_, err := p.GetBucketOwnershipControls(ctx, b)
|
|
return err
|
|
}},
|
|
{"DeleteBucketOwnershipControls", func(b string) error {
|
|
return p.DeleteBucketOwnershipControls(ctx, b)
|
|
}},
|
|
{"PutBucketVersioning", func(b string) error {
|
|
return p.PutBucketVersioning(ctx, b, types.BucketVersioningStatusEnabled)
|
|
}},
|
|
{"GetBucketVersioning", func(b string) error {
|
|
_, err := p.GetBucketVersioning(ctx, b)
|
|
return err
|
|
}},
|
|
{"PutBucketTagging", func(b string) error {
|
|
return p.PutBucketTagging(ctx, b, map[string]string{"k": "v"})
|
|
}},
|
|
{"GetBucketTagging", func(b string) error {
|
|
_, err := p.GetBucketTagging(ctx, b)
|
|
return err
|
|
}},
|
|
{"DeleteBucketTagging", func(b string) error {
|
|
return p.DeleteBucketTagging(ctx, b)
|
|
}},
|
|
{"PutBucketPolicy", func(b string) error {
|
|
return p.PutBucketPolicy(ctx, b, []byte("{}"))
|
|
}},
|
|
{"GetBucketPolicy", func(b string) error {
|
|
_, err := p.GetBucketPolicy(ctx, b)
|
|
return err
|
|
}},
|
|
{"DeleteBucketPolicy", func(b string) error {
|
|
return p.DeleteBucketPolicy(ctx, b)
|
|
}},
|
|
{"PutBucketCors", func(b string) error {
|
|
return p.PutBucketCors(ctx, b, []byte("<CORSConfiguration/>"))
|
|
}},
|
|
{"GetBucketCors", func(b string) error {
|
|
_, err := p.GetBucketCors(ctx, b)
|
|
return err
|
|
}},
|
|
{"DeleteBucketCors", func(b string) error {
|
|
return p.DeleteBucketCors(ctx, b)
|
|
}},
|
|
{"PutBucketWebsite", func(b string) error {
|
|
return p.PutBucketWebsite(ctx, b, []byte("<WebsiteConfiguration/>"))
|
|
}},
|
|
{"GetBucketWebsite", func(b string) error {
|
|
_, err := p.GetBucketWebsite(ctx, b)
|
|
return err
|
|
}},
|
|
{"DeleteBucketWebsite", func(b string) error {
|
|
return p.DeleteBucketWebsite(ctx, b)
|
|
}},
|
|
{"PutObjectLockConfiguration", func(b string) error {
|
|
return p.PutObjectLockConfiguration(ctx, b, []byte(`{"Enabled":true}`))
|
|
}},
|
|
{"GetObjectLockConfiguration", func(b string) error {
|
|
_, err := p.GetObjectLockConfiguration(ctx, b)
|
|
return err
|
|
}},
|
|
{"ListObjects", func(b string) error {
|
|
_, err := p.ListObjects(ctx, &s3.ListObjectsInput{Bucket: &b, MaxKeys: aws.Int32(1000)})
|
|
return err
|
|
}},
|
|
{"ListObjectsV2", func(b string) error {
|
|
_, err := p.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &b, MaxKeys: aws.Int32(1000), StartAfter: aws.String("")})
|
|
return err
|
|
}},
|
|
{"ListObjectVersions", func(b string) error {
|
|
_, err := p.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{Bucket: &b, MaxKeys: aws.Int32(1000)})
|
|
return err
|
|
}},
|
|
{"ListMultipartUploads", func(b string) error {
|
|
_, err := p.ListMultipartUploads(ctx, &s3.ListMultipartUploadsInput{Bucket: &b, MaxUploads: aws.Int32(1000)})
|
|
return err
|
|
}},
|
|
{"PutObject", func(b string) error {
|
|
_, err := p.PutObject(ctx, s3response.PutObjectInput{Bucket: &b, Key: key, Body: body(), ContentLength: aws.Int64(4)})
|
|
return err
|
|
}},
|
|
{"PutObjectDirectory", func(b string) error {
|
|
_, err := p.PutObject(ctx, s3response.PutObjectInput{Bucket: &b, Key: aws.String("dir/"), Body: strings.NewReader(""), ContentLength: aws.Int64(0)})
|
|
return err
|
|
}},
|
|
{"GetObject", func(b string) error {
|
|
_, err := p.GetObject(ctx, &s3.GetObjectInput{Bucket: &b, Key: key})
|
|
return err
|
|
}},
|
|
{"HeadObject", func(b string) error {
|
|
_, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &b, Key: key})
|
|
return err
|
|
}},
|
|
{"GetObjectAttributes", func(b string) error {
|
|
_, err := p.GetObjectAttributes(ctx, &s3.GetObjectAttributesInput{Bucket: &b, Key: key})
|
|
return err
|
|
}},
|
|
{"DeleteObject", func(b string) error {
|
|
_, err := p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &b, Key: key})
|
|
return err
|
|
}},
|
|
{"DeleteObjects", func(b string) error {
|
|
_, err := p.DeleteObjects(ctx, &s3.DeleteObjectsInput{Bucket: &b, Delete: &types.Delete{Objects: []types.ObjectIdentifier{{Key: key}}}})
|
|
return err
|
|
}},
|
|
{"CopyObjectDestination", func(b string) error {
|
|
_, err := p.CopyObject(ctx, s3response.CopyObjectInput{Bucket: &b, Key: key, CopySource: copySource, ExpectedBucketOwner: aws.String("")})
|
|
return err
|
|
}},
|
|
{"CopyObjectSource", func(b string) error {
|
|
_, err := p.CopyObject(ctx, s3response.CopyObjectInput{Bucket: &bucket, Key: aws.String("copy"), CopySource: aws.String(b + "/" + object), ExpectedBucketOwner: aws.String("")})
|
|
return err
|
|
}},
|
|
{"PutObjectTagging", func(b string) error {
|
|
return p.PutObjectTagging(ctx, b, object, "", map[string]string{"k": "v"})
|
|
}},
|
|
{"GetObjectTagging", func(b string) error {
|
|
_, err := p.GetObjectTagging(ctx, b, object, "")
|
|
return err
|
|
}},
|
|
{"DeleteObjectTagging", func(b string) error {
|
|
return p.DeleteObjectTagging(ctx, b, object, "")
|
|
}},
|
|
{"PutObjectLegalHold", func(b string) error {
|
|
return p.PutObjectLegalHold(ctx, b, object, "", true)
|
|
}},
|
|
{"GetObjectLegalHold", func(b string) error {
|
|
_, err := p.GetObjectLegalHold(ctx, b, object, "")
|
|
return err
|
|
}},
|
|
{"PutObjectRetention", func(b string) error {
|
|
return p.PutObjectRetention(ctx, b, object, "", []byte(`{"Mode":"GOVERNANCE"}`))
|
|
}},
|
|
{"GetObjectRetention", func(b string) error {
|
|
_, err := p.GetObjectRetention(ctx, b, object, "")
|
|
return err
|
|
}},
|
|
{"CreateMultipartUpload", func(b string) error {
|
|
_, err := p.CreateMultipartUpload(ctx, s3response.CreateMultipartUploadInput{Bucket: &b, Key: key})
|
|
return err
|
|
}},
|
|
{"UploadPart", func(b string) error {
|
|
_, err := p.UploadPart(ctx, &s3.UploadPartInput{Bucket: &b, Key: key, UploadId: &uploadID, PartNumber: aws.Int32(1), Body: body(), ContentLength: aws.Int64(4)})
|
|
return err
|
|
}},
|
|
{"UploadPartCopyDestination", func(b string) error {
|
|
_, err := p.UploadPartCopy(ctx, &s3.UploadPartCopyInput{Bucket: &b, Key: key, UploadId: &uploadID, PartNumber: aws.Int32(1), CopySource: copySource, CopySourceRange: aws.String("")})
|
|
return err
|
|
}},
|
|
{"UploadPartCopySource", func(b string) error {
|
|
_, err := p.UploadPartCopy(ctx, &s3.UploadPartCopyInput{Bucket: &bucket, Key: key, UploadId: &uploadID, PartNumber: aws.Int32(1), CopySource: aws.String(b + "/" + object), CopySourceRange: aws.String("")})
|
|
return err
|
|
}},
|
|
{"ListParts", func(b string) error {
|
|
_, err := p.ListParts(ctx, &s3.ListPartsInput{Bucket: &b, Key: key, UploadId: &uploadID, MaxParts: aws.Int32(1000)})
|
|
return err
|
|
}},
|
|
{"CompleteMultipartUpload", func(b string) error {
|
|
_, _, err := p.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: &b, Key: key, UploadId: &uploadID, MultipartUpload: &types.CompletedMultipartUpload{}})
|
|
return err
|
|
}},
|
|
{"AbortMultipartUpload", func(b string) error {
|
|
return p.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: &b, Key: key, UploadId: &uploadID})
|
|
}},
|
|
}
|
|
}
|