From f51367853d5e8fc6c00e77644a5b09faa6e9987b Mon Sep 17 00:00:00 2001 From: Ben McClelland Date: Mon, 19 Oct 2026 20:26:16 -0700 Subject: [PATCH] fix: skip excluded directories and reserve their keys Follow-up to #2514, which added PosixOpts.ExcludeDirs (--exclude-dirs, VGW_EXCLUDE_DIRS) so the listings can skip filesystem control directories such as ZFS's .zfs. The walks matched skipped names against every directory entry, files included. In WalkVersions, returning fs.SkipDir for a file skips the rest of its parent directory, so a regular file named like an excluded directory (for example an object "d/.zfs") made ListObjectVersions drop every sibling that sorted after it. Walk hid the file itself. Previously only .sgwtmp was skipped, which clients can't create, but excluded names are chosen by the operator and any client can PUT such a key. Walk and WalkVersions now skip only directories, so a file with an excluded name is listed like any other object. Excluded names were also not reserved: requests could still read and write objects under an excluded directory that the listings never show. On ZFS, .zfs is reachable by name even with snapdir=hidden, so GetObject could read snapshot contents. That bypasses bucket policies scoped to a key prefix and exposes data that was deleted or overwritten since the snapshot was taken. On other filesystems, objects written there were orphans that no listing reports. validateObjectName now rejects, with ReservedObjectKey, any key that has an excluded name as a directory at any depth, ignoring case and after resolving "..", "." and repeated slashes. Every object operation already calls it. A directory-object key (trailing slash) counts its last element as a directory; a plain key with an excluded last element is still a valid file. The new ErrExcludedObjectKey keeps the ReservedObjectKey code with a description that fits excluded directories, leaving the .sgwtmp error unchanged. Nothing changes when ExcludeDirs is empty. posix.New now also rejects "." and ".." as excluded names, since they can never match a directory entry and would silently do nothing. --- backend/posix/list_objects_snapdir_test.go | 380 +++++++++++++++++++-- backend/posix/posix.go | 98 ++++-- backend/walk.go | 57 +++- backend/walk_bench_test.go | 6 +- backend/walk_test.go | 188 +++++++++- cmd/internal/gwcli/posix.go | 2 +- extra/example.conf | 18 + s3err/s3err.go | 6 + 8 files changed, 670 insertions(+), 85 deletions(-) diff --git a/backend/posix/list_objects_snapdir_test.go b/backend/posix/list_objects_snapdir_test.go index 36b0db84..f1a0c6e4 100644 --- a/backend/posix/list_objects_snapdir_test.go +++ b/backend/posix/list_objects_snapdir_test.go @@ -16,6 +16,7 @@ package posix import ( "context" + "errors" "os" "path/filepath" "sort" @@ -25,7 +26,10 @@ import ( "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/backend/meta" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" ) // zfsSnapDir is the control directory a ZFS dataset exposes when it is mounted @@ -73,12 +77,21 @@ func newSnapdirBackend(t *testing.T, storer meta.MetadataStorer, excludeDirs []s createTestBucket(t, p, snapBucket) - bdir := p.BucketPath(snapBucket) - for name, data := range map[string]string{ + writeBucketFiles(t, p, map[string]string{ "top.txt": "top", "child/a.txt": "a", "child/.zfs/snapshot/one/snap.txt": "snap", - } { + }) + return p +} + +// writeBucketFiles writes files, keyed by their slash-separated path in the +// bucket, straight to the bucket directory. +func writeBucketFiles(t *testing.T, p *Posix, files map[string]string) { + t.Helper() + + bdir := p.BucketPath(snapBucket) + for name, data := range files { full := filepath.Join(bdir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatalf("mkdir %q: %v", name, err) @@ -87,7 +100,6 @@ func newSnapdirBackend(t *testing.T, storer meta.MetadataStorer, excludeDirs []s t.Fatalf("write %q: %v", name, err) } } - return p } func listObjectsKeys(p *Posix) ([]string, error) { @@ -132,6 +144,28 @@ func listObjectVersionsKeys(p *Posix) ([]string, error) { return keys, nil } +type listOp struct { + name string + list func(*Posix) ([]string, error) +} + +// listOps are the three listing operations that share the skip list. +var listOps = []listOp{ + {"ListObjects", listObjectsKeys}, + {"ListObjectsV2", listObjectsV2Keys}, + {"ListObjectVersions", listObjectVersionsKeys}, +} + +type metaMode struct { + name string + storer func() meta.MetadataStorer +} + +var snapdirMetaModes = []metaMode{ + {"xattr", func() meta.MetadataStorer { return meta.XattrMeta{} }}, + {"unsupported .zfs xattr", func() meta.MetadataStorer { return zfsSnapdirMeta{} }}, +} + // TestListObjectsSkipsZFSSnapDir checks that a control directory named in // PosixOpts.ExcludeDirs is not listed as bucket content and is not walked. // The directory shows up in readdir and its metadata reads fail, which used to @@ -139,25 +173,18 @@ func listObjectVersionsKeys(p *Posix) ([]string, error) { // not be walked either, or the listing would report the snapshot contents as // objects. All three listing operations share the same list. func TestListObjectsSkipsZFSSnapDir(t *testing.T) { - for mode, mkMeta := range map[string]func() meta.MetadataStorer{ - "xattr": func() meta.MetadataStorer { return meta.XattrMeta{} }, - "unsupported .zfs xattr": func() meta.MetadataStorer { return zfsSnapdirMeta{} }, - } { - t.Run(mode, func(t *testing.T) { - p := newSnapdirBackend(t, mkMeta(), []string{zfsSnapDir}) + for _, mode := range snapdirMetaModes { + t.Run(mode.name, func(t *testing.T) { + p := newSnapdirBackend(t, mode.storer(), []string{zfsSnapDir}) want := []string{"child/a.txt", "top.txt"} - for name, list := range map[string]func() ([]string, error){ - "ListObjects": func() ([]string, error) { return listObjectsKeys(p) }, - "ListObjectsV2": func() ([]string, error) { return listObjectsV2Keys(p) }, - "ListObjectVersions": func() ([]string, error) { return listObjectVersionsKeys(p) }, - } { - keys, err := list() + for _, op := range listOps { + keys, err := op.list(p) if err != nil { - t.Fatalf("%s: %v", name, err) + t.Fatalf("%s: %v", op.name, err) } if !equalKeys(keys, want) { - t.Fatalf("%s: keys = %v, want %v", name, keys, want) + t.Fatalf("%s: keys = %v, want %v", op.name, keys, want) } } }) @@ -172,43 +199,239 @@ func TestListObjectsExcludeDirsEmpty(t *testing.T) { t.Run("xattr", func(t *testing.T) { p := newSnapdirBackend(t, meta.XattrMeta{}, nil) - keys, err := listObjectsKeys(p) - if err != nil { - t.Fatalf("list objects: %v", err) - } - if !containsKey(keys, "child/.zfs/snapshot/one/snap.txt") { - t.Fatalf("keys = %v, want the .zfs snapshot object to be listed", keys) + for _, op := range listOps { + keys, err := op.list(p) + if err != nil { + t.Fatalf("%s: %v", op.name, err) + } + if !containsKey(keys, "child/.zfs/snapshot/one/snap.txt") { + t.Fatalf("%s: keys = %v, want the .zfs snapshot object to be listed", op.name, keys) + } } }) t.Run("unsupported .zfs xattr", func(t *testing.T) { p := newSnapdirBackend(t, zfsSnapdirMeta{}, nil) - if _, err := listObjectsKeys(p); err == nil { - t.Fatal("list objects succeeded, want the .zfs metadata error") + for _, op := range listOps { + if _, err := op.list(p); err == nil { + t.Fatalf("%s succeeded, want the .zfs metadata error", op.name) + } } }) } -// TestListingSkipDirs checks the list the walks receive: the metadata -// directory is always skipped, and an excluded directory that could never -// match a directory entry name is rejected instead of silently doing nothing. +// TestListObjectsExcludeDirsFileNotSkipped checks that only directories are +// excluded. A regular file with an excluded name is an ordinary object: it is +// listed, and it does not hide the objects that sort after it in the same +// directory, which ListObjectVersions used to drop. +func TestListObjectsExcludeDirsFileNotSkipped(t *testing.T) { + p := newSnapdirBackend(t, meta.XattrMeta{}, []string{zfsSnapDir}) + writeBucketFiles(t, p, map[string]string{ + "d/" + zfsSnapDir: "file", + "d/b.txt": "b", + "d/c.txt": "c", + }) + + want := []string{"child/a.txt", "d/" + zfsSnapDir, "d/b.txt", "d/c.txt", "top.txt"} + for _, op := range listOps { + keys, err := op.list(p) + if err != nil { + t.Fatalf("%s: %v", op.name, err) + } + if !equalKeys(keys, want) { + t.Errorf("%s: keys = %v, want %v", op.name, keys, want) + } + } +} + +// TestListingSkipDirs checks the directories the walks receive: the metadata +// directory is skipped at the bucket root only, the excluded directories at +// any depth, and an excluded directory that could never match a directory +// entry name is rejected instead of silently doing nothing. func TestListingSkipDirs(t *testing.T) { got, err := listingSkipDirs([]string{zfsSnapDir}) if err != nil { t.Fatalf("listing skip dirs: %v", err) } - if want := []string{MetaTmpDir, zfsSnapDir}; !equalKeys(got, want) { - t.Fatalf("skip dirs = %v, want %v", got, want) + if want := []string{MetaTmpDir}; !equalKeys(got.Root, want) { + t.Errorf("root skip dirs = %v, want %v", got.Root, want) + } + if want := []string{zfsSnapDir}; !equalKeys(got.Any, want) { + t.Errorf("any-depth skip dirs = %v, want %v", got.Any, want) } - for _, dir := range []string{"", "child/.zfs"} { + for _, dir := range []string{"", ".", "..", "child/.zfs"} { if _, err := listingSkipDirs([]string{dir}); err == nil { t.Errorf("listingSkipDirs(%q) succeeded, want an error", dir) } } } +// TestListObjectsNestedMetaTmpDir checks that only the metadata directory at +// the bucket root is hidden. The gateway never creates one further down, so a +// nested .sgwtmp directory holds objects a client stored there, which object +// requests accept and so the listings must report. +func TestListObjectsNestedMetaTmpDir(t *testing.T) { + ctx := context.Background() + bucket := snapBucket + key := "d/" + MetaTmpDir + "/x" + + p := newSnapdirBackend(t, meta.XattrMeta{}, []string{zfsSnapDir}) + if err := putTestObject(t, p, bucket, key, "data"); err != nil { + t.Fatalf("PutObject %q: %v", key, err) + } + if _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &key}); err != nil { + t.Fatalf("HeadObject %q: %v", key, err) + } + writeBucketFiles(t, p, map[string]string{MetaTmpDir + "/stray": "tmp"}) + + want := []string{"child/a.txt", key, "top.txt"} + for _, op := range listOps { + keys, err := op.list(p) + if err != nil { + t.Fatalf("%s: %v", op.name, err) + } + if !equalKeys(keys, want) { + t.Errorf("%s: keys = %v, want %v", op.name, keys, want) + } + } +} + +// TestExcludeDirsCaseAgrees checks that the listings and object requests +// match an excluded name the same way, ignoring case: a directory spelled in +// another case is neither listed nor reachable, rather than listed with +// objects that can't be read. +func TestExcludeDirsCaseAgrees(t *testing.T) { + ctx := context.Background() + bucket := snapBucket + key := "e/.ZFS/x" + + p := newSnapdirBackend(t, meta.XattrMeta{}, []string{zfsSnapDir}) + writeBucketFiles(t, p, map[string]string{key: "x"}) + + want := []string{"child/a.txt", "top.txt"} + for _, op := range listOps { + keys, err := op.list(p) + if err != nil { + t.Fatalf("%s: %v", op.name, err) + } + if !equalKeys(keys, want) { + t.Errorf("%s: keys = %v, want %v", op.name, keys, want) + } + } + + _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &key}) + if !errors.Is(err, s3err.GetAPIError(s3err.ErrExcludedObjectKey)) { + t.Errorf("HeadObject %q: got %v, want ReservedObjectKey", key, err) + } +} + +// TestExcludedDirKeysReserved checks that object requests can't reach a path +// under an excluded directory. The listings never show objects there, and on +// ZFS the .zfs directory, reachable by name even with snapdir=hidden, would +// serve snapshot contents under keys that policies on the live keys don't +// cover. +func TestExcludedDirKeysReserved(t *testing.T) { + ctx := context.Background() + reserved := s3err.GetAPIError(s3err.ErrExcludedObjectKey) + bucket := snapBucket + snapKey := "child/.zfs/snapshot/one/snap.txt" + + p := newSnapdirBackend(t, meta.XattrMeta{}, []string{zfsSnapDir}) + snapPath := filepath.Join(p.BucketPath(bucket), filepath.FromSlash(snapKey)) + + for _, key := range []string{ + snapKey, + "child/.zfs/", + "child/.ZFS/snapshot/one/snap.txt", + "child/x/../.zfs/snapshot/one/snap.txt", + "child//.zfs/snapshot/one/snap.txt", + ".zfs/a", + "child/a.txt/../.zfs/snapshot/one/snap.txt", + } { + _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: aws.String(key)}) + if !errors.Is(err, reserved) { + t.Errorf("HeadObject %q: got %v, want ReservedObjectKey", key, err) + } + } + + if _, err := p.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &snapKey}); !errors.Is(err, reserved) { + t.Errorf("GetObject: got %v, want ReservedObjectKey", err) + } + + for _, key := range []string{"child/.zfs/new.txt", "child/.zfs/snapshot/two/", "child/.zfs/"} { + if err := putTestObject(t, p, bucket, key, ""); !errors.Is(err, reserved) { + t.Errorf("PutObject %q: got %v, want ReservedObjectKey", key, err) + } + } + if _, err := os.Stat(filepath.Join(p.BucketPath(bucket), "child", zfsSnapDir, "new.txt")); !os.IsNotExist(err) { + t.Errorf("PutObject created an object under the excluded directory: %v", err) + } + if _, err := os.Stat(filepath.Join(p.BucketPath(bucket), "child", zfsSnapDir, "snapshot", "two")); !os.IsNotExist(err) { + t.Errorf("PutObject created a directory under the excluded directory: %v", err) + } + + if _, err := p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &snapKey}); !errors.Is(err, reserved) { + t.Errorf("DeleteObject: got %v, want ReservedObjectKey", err) + } + if _, err := os.Stat(snapPath); err != nil { + t.Errorf("DeleteObject removed the object under the excluded directory: %v", err) + } + + _, err := p.CopyObject(ctx, s3response.CopyObjectInput{ + Bucket: &bucket, + Key: aws.String("copied.txt"), + CopySource: aws.String(bucket + "/" + snapKey), + ExpectedBucketOwner: aws.String(""), + }) + if !errors.Is(err, reserved) { + t.Errorf("CopyObject from excluded directory: got %v, want ReservedObjectKey", err) + } + _, err = p.CopyObject(ctx, s3response.CopyObjectInput{ + Bucket: &bucket, + Key: aws.String("child/.zfs/copied.txt"), + CopySource: aws.String(bucket + "/top.txt"), + ExpectedBucketOwner: aws.String(""), + }) + if !errors.Is(err, reserved) { + t.Errorf("CopyObject into excluded directory: got %v, want ReservedObjectKey", err) + } + + _, err = p.CreateMultipartUpload(ctx, s3response.CreateMultipartUploadInput{ + Bucket: &bucket, Key: aws.String("child/.zfs/mp.txt"), + }) + if !errors.Is(err, reserved) { + t.Errorf("CreateMultipartUpload: got %v, want ReservedObjectKey", err) + } + + // A file with an excluded name is an ordinary object, as the listings + // report it, and so are keys that only resemble the excluded name. + for _, key := range []string{"d/" + zfsSnapDir, "child/.zfsx/a", "child/x.zfs/a"} { + if err := putTestObject(t, p, bucket, key, "data"); err != nil { + t.Errorf("PutObject %q: %v", key, err) + continue + } + if _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: aws.String(key)}); err != nil { + t.Errorf("HeadObject %q: %v", key, err) + } + } +} + +// TestExcludeDirsEmptyKeysAllowed checks the default: without ExcludeDirs, +// .zfs is an ordinary name and its objects can be read. +func TestExcludeDirsEmptyKeysAllowed(t *testing.T) { + bucket := snapBucket + p := newSnapdirBackend(t, meta.XattrMeta{}, nil) + + _, err := p.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: &bucket, Key: aws.String("child/.zfs/snapshot/one/snap.txt"), + }) + if err != nil { + t.Fatalf("HeadObject: %v", err) + } +} + func containsKey(keys []string, key string) bool { for _, k := range keys { if k == key { @@ -233,3 +456,96 @@ func equalKeys(got, want []string) bool { } return true } + +// TestExcludedDirBucketRejected checks that an excluded name is not a bucket, +// even with relaxed bucket names. On ZFS a bucket named .zfs at a dataset root +// would otherwise serve snapshot contents through keys with no excluded +// element of their own. +func TestExcludedDirBucketRejected(t *testing.T) { + ctx := context.Background() + invalid := s3err.GetAPIError(s3err.ErrInvalidBucketName) + + sidecar := t.TempDir() + sc, err := meta.NewSideCar(sidecar) + if err != nil { + t.Fatalf("new sidecar: %v", err) + } + root := t.TempDir() + p, err := New(root, sc, PosixOpts{ + NewDirPerm: 0755, + AbsolutePaths: true, + SideCarDir: sidecar, + ExcludeDirs: []string{zfsSnapDir}, + }) + if err != nil { + t.Fatalf("new posix: %v", err) + } + t.Cleanup(p.Shutdown) + + createTestBucket(t, p, "bkt") + snapKey := "snapshot/s1/bkt/private/secret.txt" + snapFile := filepath.Join(root, zfsSnapDir, filepath.FromSlash(snapKey)) + if err := os.MkdirAll(filepath.Dir(snapFile), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(snapFile, []byte("secret"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + for _, bucket := range []string{zfsSnapDir, strings.ToUpper(zfsSnapDir)} { + if p.IsBucketValid(bucket) { + t.Errorf("IsBucketValid(%q) = true, want false", bucket) + } + if _, err := p.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &bucket}); !errors.Is(err, invalid) { + t.Errorf("HeadBucket %q: got %v, want InvalidBucketName", bucket, err) + } + // Rejected before the ACL is read, which fails on a ZFS control + // directory with xattr metadata. + if _, err := p.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &bucket}); !errors.Is(err, invalid) { + t.Errorf("GetBucketAcl %q: got %v, want InvalidBucketName", bucket, err) + } + if _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &snapKey}); !errors.Is(err, invalid) { + t.Errorf("HeadObject %q: got %v, want InvalidBucketName", bucket, err) + } + if _, err := p.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &snapKey}); !errors.Is(err, invalid) { + t.Errorf("GetObject %q: got %v, want InvalidBucketName", bucket, err) + } + if err := putTestObject(t, p, bucket, "new.txt", "x"); !errors.Is(err, invalid) { + t.Errorf("PutObject %q: got %v, want InvalidBucketName", bucket, err) + } + err := p.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: &bucket, + CreateBucketConfiguration: &types.CreateBucketConfiguration{}, + }, []byte{}) + if !errors.Is(err, invalid) { + t.Errorf("CreateBucket %q: got %v, want InvalidBucketName", bucket, err) + } + } + if _, err := os.Stat(filepath.Join(root, zfsSnapDir, "new.txt")); !os.IsNotExist(err) { + t.Errorf("PutObject created an object in the excluded directory: %v", err) + } + + out, err := p.ListBuckets(ctx, s3response.ListBucketsInput{IsAdmin: true, MaxBuckets: 1000}) + if err != nil { + t.Fatalf("ListBuckets: %v", err) + } + var names []string + for _, b := range out.Buckets.Bucket { + names = append(names, b.Name) + } + if !equalKeys(names, []string{"bkt"}) { + t.Errorf("ListBuckets: got %v, want [bkt]", names) + } + + owners, err := p.ListBucketsAndOwners(ctx) + if err != nil { + t.Fatalf("ListBucketsAndOwners: %v", err) + } + names = nil + for _, b := range owners { + names = append(names, b.Name) + } + if !equalKeys(names, []string{"bkt"}) { + t.Errorf("ListBucketsAndOwners: got %v, want [bkt]", names) + } +} diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 3b1feb84..6e5b73c4 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -150,10 +150,13 @@ type Posix struct { // multipart composite: "ALGO--" dataIntegrityEtag bool - // skipDirs are the directory names the listing walks never descend - // into: the gateway's metadata directory plus PosixOpts.ExcludeDirs. - // It is built once in New so all three list operations share it. - skipDirs []string + // skipDirs are the directories the listing walks never descend into: + // the gateway's metadata directory at the bucket root, and the + // PosixOpts.ExcludeDirs entries at any depth. It is built once in New + // so all three list operations share it, and validateObjectName rejects + // keys through the excluded entries so objects can't be read or written + // where the listings never show them. + skipDirs backend.SkipDirs // objLockSlots are process-local striped slots taken alongside the shared // per-bucket lock files by lockObjectPublish. Channels make local lock @@ -317,26 +320,38 @@ type PosixOpts struct { // filesystem control directories that hold something other than object // data, such as ".zfs" on a ZFS dataset mounted with snapdir=visible, // where the metadata xattrs are unsupported and reading them would fail - // the whole listing. The gateway's own metadata directory is always - // skipped and does not need to be listed here. + // the whole listing. The gateway's own metadata directory at the bucket + // root is always skipped and does not need to be listed here. + // + // Only directories are excluded; a regular file with an excluded name + // is still listed. Names are matched ignoring case. Object keys with an + // excluded name as one of their directories, at any depth, are rejected + // with ReservedObjectKey, so objects there can't be read or written + // either. On ZFS that also keeps snapshot contents under .zfs from being + // read through the gateway. An excluded name is not a valid bucket name + // either, even with relaxed bucket name validation, and a directory with + // that name at the gateway root is not listed as a bucket. ExcludeDirs []string } -// listingSkipDirs returns the directory names the listing walks skip: the -// gateway's own metadata directory, which is never bucket content, followed -// by the configured filesystem-specific control directories. Each configured -// entry has to be a single, non-empty path segment; anything else could never +// listingSkipDirs returns the directories the listing walks skip: the +// gateway's own metadata directory, which only exists at the bucket root and +// is never bucket content, and the configured filesystem-specific control +// directories, which are skipped at any depth. Each configured +// entry has to be a single path segment naming a directory entry, so it can't +// be empty, contain a separator, or be "." or ".."; anything else could never // match a directory entry name and would silently do nothing. -func listingSkipDirs(excludeDirs []string) ([]string, error) { - skipDirs := make([]string, 0, len(excludeDirs)+1) - skipDirs = append(skipDirs, MetaTmpDir) +func listingSkipDirs(excludeDirs []string) (backend.SkipDirs, error) { for _, dir := range excludeDirs { - if dir == "" || strings.ContainsRune(dir, '/') || strings.ContainsRune(dir, filepath.Separator) { - return nil, fmt.Errorf("invalid excluded directory %q: must be a single, non-empty path segment", dir) + if dir == "" || dir == "." || dir == ".." || + strings.ContainsRune(dir, '/') || strings.ContainsRune(dir, filepath.Separator) { + return backend.SkipDirs{}, fmt.Errorf("invalid excluded directory %q: must be a single path segment naming a directory entry", dir) } - skipDirs = append(skipDirs, dir) } - return skipDirs, nil + return backend.SkipDirs{ + Root: []string{MetaTmpDir}, + Any: slices.Clone(excludeDirs), + }, nil } // New returns a Posix backend serving buckets from the directories under rootdir. @@ -666,18 +681,51 @@ func (p *Posix) ValidateObjectKey(bucket, key string) error { // The path is resolved like ObjectPath resolves it, so that "./", "x/../" and // repeated slashes don't hide the directory, and its name is matched ignoring // case, like case-insensitive file systems match it. +// +// It also returns ReservedObjectKey if a directory in the path, at any depth, +// is named in PosixOpts.ExcludeDirs. The listings never show objects there, +// and on ZFS the .zfs directory would otherwise expose snapshot contents +// under keys that bucket policies on the live keys don't cover. A file with +// an excluded name is still a valid object, as the listings report it. func (p *Posix) validateObjectName(bucket, object string) error { rel, err := filepath.Rel(p.BucketPath(bucket), p.ObjectPath(bucket, object)) if err != nil { return fmt.Errorf("resolve object path: %w", err) } - dir, _, _ := strings.Cut(filepath.ToSlash(rel), "/") + rel = filepath.ToSlash(rel) + dir, _, _ := strings.Cut(rel, "/") if strings.EqualFold(dir, MetaTmpDir) { return s3err.GetAPIError(s3err.ErrReservedObjectKey) } + if p.inExcludedDir(rel, strings.HasSuffix(object, "/")) { + return s3err.GetAPIError(s3err.ErrExcludedObjectKey) + } return nil } +// inExcludedDir reports whether a directory in rel, a slash-separated path +// relative to the bucket directory, is named in PosixOpts.ExcludeDirs. The +// last element of rel is a directory only when isDir is set, as it is for +// the key of a directory object. +func (p *Posix) inExcludedDir(rel string, isDir bool) bool { + if len(p.skipDirs.Any) == 0 { + return false + } + dirs := strings.Split(rel, "/") + if !isDir { + dirs = dirs[:len(dirs)-1] + } + return slices.ContainsFunc(dirs, p.isExcludedName) +} + +// isExcludedName reports whether name is named in PosixOpts.ExcludeDirs, +// ignoring case. +func (p *Posix) isExcludedName(name string) bool { + return slices.ContainsFunc(p.skipDirs.Any, func(excluded string) bool { + return strings.EqualFold(name, excluded) + }) +} + // DoesBucketExist returns NoSuchBucket unless bucket names a bucket. Backends // built on Posix that add their own entry points must check buckets with it // rather than stat the bucket path themselves. @@ -751,7 +799,7 @@ func (p *Posix) ListBuckets(ctx context.Context, input s3response.ListBucketsInp continue } - if !utils.IsValidBucketName(fi.Name()) { + if !utils.IsValidBucketName(fi.Name()) || p.isExcludedName(fi.Name()) { continue } @@ -826,6 +874,14 @@ func (p *Posix) isBucketValid(bucket string) bool { return false } + // An excluded directory at the root is no bucket either, even with + // relaxed bucket names: on ZFS a bucket named .zfs at a dataset root + // would serve snapshot contents through keys that have no excluded + // element of their own. + if p.isExcludedName(bucket) { + return false + } + if !p.validateBucketName { return true } @@ -8288,6 +8344,10 @@ func (p *Posix) ListBucketsAndOwners(ctx context.Context) (buckets []s3response. } for _, fi := range fis { + if p.isExcludedName(fi.Name()) { + continue + } + aclJSON, err := p.meta.RetrieveAttribute(nil, fi.Name(), "", aclkey) if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { return buckets, fmt.Errorf("get acl tag: %w", err) diff --git a/backend/walk.go b/backend/walk.go index 22f487d8..f45b183d 100644 --- a/backend/walk.go +++ b/backend/walk.go @@ -48,7 +48,7 @@ type walkState struct { marker string max int32 getObj GetObjFunc - skipdirs []string + skipDirs SkipDirs // Mutable state cpmap cpMap @@ -61,6 +61,27 @@ type walkState struct { type GetObjFunc func(path string, d fs.DirEntry) (s3response.Object, error) +// SkipDirs names the directories a walk never descends into or reports as +// objects. Regular files with these names are ordinary objects. Names are +// matched ignoring case: a case-insensitive file system resolves a name in +// any case to the same directory, and matching the same way on every file +// system keeps the listings in agreement with backends that reject object +// keys through these directories ignoring case. +type SkipDirs struct { + // Root names directories skipped only at the top of the walked file + // system, such as the gateway's own metadata directory. + Root []string + // Any names directories skipped at every depth. + Any []string +} + +// skips reports whether a directory named name is skipped. atRoot reports +// whether the directory is at the top of the walked file system. +func (s SkipDirs) skips(name string, atRoot bool) bool { + match := func(skip string) bool { return strings.EqualFold(name, skip) } + return slices.ContainsFunc(s.Any, match) || (atRoot && slices.ContainsFunc(s.Root, match)) +} + var ErrSkipObj = errors.New("skip this object") // map to store object common prefixes @@ -93,7 +114,7 @@ func (c cpMap) CpArray() []types.CommonPrefix { // Walk walks the supplied fs.FS and returns results compatible with list // objects responses -func Walk(ctx context.Context, fileSystem fs.FS, prefix, delimiter, marker string, max int32, getObj GetObjFunc, skipdirs []string) (WalkResults, error) { +func Walk(ctx context.Context, fileSystem fs.FS, prefix, delimiter, marker string, max int32, getObj GetObjFunc, skipDirs SkipDirs) (WalkResults, error) { // if max is 0, it should return empty non-truncated result if max == 0 { return WalkResults{ @@ -109,7 +130,7 @@ func Walk(ctx context.Context, fileSystem fs.FS, prefix, delimiter, marker strin marker: marker, max: max, getObj: getObj, - skipdirs: skipdirs, + skipDirs: skipDirs, cpmap: cpMap{}, } @@ -146,7 +167,7 @@ type GetVersionsFunc func(path, versionIdMarker string, pastVersionIdMarker *boo // WalkVersions walks the supplied fs.FS and returns results compatible with // ListObjectVersions action response -func WalkVersions(ctx context.Context, fileSystem fs.FS, prefix, delimiter, keyMarker, versionIdMarker string, max int, getObj GetVersionsFunc, skipdirs []string) (WalkVersioningResults, error) { +func WalkVersions(ctx context.Context, fileSystem fs.FS, prefix, delimiter, keyMarker, versionIdMarker string, max int, getObj GetVersionsFunc, skipDirs SkipDirs) (WalkVersioningResults, error) { cpmap := cpMap{} var objects []s3response.ObjectVersion var delMarkers []s3response.DeleteMarkerEntry @@ -172,7 +193,10 @@ func WalkVersions(ctx context.Context, fileSystem fs.FS, prefix, delimiter, keyM if path == "." { return nil } - if slices.Contains(skipdirs, d.Name()) { + // Only directories are skipped by name. A file with a skipped + // name is an ordinary object, and returning SkipDir for a file + // would skip the rest of its directory. + if d.IsDir() && skipDirs.skips(d.Name(), !strings.Contains(path, pathSeparator)) { return fs.SkipDir } @@ -381,7 +405,7 @@ func readDirEntries(path string, entries []fs.DirEntry, walkstate *walkState) { return } - if shouldSkip(entries[entriesIndex].Name(), walkstate) { + if shouldSkip(path, entries[entriesIndex], walkstate) { entriesIndex++ continue } @@ -444,7 +468,7 @@ func processDir( // if it is a directory, we // need to call recursively to // check for further preceding entries - if shouldSkip(entries[entriesIndex].Name(), walkstate) { + if shouldSkip(path, entries[entriesIndex], walkstate) { entriesIndex++ continue } @@ -558,21 +582,22 @@ func checkAndMaybeAddCommonPrefix(objectName string, walkstate *walkState, return false } -func shouldSkip(name string, walkstate *walkState) bool { - return slices.Contains(walkstate.skipdirs, name) +// shouldSkip reports whether entry, read from the directory path, is a +// skipped directory. A file with a skipped name is an ordinary object and is +// not skipped. +func shouldSkip(path string, entry fs.DirEntry, walkstate *walkState) bool { + return entry.IsDir() && walkstate.skipDirs.skips(entry.Name(), path == pathDot) } // inSkipDir reports whether dir, a directory named by the listing prefix, is -// a skipped directory or inside one. The prefix spells the directory names -// as the client sent them, which a case-insensitive file system resolves to -// a skipped directory in any case, so they are matched ignoring case. +// a skipped directory or inside one. func inSkipDir(dir string, walkstate *walkState) bool { + i := 0 for name := range strings.SplitSeq(dir, pathSeparator) { - if slices.ContainsFunc(walkstate.skipdirs, func(skip string) bool { - return strings.EqualFold(name, skip) - }) { + if walkstate.skipDirs.skips(name, i == 0) { return true } + i++ } return false } @@ -636,7 +661,7 @@ func quickWalk(walkstate *walkState) error { } if useRootEntries { - // keep fast-path semantics but still honor skipdirs for the prefixed + // keep fast-path semantics but still honor skipDirs for the prefixed // root and every directory above it if !inSkipDir(rootDir, walkstate) { // Mirror processDir: offer the root directory itself to getObj whenever diff --git a/backend/walk_bench_test.go b/backend/walk_bench_test.go index 809b7634..5280fd23 100644 --- a/backend/walk_bench_test.go +++ b/backend/walk_bench_test.go @@ -284,7 +284,7 @@ func benchGetObj(path string, d fs.DirEntry) (s3response.Object, error) { // runWalk is a convenience wrapper so each benchmark loop is one line. func runWalk(b *testing.B, fsys fs.FS, prefix, delim, marker string, max int32) { b.Helper() - _, err := backend.Walk(context.Background(), fsys, prefix, delim, marker, max, benchGetObj, nil) + _, err := backend.Walk(context.Background(), fsys, prefix, delim, marker, max, benchGetObj, backend.SkipDirs{}) if err != nil { b.Fatalf("Walk: %v", err) } @@ -320,7 +320,7 @@ func BenchmarkWalkFlatPaged(b *testing.B) { marker := "" for { res, err := backend.Walk(context.Background(), - fsys, "", "", marker, 1000, benchGetObj, nil) + fsys, "", "", marker, 1000, benchGetObj, backend.SkipDirs{}) if err != nil { b.Fatalf("Walk: %v", err) } @@ -427,7 +427,7 @@ func BenchmarkWalkLargeFlatPaged(b *testing.B) { marker := "" for { res, err := backend.Walk(context.Background(), - fsys, "", "", marker, 1000, benchGetObj, nil) + fsys, "", "", marker, 1000, benchGetObj, backend.SkipDirs{}) if err != nil { b.Fatalf("Walk: %v", err) } diff --git a/backend/walk_test.go b/backend/walk_test.go index 27cb394f..7c798c61 100644 --- a/backend/walk_test.go +++ b/backend/walk_test.go @@ -432,7 +432,7 @@ func TestWalk(t *testing.T) { for _, tc := range tt.cases { res, err := backend.Walk(context.Background(), tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs, - tt.getobj, []string{}) + tt.getobj, backend.SkipDirs{}) if err != nil { t.Errorf("%v: walk: %v", tc.name, err) } @@ -575,7 +575,7 @@ func TestWalkStop(t *testing.T) { _, err = backend.Walk(ctx, s, "", "", "", 1000, func(path string, d fs.DirEntry) (s3response.Object, error) { return s3response.Object{}, nil - }, []string{}) + }, backend.SkipDirs{}) }) select { @@ -609,7 +609,7 @@ func TestWalkPrefixInSkipDir(t *testing.T) { } { for _, delimiter := range []string{"", "/"} { res, err := backend.Walk(context.Background(), fsys, prefix, delimiter, "", 1000, - getObj, []string{".sgwtmp"}) + getObj, backend.SkipDirs{Root: []string{".sgwtmp"}}) if err != nil { t.Errorf("prefix %q delimiter %q: walk: %v", prefix, delimiter, err) continue @@ -622,6 +622,166 @@ func TestWalkPrefixInSkipDir(t *testing.T) { } } +// skipNameFS has skipped directories, which are never walked, and regular +// files with skipped names, which are ordinary objects. ".zfs" sorts before +// the siblings of d/.zfs, so skipping that file the way a directory is skipped +// would drop the siblings. "a.b" and "c.d" sort after "a" and "c" in readdir +// order but before "a/" and "c/" as keys, so processDir reaches them through +// its lookahead: the file a.b is listed and the directory c.d is skipped. +var skipNameFS = fstest.MapFS{ + ".zfs/snapshot/one/snap.txt": {}, + "a.b": {}, + "a/x": {}, + "c.d/y": {}, + "c/x": {}, + "d/.zfs": {}, + "d/b.txt": {}, + "d/c.txt": {}, + "top.txt": {}, +} + +var skipNameDirs = backend.SkipDirs{Any: []string{".zfs", "a.b", "c.d"}} + +var skipNameKeys = []string{"a.b", "a/x", "c/x", "d/.zfs", "d/b.txt", "d/c.txt", "top.txt"} + +// TestWalkSkipDirsFileNotSkipped checks that Walk skips only directories +// named in skipdirs, not regular files with the same name. +func TestWalkSkipDirsFileNotSkipped(t *testing.T) { + res, err := backend.Walk(context.Background(), skipNameFS, "", "", "", 1000, + getObjSkipDirs, skipNameDirs) + if err != nil { + t.Fatalf("walk: %v", err) + } + var want []s3response.Object + for _, key := range skipNameKeys { + want = append(want, s3response.Object{Key: backend.GetPtrFromString(key)}) + } + compareResultsOrdered("files named like skipped dirs", res, backend.WalkResults{Objects: want}, t) + + // A prefix inside the directory starts the walk there, and the file + // with the skipped name is still listed. + res, err = backend.Walk(context.Background(), skipNameFS, "d/", "", "", 1000, + getObjSkipDirs, skipNameDirs) + if err != nil { + t.Fatalf("walk prefix d/: %v", err) + } + compareResultsOrdered("file named like skipped dir under prefix", res, backend.WalkResults{ + Objects: []s3response.Object{ + {Key: backend.GetPtrFromString("d/.zfs")}, + {Key: backend.GetPtrFromString("d/b.txt")}, + {Key: backend.GetPtrFromString("d/c.txt")}, + }, + }, t) +} + +// TestWalkVersionsSkipDirsFileNotSkipped checks that WalkVersions skips only +// directories named in skipdirs. It used to return fs.SkipDir for a regular +// file with a skipped name too, which skips the rest of the file's directory. +func TestWalkVersionsSkipDirsFileNotSkipped(t *testing.T) { + getFileVersions := func(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) { + if d.IsDir() { + return nil, backend.ErrSkipObj + } + return getVersionsTestFunc(path, versionIdMarker, pastVersionIdMarker, availableObjCount, d) + } + + res, err := backend.WalkVersions(context.Background(), skipNameFS, "", "", "", "", 1000, + getFileVersions, skipNameDirs) + if err != nil { + t.Fatalf("WalkVersions: %v", err) + } + if !compareObjectVersionsOrdered(res.ObjectVersions, makeObjectVersions(skipNameKeys)) { + t.Errorf("unexpected ObjectVersions: got %v, want %v", + printVersionObjects(res.ObjectVersions), skipNameKeys) + } +} + +// rootSkipFS has the gateway's metadata directory at the root, where it is +// skipped, and directories of the same name further down, where they are +// ordinary bucket content. It also has a skipped name spelled in another +// case, which is skipped too, like object requests reject keys through it. +var rootSkipFS = fstest.MapFS{ + ".sgwtmp/multipart/x": {}, + ".SGWTMP/y": {}, + "d/.sgwtmp/x": {}, + "e/.SGWTMP/z": {}, + "e/.ZFS/snap": {}, + "top.txt": {}, +} + +var rootSkipDirs = backend.SkipDirs{Root: []string{".sgwtmp"}, Any: []string{".zfs"}} + +var rootSkipKeys = []string{"d/.sgwtmp/x", "e/.SGWTMP/z", "top.txt"} + +// TestWalkSkipDirsRootAndCase checks that Walk skips SkipDirs.Root names only +// at the root, SkipDirs.Any names at any depth, and both ignoring case. +func TestWalkSkipDirsRootAndCase(t *testing.T) { + ctx := context.Background() + + res, err := backend.Walk(ctx, rootSkipFS, "", "", "", 1000, getObjSkipDirs, rootSkipDirs) + if err != nil { + t.Fatalf("walk: %v", err) + } + var want []s3response.Object + for _, key := range rootSkipKeys { + want = append(want, s3response.Object{Key: backend.GetPtrFromString(key)}) + } + compareResultsOrdered("root and any-depth skip dirs", res, backend.WalkResults{Objects: want}, t) + + res, err = backend.Walk(ctx, rootSkipFS, "", "/", "", 1000, getObjSkipDirs, rootSkipDirs) + if err != nil { + t.Fatalf("walk with delimiter: %v", err) + } + compareResultsOrdered("root and any-depth skip dirs with delimiter", res, backend.WalkResults{ + CommonPrefixes: []types.CommonPrefix{ + {Prefix: backend.GetPtrFromString("d/")}, + {Prefix: backend.GetPtrFromString("e/")}, + }, + Objects: []s3response.Object{{Key: backend.GetPtrFromString("top.txt")}}, + }, t) + + for _, tc := range []struct { + prefix string + keys []string + }{ + {"d/.sgwtmp/", []string{"d/.sgwtmp/x"}}, + {"e/.SGWTMP/", []string{"e/.SGWTMP/z"}}, + {"e/.ZFS/", nil}, + {".SGWTMP/", nil}, + } { + res, err := backend.Walk(ctx, rootSkipFS, tc.prefix, "", "", 1000, getObjSkipDirs, rootSkipDirs) + if err != nil { + t.Fatalf("walk prefix %q: %v", tc.prefix, err) + } + var want []s3response.Object + for _, key := range tc.keys { + want = append(want, s3response.Object{Key: backend.GetPtrFromString(key)}) + } + compareResultsOrdered("prefix "+tc.prefix, res, backend.WalkResults{Objects: want}, t) + } +} + +// TestWalkVersionsSkipDirsRootAndCase checks that WalkVersions applies the +// same root-only and case-insensitive rules as Walk. +func TestWalkVersionsSkipDirsRootAndCase(t *testing.T) { + getFileVersions := func(path, versionIdMarker string, pastVersionIdMarker *bool, availableObjCount int, d fs.DirEntry) (*backend.ObjVersionFuncResult, error) { + if d.IsDir() { + return nil, backend.ErrSkipObj + } + return getVersionsTestFunc(path, versionIdMarker, pastVersionIdMarker, availableObjCount, d) + } + + res, err := backend.WalkVersions(context.Background(), rootSkipFS, "", "", "", "", 1000, + getFileVersions, rootSkipDirs) + if err != nil { + t.Fatalf("WalkVersions: %v", err) + } + if !compareObjectVersionsOrdered(res.ObjectVersions, makeObjectVersions(rootSkipKeys)) { + t.Errorf("unexpected ObjectVersions: got %v, want %v", + printVersionObjects(res.ObjectVersions), rootSkipKeys) + } +} + // caseFoldFS opens the files of a MapFS with lowercase names by names in any // case, like a case-insensitive file system. type caseFoldFS struct { @@ -651,7 +811,7 @@ func TestWalkDirectoryErrorPropagates(t *testing.T) { return s3response.Object{}, fmt.Errorf("boom") } return getObj(path, d) - }, []string{}) + }, backend.SkipDirs{}) if err == nil { t.Fatal("expected error, got nil") } @@ -672,7 +832,7 @@ func TestWalkDirectoryErrSkipObjContinues(t *testing.T) { return s3response.Object{}, backend.ErrSkipObj } return getObj(path, d) - }, []string{}) + }, backend.SkipDirs{}) if err != nil { t.Fatalf("walk: %v", err) } @@ -854,7 +1014,7 @@ func TestOrderWalk(t *testing.T) { for _, tc := range tt.cases { res, err := backend.Walk(context.Background(), tt.fsys, tc.prefix, tc.delimiter, tc.marker, tc.maxObjs, - tt.getobj, []string{}) + tt.getobj, backend.SkipDirs{}) if err != nil { t.Errorf("%v: walk: %v", tc.name, err) } @@ -1071,7 +1231,7 @@ func TestMarker(t *testing.T) { for i, page := range tc.expected { res, err := backend.Walk(context.Background(), tt.fsys, tc.prefix, tc.delimiter, marker, tc.maxObjs, - tt.getobj, []string{}) + tt.getobj, backend.SkipDirs{}) if err != nil { t.Errorf("%v: walk: %v", tc.name, err) } @@ -1181,7 +1341,7 @@ func TestWalkVersions(t *testing.T) { }, } - res, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", 1000, getVersionsTestFunc, []string{}) + res, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", 1000, getVersionsTestFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("walk versions: %v", err) } @@ -1213,7 +1373,7 @@ func TestOrderWalkVersions(t *testing.T) { }, } - res, err := backend.WalkVersions(context.Background(), fsys, "dir1/", "", "", "", 1000, getVersionsTestFunc, []string{}) + res, err := backend.WalkVersions(context.Background(), fsys, "dir1/", "", "", "", 1000, getVersionsTestFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("order walk versions: %v", err) } @@ -1367,7 +1527,7 @@ func TestWalkVersionsTruncated(t *testing.T) { // Test first page with limit of 5 (should be truncated) maxKeys := 5 - res1, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", maxKeys, getVersionsFunc, []string{}) + res1, err := backend.WalkVersions(context.Background(), fsys, "", "", "", "", maxKeys, getVersionsFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("walk versions first page: %v", err) } @@ -1402,7 +1562,7 @@ func TestWalkVersionsTruncated(t *testing.T) { } // Test second page using markers - res2, err := backend.WalkVersions(context.Background(), fsys, "", "", res1.NextMarker, res1.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{}) + res2, err := backend.WalkVersions(context.Background(), fsys, "", "", res1.NextMarker, res1.NextVersionIdMarker, maxKeys, getVersionsFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("walk versions second page: %v", err) } @@ -1420,7 +1580,7 @@ func TestWalkVersionsTruncated(t *testing.T) { // Test third page if needed var res3 backend.WalkVersioningResults if res2.Truncated { - res3, err = backend.WalkVersions(context.Background(), fsys, "", "", res2.NextMarker, res2.NextVersionIdMarker, maxKeys, getVersionsFunc, []string{}) + res3, err = backend.WalkVersions(context.Background(), fsys, "", "", res2.NextMarker, res2.NextVersionIdMarker, maxKeys, getVersionsFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("walk versions third page: %v", err) } @@ -1460,7 +1620,7 @@ func TestWalkVersionsPrefixSkipsParents(t *testing.T) { prefix := "vendor/Backup/vendor/Clients/" delimiter := "/" - res, err := backend.WalkVersions(context.Background(), fsys, prefix, delimiter, "", "", 1000, getVersionsTestFunc, []string{}) + res, err := backend.WalkVersions(context.Background(), fsys, prefix, delimiter, "", "", 1000, getVersionsTestFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("WalkVersions: %v", err) } @@ -1493,7 +1653,7 @@ func TestWalkVersionsPrefixNoDelimiterSkipsParents(t *testing.T) { prefix := "vendor/Backup/vendor/Clients/" - res, err := backend.WalkVersions(context.Background(), fsys, prefix, "", "", "", 1000, getVersionsTestFunc, []string{}) + res, err := backend.WalkVersions(context.Background(), fsys, prefix, "", "", "", 1000, getVersionsTestFunc, backend.SkipDirs{}) if err != nil { t.Fatalf("WalkVersions: %v", err) } diff --git a/cmd/internal/gwcli/posix.go b/cmd/internal/gwcli/posix.go index 8c77c65c..01376773 100644 --- a/cmd/internal/gwcli/posix.go +++ b/cmd/internal/gwcli/posix.go @@ -184,7 +184,7 @@ will be translated into the file /mnt/fs/gwroot/mybucket/a/b/c/myobject`, }, &cli.StringSliceFlag{ Name: "exclude-dirs", - Usage: "directory names the object listings do not descend into or report as objects (can be specified multiple times); use it for a filesystem control directory that holds something other than object data, such as .zfs on a ZFS dataset mounted with snapdir=visible", + Usage: "directory name, not a path, to hide from object listings and reserve at any depth in a bucket; object keys through it are rejected, and it can't be used as a bucket name (can be specified multiple times)", EnvVars: []string{"VGW_EXCLUDE_DIRS"}, }, }, diff --git a/extra/example.conf b/extra/example.conf index 463a5ea9..2cb701ef 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -781,6 +781,24 @@ ROOT_SECRET_ACCESS_KEY= # as an opaque identifier instead of a content-MD5 signal. #VGW_DATA_INTEGRITY_ETAG=false +# The VGW_EXCLUDE_DIRS option lists directory names, separated by commas, that +# ListObjects, ListObjectsV2 and ListObjectVersions do not descend into or +# report as objects. Use it for filesystem control directories that hold +# something other than object data, such as .zfs on a ZFS dataset mounted with +# snapdir=visible, where reading object metadata from the control directory +# fails the whole listing. Each entry is a single directory name, not a path, +# and matches a directory with that name, ignoring case, at any depth in a +# bucket. Regular files with an excluded name are still listed. Object keys +# with an excluded directory in their path are reserved: requests for them fail +# with ReservedObjectKey, so objects already stored under an excluded directory +# become unreachable through the gateway. On ZFS this also keeps snapshot +# contents under .zfs, which is reachable by name even with snapdir=hidden, +# from being read through the gateway. An excluded name is also rejected as a +# bucket name, even with relaxed bucket name validation, and a directory with +# that name at the gateway root is not listed as a bucket. The gateway's own +# .sgwtmp directory at the bucket root is always excluded. +#VGW_EXCLUDE_DIRS= + ########### # scoutfs # ########### diff --git a/s3err/s3err.go b/s3err/s3err.go index bc5fd1f1..1705d660 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -212,6 +212,7 @@ const ( ErrACLsDisabled ErrNoSpaceLeftOnDevice ErrReservedObjectKey + ErrExcludedObjectKey // Admin api errors ErrAdminAccessDenied @@ -816,6 +817,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "Object keys in the .sgwtmp directory are reserved for the gateway's internal use.", HTTPStatusCode: http.StatusBadRequest, }, + ErrExcludedObjectKey: { + Code: "ReservedObjectKey", + Description: "Object keys in a directory excluded from object listings are reserved.", + HTTPStatusCode: http.StatusBadRequest, + }, // Admin api errors ErrAdminAccessDenied: {