diff --git a/pkg/fs/fs.go b/pkg/fs/fs.go index a386a64b693d..efdffb517e02 100644 --- a/pkg/fs/fs.go +++ b/pkg/fs/fs.go @@ -648,6 +648,17 @@ func trimDotsForRename(paths []string) (res []string) { } func (fs *FileSystem) Rename(ctx meta.Context, oldpath string, newpath string, flags uint32) (err syscall.Errno) { + return fs.rename(ctx, oldpath, newpath, flags, 0, false) +} + +// RenameWithInheritedMetadata commits a staged regular file using the +// destination directory's GID and default ACL inheritance rules. The metadata +// service performs the inheritance and rename in one backend transaction. +func (fs *FileSystem) RenameWithInheritedMetadata(ctx meta.Context, oldpath string, newpath string, flags uint32, mode uint16) (err syscall.Errno) { + return fs.rename(ctx, oldpath, newpath, flags, mode, true) +} + +func (fs *FileSystem) rename(ctx meta.Context, oldpath string, newpath string, flags uint32, mode uint16, inheritMetadata bool) (err syscall.Errno) { oss := trimDotsForRename(strings.Split(oldpath, "/")) nss := trimDotsForRename(strings.Split(newpath, "/")) var err0 syscall.Errno @@ -681,7 +692,11 @@ func (fs *FileSystem) Rename(ctx meta.Context, oldpath string, newpath string, f if err0 != 0 { return err0 } - err = fs.m.Rename(ctx, oldfi.inode, path.Base(oldpath), newfi.inode, path.Base(newpath), flags, nil, nil) + if inheritMetadata { + err = fs.m.RenameWithInheritedMetadata(ctx, oldfi.inode, path.Base(oldpath), newfi.inode, path.Base(newpath), flags, mode, nil, nil) + } else { + err = fs.m.Rename(ctx, oldfi.inode, path.Base(oldpath), newfi.inode, path.Base(newpath), flags, nil, nil) + } fs.InvalidateEntry(oldfi.inode, path.Base(oldpath)) fs.InvalidateEntry(newfi.inode, path.Base(newpath)) return diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index bf44d667b0b7..c409f1b82c26 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -740,14 +740,14 @@ func (n *jfsObjects) CopyObject(ctx context.Context, srcBucket, srcObject, dstBu return } - eno = n.fs.Rename(mctx, tmp, dst, 0) + eno = n.fs.RenameWithInheritedMetadata(mctx, tmp, dst, 0, 0666) if eno == syscall.ENOENT { if err = n.mkdirAllInBucket(ctx, dstBucket, path.Dir(dst)); err != nil { logger.Errorf("mkdirAll %s: %s", path.Dir(dst), err) err = n.objectCommitErr(ctx, err, dstBucket, dstObject) return } - eno = n.fs.Rename(mctx, tmp, dst, 0) + eno = n.fs.RenameWithInheritedMetadata(mctx, tmp, dst, 0, 0666) } if eno != 0 { err = n.objectCommitErr(ctx, eno, dstBucket, dstObject) @@ -880,7 +880,7 @@ func (n *jfsObjects) mkdirAllUntil(ctx context.Context, p, root string) error { return eno } -func (n *jfsObjects) putObject(ctx context.Context, bucket, object string, r *minio.PutObjReader, opts minio.ObjectOptions, applyObjTaggingFunc func(tmpName string)) (fi os.FileInfo, err error) { +func (n *jfsObjects) putObject(ctx context.Context, bucket, object string, r *minio.PutObjReader, opts minio.ObjectOptions, applyObjTaggingFunc func(tmpName string), inheritMetadata bool) (fi os.FileInfo, err error) { uuid := minio.MustGetUUID() tmpname := n.tpath(bucket, "tmp", uuid[:subDirPrefix], uuid) f, eno := n.fs.Create(mctx, tmpname, 0666, n.gConf.Umask) @@ -937,7 +937,13 @@ func (n *jfsObjects) putObject(ctx context.Context, bucket, object string, r *mi return } - eno = n.fs.Rename(mctx, tmpname, object, 0) + rename := n.fs.Rename + if inheritMetadata { + rename = func(ctx meta.Context, oldpath, newpath string, flags uint32) syscall.Errno { + return n.fs.RenameWithInheritedMetadata(ctx, oldpath, newpath, flags, 0666) + } + } + eno = rename(mctx, tmpname, object, 0) if eno == syscall.ENOENT { if strings.HasPrefix(object, sep+metaBucket+sep) { err = n.objectCommitErr(ctx, eno, bucket, object, path.Base(path.Dir(object))) @@ -948,7 +954,7 @@ func (n *jfsObjects) putObject(ctx context.Context, bucket, object string, r *mi err = n.objectCommitErr(ctx, err, bucket, object) return } - eno = n.fs.Rename(mctx, tmpname, object, 0) + eno = rename(mctx, tmpname, object, 0) } if eno != 0 { err = n.objectCommitErr(ctx, eno, bucket, object) @@ -1004,7 +1010,7 @@ func (n *jfsObjects) PutObject(ctx context.Context, bucket string, object string if err != nil { logger.Errorf("set object metadata error, path: %s error %s", p, err) } - }); err != nil { + }, true); err != nil { return } } @@ -1294,7 +1300,7 @@ func (n *jfsObjects) PutObjectPart(ctx context.Context, bucket, object, uploadID if n.fs.SetXattr(mctx, tmpName, s3Etag, []byte(etag), 0) != 0 { logger.Warnf("set xattr error, path: %s,xattr: %s,value: %s,flags: %d", tmpName, s3Etag, etag, 0) } - }); err != nil { + }, false); err != nil { err = jfsToObjectErr(ctx, err, bucket, object) return } @@ -1406,7 +1412,7 @@ func (n *jfsObjects) CompleteMultipartUpload(ctx context.Context, bucket, object } name := n.path(bucket, object) - eno = n.fs.Rename(mctx, tmp, name, 0) + eno = n.fs.RenameWithInheritedMetadata(mctx, tmp, name, 0, 0666) if eno == syscall.ENOENT { if err = n.mkdirAllInBucket(ctx, bucket, path.Dir(name)); err != nil { logger.Errorf("mkdirAll %s: %s", path.Dir(name), err) @@ -1414,7 +1420,7 @@ func (n *jfsObjects) CompleteMultipartUpload(ctx context.Context, bucket, object err = n.objectCommitErr(ctx, err, bucket, object, uploadID) return } - eno = n.fs.Rename(mctx, tmp, name, 0) + eno = n.fs.RenameWithInheritedMetadata(mctx, tmp, name, 0, 0666) } if eno != 0 { _ = n.fs.Delete(mctx, tmp) diff --git a/pkg/gateway/gateway_test.go b/pkg/gateway/gateway_test.go index e157a745f2a8..211b004e3403 100644 --- a/pkg/gateway/gateway_test.go +++ b/pkg/gateway/gateway_test.go @@ -31,6 +31,7 @@ import ( "testing" "time" + "github.com/juicedata/juicefs/pkg/acl" "github.com/juicedata/juicefs/pkg/chunk" "github.com/juicedata/juicefs/pkg/fs" "github.com/juicedata/juicefs/pkg/meta" @@ -283,7 +284,7 @@ func TestObjectCommitAfterBucketDeleted(t *testing.T) { // exercise the commit path deterministically, bypassing checkBucket data := []byte("data") if _, err := g1.putObject(ctx, bucket, g1.path(bucket, "obj"), - newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}, func(string) {}); !errors.As(err, &minio.BucketNotFound{}) { + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}, func(string) {}, false); !errors.As(err, &minio.BucketNotFound{}) { t.Fatalf("putObject after bucket deleted should return BucketNotFound, got %v", err) } if _, errno := g2.fs.Stat(mctx, g2.path(bucket)); !fs.IsNotExist(errno) { @@ -843,3 +844,216 @@ func TestDeleteObjects(t *testing.T) { } }) } + +func setupMetadataInheritanceTarget(t *testing.T, jfs *fs.FileSystem, defaultRule *acl.Rule) (string, *acl.Rule) { + t.Helper() + format := jfs.Meta().GetFormat() + format.EnableACL = true + if err := jfs.Meta().Init(&format, false); err != nil { + t.Fatalf("enable ACL support: %s", err) + } + + const target = "/acl-target" + if eno := jfs.Mkdir(mctx, target, 0777, 0); eno != 0 { + t.Fatalf("mkdir target: %s", eno) + } + + // Configure the target through the metadata API with an explicit root + // context. The test must also run as an unprivileged local macOS process; + // going through vfs.File.Chown would depend on the host's OS permissions. + rootCtx := meta.Background() + var targetIno meta.Ino + eno := jfs.Meta().Lookup(rootCtx, meta.RootInode, "acl-target", &targetIno, new(meta.Attr), false) + if eno != 0 { + t.Fatalf("lookup target: %s", eno) + } + if eno = jfs.Meta().SetAttr(rootCtx, targetIno, meta.SetAttrGID, 0, &meta.Attr{Gid: 2468}); eno != 0 { + t.Fatalf("set target gid: %s", eno) + } + // Chown may clear setgid, so set it afterwards. + if eno = jfs.Meta().SetAttr(rootCtx, targetIno, meta.SetAttrMode, 0, &meta.Attr{Mode: 02770}); eno != 0 { + t.Fatalf("set target mode: %s", eno) + } + + if defaultRule == nil { + // Keep the resulting mode at 0600 while making the ACL extended, so the + // test checks both the mode derived from the default ACL and ACL storage. + defaultRule = &acl.Rule{ + Owner: 6, + Group: 0, + Mask: 0, + Other: 0, + NamedUsers: []acl.Entry{{ + Id: 1001, + Perm: 0, + }}, + } + } + if eno = jfs.Meta().SetFacl(rootCtx, targetIno, acl.TypeDefault, defaultRule); eno != 0 { + t.Fatalf("set default ACL: %s", eno) + } + + return target, defaultRule +} + +func setupNestedMetadataInheritanceTarget(t *testing.T, jfs *fs.FileSystem) (string, *acl.Rule) { + // Nested directory creation needs execute permission. Keep this setup + // separate from the regular file case, whose ACL intentionally produces 0600. + nestedRule := &acl.Rule{ + Owner: 7, + Group: 0, + Mask: 7, + Other: 0, + NamedUsers: []acl.Entry{{ + Id: 1001, + Perm: 0, + }}, + } + target, _ := setupMetadataInheritanceTarget(t, jfs, nestedRule) + return target, nestedRule +} + +func assertInheritedMetadata(t *testing.T, jfs *fs.FileSystem, name string, wantACL *acl.Rule) { + t.Helper() + fi, eno := jfs.Stat(mctx, name) + if eno != 0 { + t.Fatalf("stat %s: %s", name, eno) + } + gotACL := &acl.Rule{} + aclErr := jfs.GetFacl(mctx, name, acl.TypeAccess, gotACL) + t.Logf("%s: mode=%#o gid=%d access_acl_err=%v access_acl=%s", name, uint32(fi.Mode().Perm()), fi.Gid(), aclErr, gotACL) + + if fi.Gid() != 2468 || uint32(fi.Mode().Perm()) != uint32(wantACL.GetMode()) || aclErr != 0 || !gotACL.IsEqual(wantACL) { + t.Errorf("metadata mismatch for %s: got mode=%#o gid=%d acl_err=%v acl=%s, want mode=%#o gid=%d acl=%s", + name, uint32(fi.Mode().Perm()), fi.Gid(), aclErr, gotACL, uint32(wantACL.GetMode()), 2468, wantACL) + } +} + +// TestGatewayObjectOperationsInheritDestinationMetadata verifies that every +// Gateway upload completion path applies the destination directory's POSIX +// GID and default ACL to the final object inode. +func TestGatewayObjectOperationsInheritDestinationMetadata(t *testing.T) { + // A regular PUT creates a staged inode first and commits it with rename. + t.Run("PUT", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, defaultRule := setupMetadataInheritanceTarget(t, jfs, nil) + wantACL := defaultRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + + if _, err := jfsObj.PutObject(context.Background(), bucket, "acl-target/put", + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}); err != nil { + t.Fatalf("put object: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/put", wantACL) + }) + + // A nested object path exercises the ENOENT retry: the first rename sees + // missing parent directories, mkdirAllInBucket creates them, and the second + // rename commits the staged file with inherited metadata. + t.Run("PUT with missing parent directories", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, nestedRule := setupNestedMetadataInheritanceTarget(t, jfs) + wantACL := nestedRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + object := "acl-target/nested/deep/put" + + if _, err := jfsObj.PutObject(context.Background(), bucket, object, + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}); err != nil { + t.Fatalf("put nested object: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/nested/deep/put", wantACL) + }) + + // COPY follows the same staged-file commit path as PUT and must preserve the + // destination directory's inherited metadata. + t.Run("COPY", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, defaultRule := setupMetadataInheritanceTarget(t, jfs, nil) + wantACL := defaultRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + if _, err := jfsObj.PutObject(context.Background(), bucket, "source", + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}); err != nil { + t.Fatalf("put source object: %s", err) + } + srcInfo, err := jfsObj.GetObjectInfo(context.Background(), bucket, "source", minio.ObjectOptions{}) + if err != nil { + t.Fatalf("get source object info: %s", err) + } + if _, err = jfsObj.CopyObject(context.Background(), bucket, "source", bucket, + "acl-target/copy", srcInfo, minio.ObjectOptions{}, minio.ObjectOptions{}); err != nil { + t.Fatalf("copy object: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/copy", wantACL) + }) + + // COPY must also retry after the destination's missing parent directories + // are created, then apply the destination directory's inherited metadata. + t.Run("COPY with missing parent directories", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, nestedRule := setupNestedMetadataInheritanceTarget(t, jfs) + wantACL := nestedRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + if _, err := jfsObj.PutObject(context.Background(), bucket, "source", + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}); err != nil { + t.Fatalf("put source object: %s", err) + } + srcInfo, err := jfsObj.GetObjectInfo(context.Background(), bucket, "source", minio.ObjectOptions{}) + if err != nil { + t.Fatalf("get source object info: %s", err) + } + object := "acl-target/nested/deep/copy" + if _, err = jfsObj.CopyObject(context.Background(), bucket, "source", bucket, + object, srcInfo, minio.ObjectOptions{}, minio.ObjectOptions{}); err != nil { + t.Fatalf("copy nested object: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/nested/deep/copy", wantACL) + }) + + // Completing a multipart upload commits a previously staged inode and must + // apply inheritance at completion time, not only when the upload starts. + t.Run("multipart completion", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, defaultRule := setupMetadataInheritanceTarget(t, jfs, nil) + wantACL := defaultRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + object := "acl-target/multipart" + uploadID, err := jfsObj.NewMultipartUpload(context.Background(), bucket, object, minio.ObjectOptions{}) + if err != nil { + t.Fatalf("new multipart upload: %s", err) + } + part, err := jfsObj.PutObjectPart(context.Background(), bucket, object, uploadID, 1, + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}) + if err != nil { + t.Fatalf("put multipart part: %s", err) + } + if _, err = jfsObj.CompleteMultipartUpload(context.Background(), bucket, object, uploadID, + []minio.CompletePart{{PartNumber: 1, ETag: part.ETag}}, minio.ObjectOptions{}); err != nil { + t.Fatalf("complete multipart upload: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/multipart", wantACL) + }) + + // Multipart completion must create missing destination parents before the + // retrying rename and preserve the inherited metadata on the final object. + t.Run("multipart completion with missing parent directories", func(t *testing.T) { + jfsObj, jfs, bucket := newTestGateway(t, Config{}) + target, nestedRule := setupNestedMetadataInheritanceTarget(t, jfs) + wantACL := nestedRule.ChildAccessACL(0666) + data := []byte("metadata-inheritance") + object := "acl-target/nested/deep/multipart" + uploadID, err := jfsObj.NewMultipartUpload(context.Background(), bucket, object, minio.ObjectOptions{}) + if err != nil { + t.Fatalf("new nested multipart upload: %s", err) + } + part, err := jfsObj.PutObjectPart(context.Background(), bucket, object, uploadID, 1, + newTestPutObjReader(t, bytes.NewReader(data), data), minio.ObjectOptions{}) + if err != nil { + t.Fatalf("put nested multipart part: %s", err) + } + if _, err = jfsObj.CompleteMultipartUpload(context.Background(), bucket, object, uploadID, + []minio.CompletePart{{PartNumber: 1, ETag: part.ETag}}, minio.ObjectOptions{}); err != nil { + t.Fatalf("complete nested multipart upload: %s", err) + } + assertInheritedMetadata(t, jfs, target+"/nested/deep/multipart", wantACL) + }) +} diff --git a/pkg/meta/base.go b/pkg/meta/base.go index bb1f26ee7869..36bb4cc10aae 100644 --- a/pkg/meta/base.go +++ b/pkg/meta/base.go @@ -124,7 +124,7 @@ type engine interface { doBatchUnlink(ctx Context, parent Ino, entries []*Entry, delta *dirStat, skipCheckTrash ...bool) syscall.Errno doReadlink(ctx Context, inode Ino, noatime bool) (int64, []byte, error) doReaddir(ctx Context, inode Ino, plus uint8, entries *[]*Entry, limit int) syscall.Errno - doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode, tinode *Ino, attr, tattr *Attr) syscall.Errno + doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inheritMetadata bool, mode uint16, inode, tinode *Ino, attr, tattr *Attr) syscall.Errno doSetXattr(ctx Context, inode Ino, name string, value []byte, flags uint32) syscall.Errno doRemoveXattr(ctx Context, inode Ino, name string) syscall.Errno doRepair(ctx Context, inode Ino, attr *Attr) syscall.Errno @@ -1593,6 +1593,37 @@ func (m *baseMeta) inheritGid(ctx Context, _type uint8, parentGid uint32, parent return ctx.Gid() } +// inheritFileAttr applies the same GID and ACL rules used by Mknod when a +// regular file is created directly in parentAttr. The ACL callbacks are bound +// to the caller's backend transaction, so ACL lookup and insertion remain +// atomic with the inode update and rename. +func (m *baseMeta) inheritFileAttr(ctx Context, parentAttr, attr *Attr, mode uint16, + getACL func(uint32) (*aclAPI.Rule, error), insertACL func(*aclAPI.Rule) (uint32, error)) error { + attr.Gid = m.inheritGid(ctx, TypeFile, parentAttr.Gid, parentAttr.Mode) + mode &= 07777 + if parentAttr.DefaultACL == aclAPI.None { + return nil + } + + rule, err := getACL(parentAttr.DefaultACL) + if err != nil { + return err + } + if rule.IsMinimal() { + attr.Mode = mode & (0xFE00 | rule.GetMode()) + return nil + } + + cRule := rule.ChildAccessACL(mode) + id, err := insertACL(cRule) + if err != nil { + return err + } + attr.AccessACL = id + attr.Mode = (mode & 0xFE00) | cRule.GetMode() + return nil +} + func (m *baseMeta) inheritMode(ctx Context, _type uint8, parentGid uint32, parentMode, childMode uint16) uint16 { if ctx.Value(CtxKey("behavior")) == "Hadoop" || runtime.GOOS == "darwin" { return childMode @@ -1900,7 +1931,29 @@ func (m *baseMeta) BatchClone(ctx Context, srcParent Ino, dstParent Ino, entries return st } +// renameMetadataConcurrencyHookKey is used by package tests to force +// interleavings between RenameWithInheritedMetadata transactions. +const renameMetadataConcurrencyHookKey CtxKey = "rename-metadata-concurrency-hook" + +func (m *baseMeta) runRenameMetadataConcurrencyHook(ctx Context) { + if hook, ok := ctx.Value(renameMetadataConcurrencyHookKey).(func()); ok && hook != nil { + hook() + } +} + func (m *baseMeta) Rename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode *Ino, attr *Attr) syscall.Errno { + return m.rename(ctx, parentSrc, nameSrc, parentDst, nameDst, flags, 0, false, inode, attr) +} + +// RenameWithInheritedMetadata moves a staged regular file into its destination +// while applying the destination directory's file-creation inheritance rules. +// The backend performs the parent/child metadata reads and all metadata writes +// in the same transaction as the rename. +func (m *baseMeta) RenameWithInheritedMetadata(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, mode uint16, inode *Ino, attr *Attr) syscall.Errno { + return m.rename(ctx, parentSrc, nameSrc, parentDst, nameDst, flags, mode, true, inode, attr) +} + +func (m *baseMeta) rename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, mode uint16, inheritMetadata bool, inode *Ino, attr *Attr) syscall.Errno { if parentSrc == RootInode && nameSrc == TrashName || parentDst == RootInode && nameDst == TrashName { return syscall.EPERM } @@ -1970,8 +2023,14 @@ func (m *baseMeta) Rename(ctx Context, parentSrc Ino, nameSrc string, parentDst } tinode := new(Ino) tattr := new(Attr) - st := m.en.doRename(ctx, parentSrc, nameSrc, parentDst, nameDst, flags, inode, tinode, attr, tattr) + st := m.en.doRename(ctx, parentSrc, nameSrc, parentDst, nameDst, flags, inheritMetadata, mode, inode, tinode, attr, tattr) if st == 0 { + if inheritMetadata { + // The staged inode may still have an open-file cache entry from the + // upload. Refresh it after the transactional metadata update. + m.of.InvalidateChunk(*inode, invalidateAttrOnly) + m.of.Update(*inode, attr) + } var diffLength uint64 if attr.Typ == TypeDirectory { m.parentMu.Lock() diff --git a/pkg/meta/base_test.go b/pkg/meta/base_test.go index 972b2f0f3266..5fb85d533406 100644 --- a/pkg/meta/base_test.go +++ b/pkg/meta/base_test.go @@ -209,6 +209,12 @@ func testMeta(t *testing.T, m Meta) { testClone(t, m) testBatchClone(t, m) testACL(t, m) + testRenameWithInheritedMetadata(t, m) + testRenameWithInheritedMetadataVariants(t, m) + testRenamePreservesMetadata(t, m) + testRenameWithInheritedMetadataOverwrite(t, m) + testConcurrentRenameWithInheritedMetadata(t, m) + testRenameWithInheritedMetadataQuota(t, m) testKerberosToken(t, m) base.conf.ReadOnly = true testReadOnly(t, m) @@ -6800,3 +6806,668 @@ func TestRedisLockIndexRelease(t *testing.T) { t.Fatalf("locked$%d still lists plock inode after last owner released", r1.sid) } } + +// testRenameWithInheritedMetadata verifies that a staged file receives the +// destination directory's GID and extended access ACL when it is committed. +func testRenameWithInheritedMetadata(t *testing.T, m Meta) { + t.Helper() + ctx := Background() + const ( + targetName = "rename-inherit-target" + stageName = "rename-inherit-stage" + ) + + // The target directory has a different GID and a default ACL. The staged + // file is created elsewhere to model a gateway upload before it is committed. + defaultRule := &aclAPI.Rule{ + Owner: 6, + Group: 0, + Mask: 0, + Other: 0, + NamedUsers: []aclAPI.Entry{{ + Id: 1001, + Perm: 0, + }}, + } + targetIno := setupRenameInheritanceTarget(t, m, targetName, defaultRule) + stageIno := createRenameStage(t, m, stageName) + defer func() { + _ = m.Unlink(ctx, targetIno, "result") + _ = m.Unlink(ctx, RootInode, stageName) + _ = m.Rmdir(ctx, RootInode, targetName) + }() + + // The special rename should apply the destination directory's metadata to + // the staged file as part of the commit. + if st := m.RenameWithInheritedMetadata(ctx, RootInode, stageName, targetIno, "result", 0, 0666, nil, nil); st != 0 { + t.Fatalf("rename with inherited metadata: %s", st) + } + + // Verify both the effective mode/GID and the materialized access ACL. + var attr Attr + if st := m.GetAttr(ctx, stageIno, &attr); st != 0 { + t.Fatalf("get result attr: %s", st) + } + wantACL := defaultRule.ChildAccessACL(0666) + gotACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, gotACL); st != 0 { + t.Fatalf("get result access acl: %s", st) + } + if attr.Gid != 2468 || attr.Mode&07777 != wantACL.GetMode() || !gotACL.IsEqual(wantACL) { + t.Fatalf("inherited metadata mismatch: mode=%#o gid=%d acl=%s, want mode=%#o gid=%d acl=%s", + attr.Mode&07777, attr.Gid, gotACL, wantACL.GetMode(), 2468, wantACL) + } +} + +func setupRenameInheritanceTarget(t *testing.T, m Meta, name string, defaultRule *aclAPI.Rule) Ino { + t.Helper() + ctx := Background() + var target Ino + // The setgid bit makes the destination GID observable during inheritance; + // defaultRule is optional so callers can exercise both ACL and non-ACL cases. + if st := m.Mkdir(ctx, RootInode, name, 02770, 0, 0, &target, nil); st != 0 { + t.Fatalf("mkdir target %s: %s", name, st) + } + if st := m.SetAttr(ctx, target, SetAttrGID, 0, &Attr{Gid: 2468}); st != 0 { + t.Fatalf("set target %s gid: %s", name, st) + } + if st := m.SetAttr(ctx, target, SetAttrMode, 0, &Attr{Mode: 02770}); st != 0 { + t.Fatalf("set target %s mode: %s", name, st) + } + if defaultRule != nil { + if st := m.SetFacl(ctx, target, aclAPI.TypeDefault, defaultRule.Dup()); st != 0 { + t.Fatalf("set target %s default acl: %s", name, st) + } + } + return target +} + +func createRenameStage(t *testing.T, m Meta, name string) Ino { + t.Helper() + ctx := Background() + var inode Ino + // Keep this helper representative of an uploaded/staged regular file: it is + // created outside the destination directory and carries no destination ACL. + if st := m.Create(ctx, RootInode, name, 0666, 022, 0, &inode, nil); st != 0 { + t.Fatalf("create staged file %s: %s", name, st) + } + if st := m.Close(ctx, inode); st != 0 { + t.Fatalf("close staged file %s: %s", name, st) + } + return inode +} + +// testRenameWithInheritedMetadataVariants covers rename inheritance when the +// destination has no default ACL or only a minimal default ACL. +func testRenameWithInheritedMetadataVariants(t *testing.T, m Meta) { + // Both cases should inherit the destination GID but materialize no extended + // access ACL: a missing default ACL preserves the mode, while a minimal + // default ACL changes only the mode bits. + cases := []struct { + name string + targetName string + stageName string + defaultRule *aclAPI.Rule + wantMode uint16 + }{ + { + name: "without default acl", + targetName: "rename-inherit-no-default-acl", + stageName: "rename-inherit-no-default-stage", + wantMode: 0644, + }, + { + name: "with minimal default acl", + targetName: "rename-inherit-minimal-default-acl", + stageName: "rename-inherit-minimal-stage", + defaultRule: &aclAPI.Rule{Owner: 7, Group: 5, Mask: 0xFFFF, Other: 1}, + wantMode: 0640, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := Background() + target := setupRenameInheritanceTarget(t, m, tc.targetName, tc.defaultRule) + stageIno := createRenameStage(t, m, tc.stageName) + defer func() { + _ = m.Unlink(ctx, target, "result") + _ = m.Unlink(ctx, RootInode, tc.stageName) + _ = m.Rmdir(ctx, RootInode, tc.targetName) + }() + + if st := m.RenameWithInheritedMetadata(ctx, RootInode, tc.stageName, target, "result", 0, 0666, nil, nil); st != 0 { + t.Fatalf("rename with inherited metadata: %s", st) + } + + var attr Attr + if st := m.GetAttr(ctx, stageIno, &attr); st != 0 { + t.Fatalf("get result attr: %s", st) + } + if attr.Gid != 2468 || attr.Mode&07777 != tc.wantMode || attr.AccessACL != aclAPI.None { + t.Fatalf("unexpected metadata: mode=%#o gid=%d access_acl=%d, want mode=%#o gid=%d access_acl=%d", + attr.Mode&07777, attr.Gid, attr.AccessACL, tc.wantMode, 2468, aclAPI.None) + } + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, &aclAPI.Rule{}); st != ENOATTR { + t.Fatalf("get access acl: got %s, want %s", st, ENOATTR) + } + }) + } +} + +// testRenamePreservesMetadata protects the ordinary POSIX rename path from +// accidentally inheriting metadata from the destination directory. +func testRenamePreservesMetadata(t *testing.T, m Meta) { + ctx := Background() + const targetName = "rename-preserve-metadata-target" + // Give the source and destination distinct metadata so an accidental + // inheritance by ordinary Rename is visible. + targetRule := &aclAPI.Rule{ + Owner: 7, + Group: 7, + Mask: 0, + Other: 0, + NamedUsers: []aclAPI.Entry{{ + Id: 1001, + Perm: 0, + }}, + } + target := setupRenameInheritanceTarget(t, m, targetName, targetRule) + const stageName = "rename-preserve-metadata-stage" + stageIno := createRenameStage(t, m, stageName) + defer func() { + _ = m.Unlink(ctx, target, "result") + _ = m.Unlink(ctx, RootInode, stageName) + _ = m.Rmdir(ctx, RootInode, targetName) + }() + + if st := m.SetAttr(ctx, stageIno, SetAttrGID, 0, &Attr{Gid: 1357}); st != 0 { + t.Fatalf("set staged file gid: %s", st) + } + sourceRule := &aclAPI.Rule{ + Owner: 6, + Group: 4, + Mask: 4, + Other: 0, + NamedUsers: []aclAPI.Entry{{ + Id: 2001, + Perm: 4, + }}, + } + if st := m.SetFacl(ctx, stageIno, aclAPI.TypeAccess, sourceRule); st != 0 { + t.Fatalf("set staged file access acl: %s", st) + } + + var before Attr + if st := m.GetAttr(ctx, stageIno, &before); st != 0 { + t.Fatalf("get staged file before rename: %s", st) + } + beforeACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, beforeACL); st != 0 { + t.Fatalf("get staged file access acl before rename: %s", st) + } + // Ordinary POSIX rename moves the directory entry only; it must not rewrite + // the source file's GID, mode, or access ACL. + if st := m.Rename(ctx, RootInode, stageName, target, "result", 0, nil, nil); st != 0 { + t.Fatalf("ordinary rename: %s", st) + } + + var after Attr + if st := m.GetAttr(ctx, stageIno, &after); st != 0 { + t.Fatalf("get result attr after ordinary rename: %s", st) + } + afterACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, afterACL); st != 0 { + t.Fatalf("get result access acl after ordinary rename: %s", st) + } + if after.Gid != before.Gid || after.Mode != before.Mode || !afterACL.IsEqual(beforeACL) { + t.Fatalf("ordinary rename changed source metadata: before mode=%#o gid=%d acl=%s, after mode=%#o gid=%d acl=%s", + before.Mode&07777, before.Gid, beforeACL, after.Mode&07777, after.Gid, afterACL) + } +} + +// testRenameWithInheritedMetadataOverwrite covers replacement and failed +// no-replace rename paths, including rollback of staged metadata changes. +func testRenameWithInheritedMetadataOverwrite(t *testing.T, m Meta) { + // Replacing an existing file must apply the destination metadata to the + // incoming staged inode and leave the destination name pointing to it. + t.Run("overwrite existing file", func(t *testing.T) { + ctx := Background() + // The destination name already exists, so this verifies replacement while + // applying the destination directory's metadata to the incoming file. + const targetName = "rename-inherit-overwrite-target" + defaultRule := &aclAPI.Rule{ + Owner: 7, + Group: 1, + Mask: 4, + Other: 0, + NamedUsers: []aclAPI.Entry{{ + Id: 1001, + Perm: 4, + }}, + } + target := setupRenameInheritanceTarget(t, m, targetName, defaultRule) + stageName := "rename-inherit-overwrite-stage" + stageIno := createRenameStage(t, m, stageName) + defer func() { + _ = m.Unlink(ctx, target, "result") + _ = m.Unlink(ctx, RootInode, stageName) + _ = m.Rmdir(ctx, RootInode, targetName) + }() + + var oldIno Ino + if st := m.Create(ctx, target, "result", 0666, 022, 0, &oldIno, nil); st != 0 { + t.Fatalf("create existing result: %s", st) + } + if st := m.Close(ctx, oldIno); st != 0 { + t.Fatalf("close existing result: %s", st) + } + if st := m.RenameWithInheritedMetadata(ctx, RootInode, stageName, target, "result", 0, 0666, nil, nil); st != 0 { + t.Fatalf("rename over existing result: %s", st) + } + + var resultIno Ino + var resultAttr Attr + if st := m.Lookup(ctx, target, "result", &resultIno, &resultAttr, true); st != 0 { + t.Fatalf("lookup overwritten result: %s", st) + } + if resultIno != stageIno || resultAttr.Gid != 2468 || resultAttr.Mode&07777 != 0640 { + t.Fatalf("unexpected overwritten result: inode=%d mode=%#o gid=%d", resultIno, resultAttr.Mode&07777, resultAttr.Gid) + } + gotACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, resultIno, aclAPI.TypeAccess, gotACL); st != 0 { + t.Fatalf("get overwritten result access acl: %s", st) + } + wantACL := &aclAPI.Rule{Owner: 6, Group: 1, Mask: 4, Other: 0, NamedUsers: []aclAPI.Entry{{Id: 1001, Perm: 4}}} + if !gotACL.IsEqual(wantACL) { + t.Fatalf("unexpected overwritten result access acl: got %s, want %s", gotACL, wantACL) + } + if st := m.Lookup(ctx, RootInode, stageName, new(Ino), new(Attr), false); st != syscall.ENOENT { + t.Fatalf("staged entry after overwrite: got %s, want %s", st, syscall.ENOENT) + } + }) + + // A failed no-replace commit must roll back the inheritance changes made to + // the staged inode and preserve the existing destination entry. + t.Run("no replace leaves source unchanged", func(t *testing.T) { + ctx := Background() + // A failed no-replace operation must not leave partially inherited + // metadata on the staged source. + const targetName = "rename-inherit-no-replace-target" + defaultRule := &aclAPI.Rule{Owner: 7, Group: 1, Mask: 4, Other: 0, NamedUsers: []aclAPI.Entry{{Id: 1001, Perm: 4}}} + target := setupRenameInheritanceTarget(t, m, targetName, defaultRule) + stageName := "rename-inherit-no-replace-stage" + stageIno := createRenameStage(t, m, stageName) + defer func() { + _ = m.Unlink(ctx, target, "result") + _ = m.Unlink(ctx, RootInode, stageName) + _ = m.Rmdir(ctx, RootInode, targetName) + }() + + if st := m.SetAttr(ctx, stageIno, SetAttrGID, 0, &Attr{Gid: 1357}); st != 0 { + t.Fatalf("set staged file gid: %s", st) + } + sourceRule := &aclAPI.Rule{Owner: 6, Group: 4, Mask: 4, Other: 0, NamedUsers: []aclAPI.Entry{{Id: 2001, Perm: 4}}} + if st := m.SetFacl(ctx, stageIno, aclAPI.TypeAccess, sourceRule); st != 0 { + t.Fatalf("set staged file access acl: %s", st) + } + var before Attr + if st := m.GetAttr(ctx, stageIno, &before); st != 0 { + t.Fatalf("get staged file before no-replace rename: %s", st) + } + beforeACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, beforeACL); st != 0 { + t.Fatalf("get staged file acl before no-replace rename: %s", st) + } + + var existing Ino + if st := m.Create(ctx, target, "result", 0666, 022, 0, &existing, nil); st != 0 { + t.Fatalf("create existing result: %s", st) + } + if st := m.Close(ctx, existing); st != 0 { + t.Fatalf("close existing result: %s", st) + } + // The existing destination forces EEXIST before the rename can commit. + if st := m.RenameWithInheritedMetadata(ctx, RootInode, stageName, target, "result", RenameNoReplace, 0666, nil, nil); st != syscall.EEXIST { + t.Fatalf("no-replace rename: got %s, want %s", st, syscall.EEXIST) + } + + var after Attr + if st := m.GetAttr(ctx, stageIno, &after); st != 0 { + t.Fatalf("get staged file after failed no-replace rename: %s", st) + } + afterACL := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, afterACL); st != 0 { + t.Fatalf("get staged file acl after failed no-replace rename: %s", st) + } + if after.Gid != before.Gid || after.Mode != before.Mode || !afterACL.IsEqual(beforeACL) { + t.Fatalf("failed no-replace rename changed source metadata: before mode=%#o gid=%d acl=%s, after mode=%#o gid=%d acl=%s", + before.Mode&07777, before.Gid, beforeACL, after.Mode&07777, after.Gid, afterACL) + } + var resultIno Ino + if st := m.Lookup(ctx, target, "result", &resultIno, new(Attr), false); st != 0 || resultIno != existing { + t.Fatalf("existing result after failed no-replace rename: status=%s inode=%d, want status=success inode=%d", st, resultIno, existing) + } + }) +} + +func sharedMetaClientForConcurrencyTest(t *testing.T, m Meta) (Meta, func()) { + t.Helper() + base := newBaseMeta(m.getBase().addr, testConfig()) + base.setFormat(m.getBase().getFormat()) + switch current := m.(type) { + case *kvMeta: + other := &kvMeta{baseMeta: base, client: current.client} + other.en = other + return other, func() { other.of.close() } + case *dbMeta: + other := &dbMeta{ + baseMeta: base, + db: current.db, + spool: current.spool, + statement: current.statement, + tablePrefix: current.tablePrefix, + } + other.en = other + return other, func() { other.of.close() } + case *redisMeta: + other := &redisMeta{baseMeta: base, rdb: current.rdb, prefix: current.prefix} + other.en = other + return other, func() { other.of.close() } + default: + t.Fatalf("unsupported metadata client %T", m) + return nil, func() {} + } +} + +func setupConcurrentRenameInheritanceTarget(t *testing.T, m Meta, name string) (Ino, *aclAPI.Rule, *aclAPI.Rule) { + t.Helper() + ctx := Background() + var target Ino + if st := m.Mkdir(ctx, RootInode, name, 02770, 0, 0, &target, nil); st != 0 { + t.Fatalf("mkdir %s: %s", name, st) + } + if st := m.SetAttr(ctx, target, SetAttrGID, 0, &Attr{Gid: 2468}); st != 0 { + t.Fatalf("set %s gid: %s", name, st) + } + if st := m.SetAttr(ctx, target, SetAttrMode, 0, &Attr{Mode: 02770}); st != 0 { + t.Fatalf("set %s mode: %s", name, st) + } + + ruleA := &aclAPI.Rule{ + Owner: 7, + Group: 5, + Mask: 5, + Other: 0, + NamedUsers: []aclAPI.Entry{{ + Id: 1001, + Perm: 4, + }}, + } + ruleB := &aclAPI.Rule{ + Owner: 6, + Group: 2, + Mask: 6, + Other: 1, + NamedUsers: []aclAPI.Entry{{ + Id: 1001, + Perm: 0, + }}, + } + if st := m.SetFacl(ctx, target, aclAPI.TypeDefault, ruleA); st != 0 { + t.Fatalf("set %s default acl A: %s", name, st) + } + if st := m.SetFacl(ctx, target, aclAPI.TypeDefault, ruleB); st != 0 { + t.Fatalf("set %s default acl B: %s", name, st) + } + if st := m.SetFacl(ctx, target, aclAPI.TypeDefault, ruleA); st != 0 { + t.Fatalf("reset %s default acl A: %s", name, st) + } + return target, ruleA, ruleB +} + +func assertConcurrentInheritedMetadata(t *testing.T, m Meta, inode Ino, rules ...*aclAPI.Rule) { + t.Helper() + ctx := Background() + var attr Attr + if st := m.GetAttr(ctx, inode, &attr); st != 0 { + t.Fatalf("get inherited attr: %s", st) + } + got := &aclAPI.Rule{} + if st := m.GetFacl(ctx, inode, aclAPI.TypeAccess, got); st != 0 { + t.Fatalf("get inherited access acl: %s", st) + } + for _, rule := range rules { + want := rule.ChildAccessACL(0666) + if attr.Gid == 2468 && attr.Mode&07777 == want.GetMode() && got.IsEqual(want) { + return + } + } + t.Fatalf("inherited metadata is not a complete parent version: mode=%#o gid=%d acl=%s", attr.Mode&07777, attr.Gid, got) +} + +func testConcurrentRenameWithInheritedMetadata(t *testing.T, m Meta) { + // Force an ACL update after the parent metadata is read and verify that the + // backend transaction retries or serializes the rename without mixed metadata. + t.Run("metadata read window", func(t *testing.T) { + ctx := Background() + const targetName = "rename-inherit-concurrent-window" + target, ruleA, ruleB := setupConcurrentRenameInheritanceTarget(t, m, targetName) + stageName := "rename-inherit-window-stage" + defer func() { + _ = m.Unlink(ctx, RootInode, stageName) + _ = m.Unlink(ctx, target, "result") + _ = m.Rmdir(ctx, RootInode, targetName) + }() + + var stageIno Ino + if st := m.Create(ctx, RootInode, stageName, 0666, 022, 0, &stageIno, nil); st != 0 { + t.Fatalf("create staged file: %s", st) + } + if st := m.Close(ctx, stageIno); st != 0 { + t.Fatalf("close staged file: %s", st) + } + + updater, closeUpdater := sharedMetaClientForConcurrencyTest(t, m) + defer closeUpdater() + + // Pause after reading the parent metadata, then update that parent through + // another metadata client to force the backend's conflict path. + metadataRead := make(chan struct{}) + release := make(chan struct{}) + var hookOnce sync.Once + hookCtx := ctx.WithValue(renameMetadataConcurrencyHookKey, func() { + hookOnce.Do(func() { + close(metadataRead) + <-release + }) + }) + + renameDone := make(chan syscall.Errno, 1) + go func() { + renameDone <- m.RenameWithInheritedMetadata(hookCtx, RootInode, stageName, target, "result", 0, 0666, nil, nil) + }() + select { + case <-metadataRead: + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for rename transaction to read parent metadata") + } + + updateDone := make(chan syscall.Errno, 1) + go func() { + updateDone <- updater.SetFacl(ctx, target, aclAPI.TypeDefault, ruleB) + }() + + updatedBeforeRelease := false + if _, ok := m.(*dbMeta); ok { + // SQL backends may hold a row/write lock until the rename commits. + select { + case st := <-updateDone: + if st != 0 { + t.Fatalf("concurrent default acl update: %s", st) + } + updatedBeforeRelease = true + default: + } + } else { + select { + case st := <-updateDone: + if st != 0 { + t.Fatalf("concurrent default acl update: %s", st) + } + updatedBeforeRelease = true + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for concurrent default acl update") + } + } + + close(release) + select { + case st := <-renameDone: + if st != 0 { + t.Fatalf("rename with inherited metadata: %s", st) + } + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for rename transaction") + } + if !updatedBeforeRelease { + select { + case st := <-updateDone: + if st != 0 { + t.Fatalf("concurrent default acl update after release: %s", st) + } + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for concurrent default acl update after release") + } + } + + assertConcurrentInheritedMetadata(t, m, stageIno, ruleA, ruleB) + if updatedBeforeRelease { + // An optimistic transaction must retry after the parent changed. + got := &aclAPI.Rule{} + if st := m.GetFacl(ctx, stageIno, aclAPI.TypeAccess, got); st != 0 { + t.Fatalf("get result access acl: %s", st) + } + if !got.IsEqual(ruleB.ChildAccessACL(0666)) { + t.Fatalf("rename committed stale metadata: got %s, want %s", got, ruleB.ChildAccessACL(0666)) + } + } + }) +} + +// testRenameWithInheritedMetadataQuota verifies that changing a staged file's +// GID during rename moves its inode and aligned space usage between groups. +func testRenameWithInheritedMetadataQuota(t *testing.T, m Meta) { + t.Helper() + ctx := Background() + const ( + targetName = "rename-inherit-quota-target" + stageName = "rename-inherit-quota-stage" + ordinaryName = "rename-inherit-quota-ordinary" + oldGID = uint32(1357) + newGID = uint32(2468) + fileSize = uint64(8192) + ) + oldKey := fmt.Sprintf("%d", oldGID) + newKey := fmt.Sprintf("%d", newGID) + var target Ino + + // Enable group quota accounting before creating the test objects so all + // setup changes are included in the baseline measurements below. + for _, gid := range []uint32{oldGID, newGID} { + key := fmt.Sprintf("%d", gid) + if err := m.HandleQuota(ctx, QuotaSet, key, GroupQuotaType, + map[string]*Quota{key: {MaxSpace: 1 << 30, MaxInodes: 100}}, false, false, false); err != nil { + t.Fatalf("set group quota %d: %s", gid, err) + } + } + m.getBase().loadQuotas() + + getUsage := func(key string) (int64, int64) { + m.getBase().doFlushQuotas() + quotas := make(map[string]*Quota) + if err := m.HandleQuota(ctx, QuotaGet, key, GroupQuotaType, quotas, false, false, false); err != nil { + t.Fatalf("get group quota %s: %s", key, err) + } + q := quotas[key] + if q == nil { + t.Fatalf("group quota %s not found", key) + } + return q.UsedInodes, q.UsedSpace + } + + defer func() { + _ = m.Unlink(ctx, RootInode, stageName) + if target > 0 { + _ = m.Unlink(ctx, target, "result") + } + _ = m.Unlink(ctx, RootInode, ordinaryName) + _ = m.Rmdir(ctx, RootInode, targetName) + _ = m.HandleQuota(ctx, QuotaDel, oldKey, GroupQuotaType, nil, false, false, false) + _ = m.HandleQuota(ctx, QuotaDel, newKey, GroupQuotaType, nil, false, false, false) + }() + + // Prepare a staged file owned by oldGID and a setgid destination owned by + // newGID. The file has data so both inode and aligned space deltas matter. + target = setupRenameInheritanceTarget(t, m, targetName, nil) + var stageIno Ino + if st := m.Create(ctx, RootInode, stageName, 0666, 022, 0, &stageIno, nil); st != 0 { + t.Fatalf("create staged file: %s", st) + } + if st := m.SetAttr(ctx, stageIno, SetAttrGID, 0, &Attr{Gid: oldGID}); st != 0 { + t.Fatalf("set staged file gid: %s", st) + } + var sliceID uint64 + if st := m.NewSlice(ctx, &sliceID); st != 0 { + t.Fatalf("new slice: %s", st) + } + if st := m.Write(ctx, stageIno, 0, 0, Slice{Id: sliceID, Size: uint32(fileSize), Len: uint32(fileSize)}, time.Now()); st != 0 { + t.Fatalf("write staged file: %s", st) + } + if st := m.Close(ctx, stageIno); st != 0 { + t.Fatalf("close staged file: %s", st) + } + + oldBeforeInodes, oldBeforeSpace := getUsage(oldKey) + newBeforeInodes, newBeforeSpace := getUsage(newKey) + + // Inherited-metadata rename reassigns the file to newGID, so accounting must + // move exactly one inode and fileSize bytes between the two groups. + if st := m.RenameWithInheritedMetadata(ctx, RootInode, stageName, target, "result", 0, 0666, nil, nil); st != 0 { + t.Fatalf("rename with inherited metadata: %s", st) + } + + oldAfterInodes, oldAfterSpace := getUsage(oldKey) + newAfterInodes, newAfterSpace := getUsage(newKey) + wantSpace := align4K(fileSize) + if oldAfterInodes != oldBeforeInodes-1 || oldAfterSpace != oldBeforeSpace-wantSpace { + t.Fatalf("old group quota mismatch: before=%d/%d after=%d/%d, want %d/%d", + oldBeforeInodes, oldBeforeSpace, oldAfterInodes, oldAfterSpace, oldBeforeInodes-1, oldBeforeSpace-wantSpace) + } + if newAfterInodes != newBeforeInodes+1 || newAfterSpace != newBeforeSpace+wantSpace { + t.Fatalf("new group quota mismatch: before=%d/%d after=%d/%d, want %d/%d", + newBeforeInodes, newBeforeSpace, newAfterInodes, newAfterSpace, newBeforeInodes+1, newBeforeSpace+wantSpace) + } + + // Reuse the same data-bearing inode for ordinary rename after restoring its + // original GID. This isolates rename from quota accounting without another + // full file setup. + if st := m.SetAttr(ctx, stageIno, SetAttrGID, 0, &Attr{Gid: oldGID}); st != 0 { + t.Fatalf("restore staged file gid: %s", st) + } + oldBeforeInodes, oldBeforeSpace = getUsage(oldKey) + newBeforeInodes, newBeforeSpace = getUsage(newKey) + // The ordinary path must only move the directory entry; group accounting + // must remain identical even though the destination has a different GID. + if st := m.Rename(ctx, target, "result", RootInode, ordinaryName, 0, nil, nil); st != 0 { + t.Fatalf("ordinary rename: %s", st) + } + oldAfterInodes, oldAfterSpace = getUsage(oldKey) + newAfterInodes, newAfterSpace = getUsage(newKey) + if oldAfterInodes != oldBeforeInodes || oldAfterSpace != oldBeforeSpace || + newAfterInodes != newBeforeInodes || newAfterSpace != newBeforeSpace { + t.Fatalf("ordinary rename changed group quota: before old=%d/%d new=%d/%d, after old=%d/%d new=%d/%d", + oldBeforeInodes, oldBeforeSpace, newBeforeInodes, newBeforeSpace, + oldAfterInodes, oldAfterSpace, newAfterInodes, newAfterSpace) + } +} diff --git a/pkg/meta/interface.go b/pkg/meta/interface.go index 0cd69c31e72c..dd3446fd190b 100644 --- a/pkg/meta/interface.go +++ b/pkg/meta/interface.go @@ -456,6 +456,10 @@ type Meta interface { // The targeted entry will be overwrited if it's a file or empty directory. // For Hadoop, the target should not be overwritten. Rename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode *Ino, attr *Attr) syscall.Errno + // RenameWithInheritedMetadata moves a staged regular file and applies the + // destination directory's file-creation GID and default ACL rules atomically. + // mode is the mode requested for the final file before default ACL handling. + RenameWithInheritedMetadata(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, mode uint16, inode *Ino, attr *Attr) syscall.Errno // Link creates an entry for node. Link(ctx Context, inodeSrc, parent Ino, name string, attr *Attr) syscall.Errno // Readdir returns all entries for given directory, which include attributes if plus is true. diff --git a/pkg/meta/redis.go b/pkg/meta/redis.go index 9eb3744bfb6d..05ab38896ad3 100644 --- a/pkg/meta/redis.go +++ b/pkg/meta/redis.go @@ -2425,13 +2425,14 @@ func (m *redisMeta) doRmdir(ctx Context, parent Ino, name string, pinode *Ino, o return errno(err) } -func (m *redisMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { +func (m *redisMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inheritMetadata bool, mode uint16, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { exchange := flags == RenameExchange var opened bool var trash, dino Ino var dtyp uint8 var tattr Attr var newSpace, newInode int64 + var oldGid, newGid uint32 keys := []string{m.inodeKey(parentSrc), m.entryKey(parentSrc), m.inodeKey(parentDst), m.entryKey(parentDst)} if parentSrc.IsTrash() { // lock the parentDst @@ -2620,6 +2621,19 @@ func (m *redisMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentD if ctx.Uid() != 0 && sattr.Mode&01000 != 0 && ctx.Uid() != sattr.Uid && ctx.Uid() != iattr.Uid { return syscall.EACCES } + if inheritMetadata { + if iattr.Typ != TypeFile { + return syscall.EINVAL + } + oldGid = iattr.Gid + if err := m.inheritFileAttr(ctx, &dattr, &iattr, mode, + func(id uint32) (*aclAPI.Rule, error) { return m.getACL(ctx, tx, id) }, + func(rule *aclAPI.Rule) (uint32, error) { return m.insertACL(ctx, tx, rule) }); err != nil { + return err + } + newGid = iattr.Gid + m.runRenameMetadataConcurrencyHook(ctx) + } if parentSrc != parentDst { if typ == TypeDirectory { @@ -2753,6 +2767,11 @@ func (m *redisMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentD m.updateUserGroupStat(ctx, tattr.Uid, tattr.Gid, newSpace, newInode) } } + if err == nil && inheritMetadata && oldGid != newGid { + space := align4K(attr.Length) + m.updateUserGroupStat(ctx, 0, oldGid, -space, -1) + m.updateUserGroupStat(ctx, 0, newGid, space, 1) + } return errno(err) } diff --git a/pkg/meta/sql.go b/pkg/meta/sql.go index fb696879d144..81294b1e26fd 100644 --- a/pkg/meta/sql.go +++ b/pkg/meta/sql.go @@ -2395,7 +2395,7 @@ func (m *dbMeta) getNodes(s *xorm.Session, nodes ...*node) error { return nil } -func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { +func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inheritMetadata bool, mode uint16, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { var trash Ino if st := m.checkTrash(parentDst, &trash); st != 0 { return st @@ -2405,6 +2405,7 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst var dino Ino var dn node var newSpace, newInode int64 + var oldGid, newGid uint32 parentLocks := []Ino{parentDst} if !parentSrc.IsTrash() { // there should be no conflict if parentSrc is in trash, relax lock to accelerate `restore` subcommand parentLocks = append(parentLocks, parentSrc) @@ -2417,7 +2418,14 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst trash = requestedTrash var spn = node{Inode: parentSrc} var dpn = node{Inode: parentDst} - err := m.getNodes(s, &spn, &dpn) + var err error + if inheritMetadata { + // Lock both parent inodes so a concurrent default-ACL/GID change + // cannot race with the inheritance decision below. + err = m.getNodesForUpdate(s, &spn, &dpn) + } else { + err = m.getNodes(s, &spn, &dpn) + } if err != nil { return err } @@ -2457,7 +2465,11 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst return syscall.EPERM } var sn = node{Inode: se.Inode} - ok, err = s.Get(&sn) + if inheritMetadata { + ok, err = s.ForUpdate().Get(&sn) + } else { + ok, err = s.Get(&sn) + } if err != nil { return err } @@ -2569,6 +2581,20 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst if ctx.Uid() != 0 && spn.Mode&01000 != 0 && ctx.Uid() != spn.Uid && ctx.Uid() != sn.Uid { return syscall.EACCES } + if inheritMetadata { + if sattr.Typ != TypeFile { + return syscall.EINVAL + } + oldGid = sattr.Gid + if err := m.inheritFileAttr(ctx, &dpattr, &sattr, mode, + func(id uint32) (*aclAPI.Rule, error) { return m.getACL(s, id) }, + func(rule *aclAPI.Rule) (uint32, error) { return m.insertACL(s, rule) }); err != nil { + return err + } + newGid = sattr.Gid + m.parseNode(&sattr, &sn) + m.runRenameMetadataConcurrencyHook(ctx) + } if parentSrc != parentDst { if se.Type == TypeDirectory { @@ -2680,7 +2706,14 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst } } - if _, err := s.Cols("ctime", "ctimensec", "parent").Update(&sn, &node{Inode: sn.Inode}); err != nil { + updateCols := []string{"ctime", "ctimensec", "parent"} + if inheritMetadata { + // The staged inode's GID, mode, and access ACL were calculated from + // the destination parent inside this transaction. Include them in + // the same update as the rename's parent/ctime changes. + updateCols = []string{"mode", "gid", "ctime", "ctimensec", "parent", "access_acl_id"} + } + if _, err := s.Cols(updateCols...).Update(&sn, &node{Inode: sn.Inode}); err != nil { return err } @@ -2750,6 +2783,11 @@ func (m *dbMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst m.updateUserGroupStat(ctx, dn.Uid, dn.Gid, newSpace, newInode) } } + if err == nil && inheritMetadata && oldGid != newGid { + space := align4K(attr.Length) + m.updateUserGroupStat(ctx, 0, oldGid, -space, -1) + m.updateUserGroupStat(ctx, 0, newGid, space, 1) + } return errno(err) } diff --git a/pkg/meta/tkv.go b/pkg/meta/tkv.go index bc7743a4e2f3..acb17b0db53b 100644 --- a/pkg/meta/tkv.go +++ b/pkg/meta/tkv.go @@ -2147,7 +2147,7 @@ func (m *kvMeta) doRmdir(ctx Context, parent Ino, name string, pinode *Ino, oldA return errno(err) } -func (m *kvMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { +func (m *kvMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst Ino, nameDst string, flags uint32, inheritMetadata bool, mode uint16, inode, tInode *Ino, attr, tAttr *Attr) syscall.Errno { var trash Ino if st := m.checkTrash(parentDst, &trash); st != 0 { return st @@ -2158,6 +2158,7 @@ func (m *kvMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst var dtyp uint8 var tattr Attr var newSpace, newInode int64 + var oldGid, newGid uint32 parentLocks := []Ino{parentDst} if !parentSrc.IsTrash() { // there should be no conflict if parentSrc is in trash, relax lock to accelerate `restore` subcommand parentLocks = append(parentLocks, parentSrc) @@ -2220,6 +2221,19 @@ func (m *kvMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst ctx.Uid() != iattr.Uid && (ctx.Uid() != sattr.Uid || iattr.Typ == TypeDirectory) { return syscall.EACCES } + if inheritMetadata { + if iattr.Typ != TypeFile { + return syscall.EINVAL + } + oldGid = iattr.Gid + if err := m.inheritFileAttr(ctx, &dattr, &iattr, mode, + func(id uint32) (*aclAPI.Rule, error) { return m.getACL(tx, id) }, + func(rule *aclAPI.Rule) (uint32, error) { return m.insertACL(tx, rule) }); err != nil { + return err + } + newGid = iattr.Gid + m.runRenameMetadataConcurrencyHook(ctx) + } dbuf := rs[3] if dbuf == nil && m.conf.CaseInsensi { @@ -2437,6 +2451,11 @@ func (m *kvMeta) doRename(ctx Context, parentSrc Ino, nameSrc string, parentDst m.updateUserGroupStat(ctx, tattr.Uid, tattr.Gid, newSpace, newInode) } } + if err == nil && inheritMetadata && oldGid != newGid { + space := align4K(attr.Length) + m.updateUserGroupStat(ctx, 0, oldGid, -space, -1) + m.updateUserGroupStat(ctx, 0, newGid, space, 1) + } return errno(err) }