feat(fs): group folders via ACL discovery in shared_with_me (#19)
Files granted to a user via explicit ACL entries — user subjects or any of their effective groups — now surface in the flat "shared with me" listing alongside saved share shortcuts, and resolve as browsable subtree roots under cloudreve://shared_with_me/<fileHash>. - inventory: AclClient.SharedFileIDs unions per-file permission bits across matching user/group subject rows (memberships included via GroupIDsOf); entries lacking the read bit are dropped. - childFileQuery unions AclSharedIDs into the symbolic orphan listing; search skips the owner pin for that listing so foreign-owned grants stay searchable. - sharedWithMeNavigator resolves hash-addressed files: own symbolic shortcuts resolve for share-URI translation; ACL grants become subtree roots with the owner's parent chain rebuilt and ACL-derived capability sets inherited by children. Uploads charge the owner and inherit the folder's storage policy. - Per-subtree navigator keys prevent capability staleness across folders; vault wrapper keeps owner-vaulted entries hidden. Tests cover listing, subtree resolution, denial paths, membership-based grants, user subjects, and symbolic shortcuts. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3589/head
parent
28d927cf7b
commit
361ec0736a
@ -0,0 +1,230 @@
|
|||||||
|
package dbfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/application/constants"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/aclentry"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// groupFolderFixture builds an owner with a populated folder and a member
|
||||||
|
// user in a separate group. Returns (member, owner, sharedDir, nestedFile).
|
||||||
|
func groupFolderFixture(t *testing.T, client *ent.Client) (*ent.User, *ent.User, *ent.File, *ent.File) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ownerGroup := client.Group.Create().SetName("owners").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||||
|
memberGroup := client.Group.Create().SetName("members").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||||
|
|
||||||
|
owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(ownerGroup).SaveX(ctx)
|
||||||
|
ownerRoot := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
|
||||||
|
sharedDir := client.File.Create().SetName("team_dir").SetType(int(types.FileTypeFolder)).SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
|
||||||
|
nested := client.File.Create().SetName("nested.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(sharedDir).SaveX(ctx)
|
||||||
|
|
||||||
|
member := client.User.Create().SetEmail("member@example.com").SetNick("m").SetGroup(memberGroup).SaveX(ctx)
|
||||||
|
client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(member).SaveX(ctx)
|
||||||
|
|
||||||
|
// Attach the primary-group edge the navigator relies on.
|
||||||
|
member.Edges.Group = memberGroup
|
||||||
|
return member, owner, sharedDir, nested
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupFolderNavigator(t *testing.T, client *ent.Client, user *ent.User) (*sharedWithMeNavigator, hashid.Encoder) {
|
||||||
|
t.Helper()
|
||||||
|
hasher, err := hashid.New("group-folder-test-salt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
n := NewSharedWithMeNavigator(user, inventory.NewFileClient(client, conf.SQLiteDB, hasher),
|
||||||
|
inventory.NewAclClient(client, conf.SQLiteDB),
|
||||||
|
logging.NewConsoleLogger(logging.LevelError), &setting.DBFS{MaxPageSize: 50}, hasher)
|
||||||
|
return n.(*sharedWithMeNavigator), hasher
|
||||||
|
}
|
||||||
|
|
||||||
|
func aclGrant(t *testing.T, client *ent.Client, fileID int, st aclentry.SubjectType, sid int, perms ...types.AclPermission) {
|
||||||
|
t.Helper()
|
||||||
|
bs := &boolset.BooleanSet{}
|
||||||
|
for _, p := range perms {
|
||||||
|
boolset.Set(int(p), true, bs)
|
||||||
|
}
|
||||||
|
client.AclEntry.Create().
|
||||||
|
SetFileID(fileID).SetSubjectType(st).SetSubjectID(sid).SetPermissions(bs).
|
||||||
|
SaveX(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharedWithMeURI(t *testing.T, parts ...string) *fs.URI {
|
||||||
|
t.Helper()
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://" + string(constants.FileSystemSharedWithMe))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return uri.Join(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeListsAclGrantedFolder(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, _, sharedDir, _ := groupFolderFixture(t, client)
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID, types.AclPermRead)
|
||||||
|
|
||||||
|
n, _ := groupFolderNavigator(t, client, member)
|
||||||
|
root, err := n.To(ctx, sharedWithMeURI(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Files, 1)
|
||||||
|
require.Equal(t, "team_dir", res.Files[0].Name())
|
||||||
|
|
||||||
|
// The entry carries the ACL-derived capability set and real owner.
|
||||||
|
require.NotNil(t, res.Files[0].Capabilities())
|
||||||
|
require.True(t, res.Files[0].Capabilities().Enabled(int(NavigatorCapabilityListChildren)))
|
||||||
|
require.False(t, res.Files[0].Capabilities().Enabled(int(NavigatorCapabilityUploadFile)))
|
||||||
|
require.Equal(t, sharedDir.OwnerID, res.Files[0].Owner().ID)
|
||||||
|
require.Equal(t, sharedWithMeURI(t, hashid.EncodeFileID(n.hasher, sharedDir.ID)).String(), res.Files[0].Uri(false).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeResolvesGroupFolderSubtree(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, owner, sharedDir, nested := groupFolderFixture(t, client)
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID,
|
||||||
|
types.AclPermRead, types.AclPermCreate)
|
||||||
|
|
||||||
|
n, hasher := groupFolderNavigator(t, client, member)
|
||||||
|
|
||||||
|
root, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, sharedDir.ID, root.Model.ID)
|
||||||
|
require.Equal(t, owner.ID, root.Owner().ID)
|
||||||
|
require.True(t, root.Capabilities().Enabled(int(NavigatorCapabilityUploadFile)))
|
||||||
|
require.True(t, root.Capabilities().Enabled(int(NavigatorCapabilityListChildren)))
|
||||||
|
require.False(t, root.Capabilities().Enabled(int(NavigatorCapabilityDeleteFile)))
|
||||||
|
|
||||||
|
// Deeper elements walk the owner's subtree.
|
||||||
|
child, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID), "nested.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, nested.ID, child.Model.ID)
|
||||||
|
require.True(t, child.Capabilities().Enabled(int(NavigatorCapabilityDownloadFile)))
|
||||||
|
|
||||||
|
// Children of the subtree list like a regular folder.
|
||||||
|
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Files, 1)
|
||||||
|
require.Equal(t, "nested.txt", res.Files[0].Name())
|
||||||
|
require.True(t, res.Files[0].Uri(false).String() == sharedWithMeURI(t,
|
||||||
|
hashid.EncodeFileID(hasher, sharedDir.ID), "nested.txt").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeDeniesUngranted(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, _, sharedDir, _ := groupFolderFixture(t, client)
|
||||||
|
n, hasher := groupFolderNavigator(t, client, member)
|
||||||
|
|
||||||
|
// No ACL at all.
|
||||||
|
_, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Grant without the read bit is not a browse grant.
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID, types.AclPermCreate)
|
||||||
|
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Grant to a different group does not leak.
|
||||||
|
other := client.Group.Create().SetName("other").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, other.ID, types.AclPermRead)
|
||||||
|
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Everyone-tier grants allow browsing but do not surface in the listing.
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeEveryone, 0, types.AclPermRead)
|
||||||
|
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
root, err := n.To(ctx, sharedWithMeURI(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, res.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeMembershipGrant(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, _, sharedDir, _ := groupFolderFixture(t, client)
|
||||||
|
|
||||||
|
// Grant lands on a group the user holds only through a membership.
|
||||||
|
teamGroup := client.Group.Create().SetName("team").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||||
|
m := client.GroupMembership.Create().SetUserID(member.ID).SetGroupID(teamGroup.ID).SaveX(ctx)
|
||||||
|
m.Edges.Group = teamGroup
|
||||||
|
member.Edges.Memberships = []*ent.GroupMembership{m}
|
||||||
|
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, teamGroup.ID, types.AclPermRead)
|
||||||
|
|
||||||
|
n, hasher := groupFolderNavigator(t, client, member)
|
||||||
|
root, err := n.To(ctx, sharedWithMeURI(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Files, 1)
|
||||||
|
|
||||||
|
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeSymbolicShortcutResolves(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, _, _, _ := groupFolderFixture(t, client)
|
||||||
|
|
||||||
|
// A saved share shortcut of the member's own.
|
||||||
|
shortcut := client.File.Create().SetName("saved_share").SetType(int(types.FileTypeFolder)).
|
||||||
|
SetOwner(member).SetIsSymbolic(true).SaveX(ctx)
|
||||||
|
|
||||||
|
n, hasher := groupFolderNavigator(t, client, member)
|
||||||
|
|
||||||
|
// No ACL on the owner's dir — resolution rides on ownership of the shortcut.
|
||||||
|
res, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, shortcut.ID)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, shortcut.ID, res.Model.ID)
|
||||||
|
require.Equal(t, member.ID, res.Owner().ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedWithMeUserSubjectGrant(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
member, _, sharedDir, nested := groupFolderFixture(t, client)
|
||||||
|
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeUser, member.ID, types.AclPermRead)
|
||||||
|
|
||||||
|
n, hasher := groupFolderNavigator(t, client, member)
|
||||||
|
root, err := n.To(ctx, sharedWithMeURI(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Files, 1)
|
||||||
|
|
||||||
|
file, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID), nested.Name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, nested.ID, file.Model.ID)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue