Files
versitygw/backend/posix/rootdir_test.go
T
Radu Berinde 7ae119d654 posix: add AbsolutePaths option for embedding without chdir
Problem:
- `posix.New` calls `os.Chdir(rootdir)` and uses cwd-relative paths for
  every bucket and object. That is the cheapest way to address files, but
  the cwd is process-wide: embedding the gateway (`embedgw`) silently moves
  the host program's cwd. In particular, Go unit tests that embed the
  gateway can no longer read their test data files by relative path.

Change:
- New `PosixOpts.AbsolutePaths`. When set, `New` leaves the working
  directory alone and builds every path from the absolute root; a relative
  `VersioningDir`/`SideCarDir` is then resolved against the working
  directory rather than the root. The default is unchanged: chdir and
  relative paths.
- All bucket and object paths go through new `BucketPath`/`ObjectPath`,
  which return the name as-is by default and prefix the root with
  `AbsolutePaths`. An absolute "bucket" (the versioning directory
  substitution) is passed through unchanged.
- `tmpfile` records the bucket directory path so `link()` and its fallbacks
  use the same addressing; `ListBuckets` reads the root through the same
  helper.
- `meta.XattrMeta` needs the same root with `AbsolutePaths`. New
  `meta.RootDirSetter` interface; `posix.New` calls `WithRootDir` on
  storers that implement it in that mode. A zero `XattrMeta` keeps
  resolving against the cwd. `SideCar`/`NoMeta` unchanged. A type that
  embeds `XattrMeta` inherits a `WithRootDir` that returns a bare
  `XattrMeta`, so it needs its own (documented on `RootDirSetter`).
- `DeleteObject` (directory object), `ListParts`, and `UploadPartCopy`
  passed filesystem paths where the metadata API expects bucket/object
  names; they now pass names, so the sidecar layout is unchanged in both
  modes.
- Windows `handleParentDirError` walks up until `filepath.Dir` is a fixed
  point, which works for relative and absolute paths.
- scoutfs used cwd-relative bucket/object paths in `CreateBucket`,
  `GetObject`, `HeadObject`, `RestoreObject` and the glacier walk; they now
  go through `BucketPath`/`ObjectPath`. `scoutfs.New` resolves `rootdir`
  before `posix.New` so a relative root no longer reopens `rootdir/rootdir`
  after the chdir.
- `isBucketValid` unconditionally rejects names that do not denote a single
  entry under the root: `""`, `.`, `..`, names containing a path separator,
  and absolute paths. `XattrMeta` rejects `""`, `.` and `..` likewise.
  With relative paths `os.Stat("")` and `os.RemoveAll(".")` failed by
  accident; with absolute paths they would act on the root directory itself
  (reachable with strict bucket names disabled, or via the admin
  `change-bucket-owner` endpoint which does not validate `bucket`).
- scoutfs had its own `isBucketValid` whose `validateBucketName` flag was
  never set, so it accepted everything. It now delegates to the new exported
  `Posix.IsBucketValid`.
- `UploadPartCopy` did not validate the copy source's bucket name (unlike
  `CopyObject`); it does now.
- `New` opens the root after validating the versioning and sidecar
  directories, so those error paths no longer leak the root handle. The
  chdir still happens first, so a relative directory resolves against the
  root as before.

Tests:
- New `TestDefaultModeChangesWorkingDirectory` documents the default.
- New `TestRootDirIndependentOfWorkingDirectory`: `AbsolutePaths` with a
  relative root from an unrelated cwd, checks cwd is untouched and that
  put/get/list/delete, copy, multipart upload with checksums and part copy,
  directory-object delete, and invalid bucket names behave correctly under
  the root, for both metadata storers.
- New `TestVersioningDirIndependentOfWorkingDirectory`: same setup with a
  relative versioning directory; versions land there and not under the
  root or cwd.
- New `TestXattrMetaPath` covers cwd-relative and root resolution, absolute
  pass-through and the rejected names.
- New `BenchmarkPosix*` benchmarks (small-object head/get/put/list, both
  storers, both path modes). The default mode matches `main` within noise
  on both Linux and macOS. `AbsolutePaths` costs about 0.2µs (Linux) to
  0.4µs (macOS) per path lookup; on Linux (arm64 VM, overlayfs) that is
  +2-3% on PutObject and +10-27% on the metadata-heavy small-object
  HeadObject/GetObject/ListObjectsV2 with xattr metadata, which is why it
  is opt-in.
2026-09-10 13:28:01 -07:00

443 lines
15 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"
"encoding/base64"
"encoding/binary"
"errors"
"hash/crc32"
"io"
"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/backend/meta"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
// TestDefaultModeChangesWorkingDirectory documents the default behavior: New
// changes the process working directory to the root and addresses buckets by
// relative paths.
func TestDefaultModeChangesWorkingDirectory(t *testing.T) {
work := t.TempDir()
t.Chdir(work)
root := t.TempDir()
p, err := New(root, meta.XattrMeta{}, PosixOpts{})
if err != nil {
t.Fatalf("new posix: %v", err)
}
// Compare by identity: on macOS the temp dir is reached through a
// symlink, so Getwd returns a different spelling of the same directory.
wdInfo, err := os.Stat(".")
if err != nil {
t.Fatalf("stat working directory: %v", err)
}
rootInfo, err := os.Stat(root)
if err != nil {
t.Fatalf("stat root: %v", err)
}
if !os.SameFile(wdInfo, rootInfo) {
wd, _ := os.Getwd()
t.Fatalf("working directory = %q; want root %q", wd, root)
}
if got := p.BucketPath("b"); got != "b" {
t.Fatalf("BucketPath = %q, want %q", got, "b")
}
if got := p.ObjectPath("b", "d/o"); got != filepath.Join("b", "d", "o") {
t.Fatalf("ObjectPath = %q, want %q", got, filepath.Join("b", "d", "o"))
}
}
// TestRootDirIndependentOfWorkingDirectory checks that with AbsolutePaths the
// backend neither changes the process working directory nor depends on it:
// every operation resolves buckets and objects under the root directory it
// was given, so the gateway can be embedded in a process whose working
// directory is elsewhere.
func TestRootDirIndependentOfWorkingDirectory(t *testing.T) {
for name, mkMeta := range metaModes(t) {
t.Run(name, func(t *testing.T) {
// Work from a directory that is neither the gateway root nor
// anything under it, and hand New a relative path to the root so
// that its resolution against the working directory is exercised.
work := t.TempDir()
t.Chdir(work)
root := t.TempDir()
relRoot, err := filepath.Rel(work, root)
if err != nil {
t.Fatalf("relative root: %v", err)
}
storer, opts := mkMeta(t)
opts.AbsolutePaths = true
opts.CopyObjectThreshold = 1 << 20
p, err := New(relRoot, storer, opts)
if err != nil {
t.Fatalf("new posix: %v", err)
}
if wd, err := os.Getwd(); err != nil || wd != work {
t.Fatalf("New changed the working directory to %q (want %q, err %v)", wd, work, err)
}
if got := p.BucketPath("b"); got != filepath.Join(root, "b") {
t.Fatalf("BucketPath = %q, want %q", got, filepath.Join(root, "b"))
}
ctx := context.Background()
bucket, object, body := "bucket", "dir/object", "hello"
createTestBucket(t, p, bucket)
_, err = p.PutObject(ctx, s3response.PutObjectInput{
Bucket: &bucket,
Key: &object,
Body: strings.NewReader(body),
ContentLength: aws.Int64(int64(len(body))),
})
if err != nil {
t.Fatalf("put object: %v", err)
}
// Everything landed under the root, nothing under the working
// directory.
if _, err := os.Stat(filepath.Join(root, bucket, object)); err != nil {
t.Fatalf("object not under root: %v", err)
}
if _, err := os.Stat(filepath.Join(work, bucket)); err == nil {
t.Fatalf("bucket directory created under the working directory")
}
out, err := p.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &object})
if err != nil {
t.Fatalf("get object: %v", err)
}
got, err := io.ReadAll(out.Body)
out.Body.Close()
if err != nil || string(got) != body {
t.Fatalf("get object body = %q, %v; want %q", got, err, body)
}
list, err := p.ListObjects(ctx, &s3.ListObjectsInput{Bucket: &bucket, MaxKeys: aws.Int32(1000)})
if err != nil {
t.Fatalf("list objects: %v", err)
}
if len(list.Contents) != 1 || *list.Contents[0].Key != object {
t.Fatalf("list objects = %+v, want just %q", list.Contents, object)
}
buckets, err := p.ListBuckets(ctx, s3response.ListBucketsInput{IsAdmin: true, MaxBuckets: 1000})
if err != nil {
t.Fatalf("list buckets: %v", err)
}
if len(buckets.Buckets.Bucket) != 1 || buckets.Buckets.Bucket[0].Name != bucket {
t.Fatalf("list buckets = %+v, want just %q", buckets.Buckets.Bucket, bucket)
}
// A multipart upload stores its checksum algorithm in metadata
// keyed by the upload's temporary directory; ListParts must find
// it under the root, and the parts must land there too.
mpKey := "mp/object"
mp, err := p.CreateMultipartUpload(ctx, s3response.CreateMultipartUploadInput{
Bucket: &bucket,
Key: &mpKey,
ChecksumAlgorithm: types.ChecksumAlgorithmCrc32,
ChecksumType: types.ChecksumTypeComposite,
})
if err != nil {
t.Fatalf("create multipart upload: %v", err)
}
uploadID := mp.UploadId
crc := make([]byte, 4)
binary.BigEndian.PutUint32(crc, crc32.ChecksumIEEE([]byte(body)))
partCRC := base64.StdEncoding.EncodeToString(crc)
part, err := p.UploadPart(ctx, &s3.UploadPartInput{
Bucket: &bucket,
Key: &mpKey,
UploadId: &uploadID,
PartNumber: aws.Int32(1),
ContentLength: aws.Int64(int64(len(body))),
Body: strings.NewReader(body),
ChecksumCRC32: &partCRC,
})
if err != nil {
t.Fatalf("upload part: %v", err)
}
// The copy source's bucket goes through the same validation as
// every other bucket name.
for _, srcBucket := range []string{"..", "."} {
_, err := p.UploadPartCopy(ctx, &s3.UploadPartCopyInput{
Bucket: &bucket,
Key: &mpKey,
UploadId: &uploadID,
PartNumber: aws.Int32(2),
CopySource: aws.String(srcBucket + "/" + object),
})
if !errors.Is(err, s3err.GetBucketErr(s3err.ErrInvalidBucketName, srcBucket)) {
t.Fatalf("upload part copy from bucket %q: got %v, want InvalidBucketName", srcBucket, err)
}
}
// A valid copy source resolves under the root like any object.
_, err = p.UploadPartCopy(ctx, &s3.UploadPartCopyInput{
Bucket: &bucket,
Key: &mpKey,
UploadId: &uploadID,
PartNumber: aws.Int32(2),
CopySource: aws.String(bucket + "/" + object),
CopySourceRange: aws.String(""),
})
if err != nil {
t.Fatalf("upload part copy: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket, MetaTmpMultipartDir)); err != nil {
t.Fatalf("multipart directory not under root: %v", err)
}
lp, err := p.ListParts(ctx, &s3.ListPartsInput{Bucket: &bucket, Key: &mpKey, UploadId: &uploadID, MaxParts: aws.Int32(1000)})
if err != nil {
t.Fatalf("list parts: %v", err)
}
if lp.ChecksumAlgorithm != types.ChecksumAlgorithmCrc32 || len(lp.Parts) != 2 {
t.Fatalf("list parts = algorithm %q, %d parts; want CRC32, 2 parts", lp.ChecksumAlgorithm, len(lp.Parts))
}
_, _, err = p.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &bucket,
Key: &mpKey,
UploadId: &uploadID,
MultipartUpload: &types.CompletedMultipartUpload{
Parts: []types.CompletedPart{{ETag: part.ETag, PartNumber: aws.Int32(1), ChecksumCRC32: &partCRC}},
},
})
if err != nil {
t.Fatalf("complete multipart upload: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket, mpKey)); err != nil {
t.Fatalf("multipart object not under root: %v", err)
}
// CopyObject resolves both source and destination under the root.
copyKey := "copy/object"
_, err = p.CopyObject(ctx, s3response.CopyObjectInput{
Bucket: &bucket,
Key: &copyKey,
CopySource: aws.String(bucket + "/" + object),
ExpectedBucketOwner: aws.String(""),
})
if err != nil {
t.Fatalf("copy object: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket, copyKey)); err != nil {
t.Fatalf("copied object not under root: %v", err)
}
if _, err := os.Stat(filepath.Join(work, bucket)); err == nil {
t.Fatalf("bucket directory created under the working directory")
}
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &copyKey})
if err != nil {
t.Fatalf("delete copied object: %v", err)
}
// Deleting an explicitly created directory object that still has
// children removes only its metadata; the lookup is by bucket and
// object name.
dirKey := "dir/"
_, err = p.PutObject(ctx, s3response.PutObjectInput{
Bucket: &bucket,
Key: &dirKey,
Body: strings.NewReader(""),
ContentLength: aws.Int64(0),
})
if err != nil {
t.Fatalf("put directory object: %v", err)
}
if _, err := p.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &dirKey}); err != nil {
t.Fatalf("head directory object: %v", err)
}
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &dirKey})
if err != nil {
t.Fatalf("delete non-empty directory object: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket, object)); err != nil {
t.Fatalf("child object removed with directory object: %v", err)
}
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &dirKey})
if !errors.Is(err, s3err.GetAPIError(s3err.ErrDirectoryNotEmpty)) {
t.Fatalf("second delete of directory object: got %v, want DirectoryNotEmpty", err)
}
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &object})
if err != nil {
t.Fatalf("delete object: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket, "dir")); !os.IsNotExist(err) {
t.Fatalf("empty parent directory not removed: %v", err)
}
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &mpKey})
if err != nil {
t.Fatalf("delete multipart object: %v", err)
}
// Bucket names that would resolve to the root directory, its
// parent, or outside the root are rejected in every validation
// mode (the test backend has posix-level validation off).
for _, name := range []string{"", ".", "..", "a/b", root} {
want := s3err.GetBucketErr(s3err.ErrInvalidBucketName, name)
_, err = p.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &name})
if !errors.Is(err, want) {
t.Fatalf("head bucket %q: got %v, want InvalidBucketName", name, err)
}
if err := p.DeleteBucket(ctx, name); !errors.Is(err, want) {
t.Fatalf("delete bucket %q: got %v, want InvalidBucketName", name, err)
}
}
if _, err := os.Stat(root); err != nil {
t.Fatalf("root directory gone: %v", err)
}
err = p.DeleteBucket(ctx, bucket)
if err != nil {
t.Fatalf("delete bucket: %v", err)
}
if _, err := os.Stat(filepath.Join(root, bucket)); !os.IsNotExist(err) {
t.Fatalf("bucket directory not removed: %v", err)
}
if wd, err := os.Getwd(); err != nil || wd != work {
t.Fatalf("working directory changed to %q (want %q, err %v)", wd, work, err)
}
})
}
}
// TestVersioningDirIndependentOfWorkingDirectory checks the versioning
// directory substitution with AbsolutePaths: the versioning code passes the
// absolute path of a bucket's versioning directory where a bucket name is
// expected, and both the backend and the metadata storer must use it as
// given rather than resolving it under the root or the working directory.
func TestVersioningDirIndependentOfWorkingDirectory(t *testing.T) {
for name, mkMeta := range metaModes(t) {
t.Run(name, func(t *testing.T) {
work := t.TempDir()
t.Chdir(work)
root := t.TempDir()
vdir := t.TempDir()
relRoot, err := filepath.Rel(work, root)
if err != nil {
t.Fatalf("relative root: %v", err)
}
relVdir, err := filepath.Rel(work, vdir)
if err != nil {
t.Fatalf("relative versioning dir: %v", err)
}
storer, opts := mkMeta(t)
opts.VersioningDir = relVdir
opts.AbsolutePaths = true
p, err := New(relRoot, storer, opts)
if err != nil {
t.Fatalf("new posix: %v", err)
}
ctx := context.Background()
bucket, object := "bucket", "object"
createTestBucket(t, p, bucket)
if err := p.PutBucketVersioning(ctx, bucket, types.BucketVersioningStatusEnabled); err != nil {
t.Fatalf("put bucket versioning: %v", err)
}
put := func(body string) string {
t.Helper()
out, err := p.PutObject(ctx, s3response.PutObjectInput{
Bucket: &bucket,
Key: &object,
Body: strings.NewReader(body),
ContentLength: aws.Int64(int64(len(body))),
})
if err != nil {
t.Fatalf("put object %q: %v", body, err)
}
return out.VersionID
}
v1 := put("one")
v2 := put("two")
if v1 == "" || v2 == "" || v1 == v2 {
t.Fatalf("version ids = %q, %q; want two distinct non-empty ids", v1, v2)
}
// The first version was copied into the versioning directory;
// the root holds only the bucket (and the object lock directory)
// and the working directory stays empty.
if _, err := os.Stat(filepath.Join(vdir, bucket)); err != nil {
t.Fatalf("bucket versioning directory missing: %v", err)
}
ents, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read root directory: %v", err)
}
for _, e := range ents {
if e.Name() != bucket && e.Name() != objLockDir {
t.Fatalf("unexpected entry %q in root directory", e.Name())
}
}
if ents, err := os.ReadDir(work); err != nil || len(ents) != 0 {
t.Fatalf("working directory entries = %v, %v; want none", ents, err)
}
get := func(versionID string) string {
t.Helper()
in := &s3.GetObjectInput{Bucket: &bucket, Key: &object}
if versionID != "" {
in.VersionId = &versionID
}
out, err := p.GetObject(ctx, in)
if err != nil {
t.Fatalf("get object version %q: %v", versionID, err)
}
defer out.Body.Close()
got, err := io.ReadAll(out.Body)
if err != nil {
t.Fatalf("read object version %q: %v", versionID, err)
}
return string(got)
}
if got := get(""); got != "two" {
t.Fatalf("current version = %q, want %q", got, "two")
}
if got := get(v1); got != "one" {
t.Fatalf("version %q = %q, want %q", v1, got, "one")
}
// Deleting a specific version removes it from the versioning
// directory.
_, err = p.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: &object, VersionId: &v1})
if err != nil {
t.Fatalf("delete version: %v", err)
}
_, err = p.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: &object, VersionId: &v1})
if !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchVersion)) {
t.Fatalf("get deleted version: got %v, want NoSuchVersion", err)
}
if wd, err := os.Getwd(); err != nil || wd != work {
t.Fatalf("working directory changed to %q (want %q, err %v)", wd, work, err)
}
})
}
}