Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
- 'script-drives/**'
fs:
- 'drive/fs/**'
- 'drive/sftp/**'
web:
- 'web/**'
docs:
Expand All @@ -70,7 +71,7 @@ jobs:
run: go test -v ./...

fs-platform-test:
name: Local fs tests (${{ matrix.os }})
name: Filesystem and SFTP tests (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.fs == 'true'
strategy:
Expand All @@ -89,8 +90,8 @@ jobs:
with:
go-version-file: go.mod

- name: Run local fs tests
run: go test -v ./drive/fs
- name: Run filesystem and SFTP tests
run: go test -v ./drive/fs ./drive/sftp

go-lint:
name: Go lint
Expand Down
52 changes: 52 additions & 0 deletions common/driveutil/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,58 @@ func (w *BoundDriveFS) Rename(ctx context.Context, oldName, newName string) erro
return mapError(e)
}

// Move keeps source authorization and replacement in one drive operation. In
// particular, the WebDAV handler must not remove the destination beforehand.
func (w *BoundDriveFS) Move(ctx context.Context, fromPath, toPath string, override bool) error {
fromPath, toPath = utils.CleanPath(fromPath), utils.CleanPath(toPath)
if fromPath == toPath || utils.IsPathParent(toPath, fromPath) {
return os.ErrPermission
}
from, e := w.drive.Get(ctx, utils.CleanPath(fromPath))
if e != nil {
return mapError(e)
}
plan, e := PrepareWrite(ctx, w.drive, utils.CleanPath(toPath), WriteModeForOverride(override))
if e != nil {
return mapError(e)
}
_, e = w.drive.Move(task.NewContextWrapper(ctx), from, plan.Path, plan.Override)
return mapError(e)
}

// Copy uses the drive's subtree authorization before any destination changes.
// A Depth: 0 directory copy reads only the source directory itself.
func (w *BoundDriveFS) Copy(ctx context.Context, fromPath, toPath string, override, recursive bool) error {
fromPath, toPath = utils.CleanPath(fromPath), utils.CleanPath(toPath)
if fromPath == toPath || utils.IsPathParent(toPath, fromPath) {
return os.ErrPermission
}
from, e := w.drive.Get(ctx, fromPath)
if e != nil {
return mapError(e)
}
plan, e := PrepareWrite(ctx, w.drive, toPath, WriteModeForOverride(override))
if e != nil {
return mapError(e)
}
if recursive || from.Type().IsFile() {
_, e = w.drive.Copy(task.NewContextWrapper(ctx), from, plan.Path, plan.Override)
return mapError(e)
}
if _, e := w.drive.Get(ctx, plan.Path); e == nil {
if !override {
return os.ErrExist
}
if e := w.drive.Delete(task.NewContextWrapper(ctx), plan.Path); e != nil {
return mapError(e)
}
} else if !err.IsNotFoundError(e) {
return mapError(e)
}
_, e = w.drive.MakeDir(ctx, plan.Path)
return mapError(e)
}

func (w *BoundDriveFS) newDriveFSFile(ctx context.Context, e types.IEntry, flag int) *driveFSFile {
var seekPos int64 = 0
if flag&os.O_APPEND != 0 && flag&os.O_TRUNC == 0 {
Expand Down
65 changes: 65 additions & 0 deletions common/driveutil/write.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package driveutil

import (
"context"
err "go-drive/common/errors"
"go-drive/common/i18n"
"go-drive/common/types"
"go-drive/common/utils"
"slices"
)

// PrepareWrite preserves wrapper-specific authorization and path translation.
// Plain storage drives use the same read-only inspection without user policy.
func PrepareWrite(ctx context.Context, drive types.IDrive, path string, mode types.WriteMode) (*types.WritePlan, error) {
if p, ok := drive.(types.WritePreparer); ok {
return p.PrepareWrite(ctx, path, mode)
}
return InspectWrite(ctx, drive, path, mode)
}

// InspectWrite selects a destination and lists missing parents without writing.
// Callers must execute the returned path exactly; a later conflict is an error.
func InspectWrite(ctx context.Context, drive types.IDrive, path string, mode types.WriteMode) (*types.WritePlan, error) {
path = utils.CleanPath(path)
if utils.IsRootPath(path) {
return nil, err.NewNotAllowedError()
}
if mode == types.WriteRename {
var e error
path, e = FindNonExistsEntryName(ctx, drive, path)
if e != nil {
return nil, e
}
} else if mode != types.WriteReject && mode != types.WriteOverwrite {
return nil, err.NewNotAllowedError()
}
if mode != types.WriteOverwrite {
if _, e := RequireFileNotExists(ctx, drive, path); e != nil {
return nil, e
}
}
plan := &types.WritePlan{Path: path, Override: mode == types.WriteOverwrite}
for parent := utils.PathParent(path); !utils.IsRootPath(parent); parent = utils.PathParent(parent) {
entry, e := drive.Get(ctx, parent)
if e == nil {
if !entry.Type().IsDir() {
return nil, err.NewNotAllowedMessageError(i18n.T("drive.file_exists"))
}
break
}
if !err.IsNotFoundError(e) {
return nil, e
}
plan.Parents = append(plan.Parents, parent)
}
slices.Reverse(plan.Parents)
return plan, nil
}

func WriteModeForOverride(override bool) types.WriteMode {
if override {
return types.WriteOverwrite
}
return types.WriteReject
}
26 changes: 24 additions & 2 deletions common/types/drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ type DriveMeta struct {
Props M
}

// WriteMode controls conflicts while preparing a destination. Execution never
// allocates another name: override=false always rejects an existing target.
type WriteMode uint8

const (
WriteReject WriteMode = iota
WriteRename
WriteOverwrite
)

// WritePlan describes a read-only destination inspection in the caller's
// virtual namespace. It does not reserve the name or create parent directories.
type WritePlan struct {
Path string
Parents []string
Override bool
}

type WritePreparer interface {
PrepareWrite(context.Context, string, WriteMode) (*WritePlan, error)
}

// IDrive is the main API. It's the abstraction of drives.
// The path argument in all public API always is valid path that not starts with slash,
// and the root path is always empty string. Implementations can consider the path to be absolute.
Expand All @@ -112,11 +134,11 @@ type IDrive interface {
// If the underlying drive does not support copying, Copy can return err.NewUnsupportedError,
// then dispatcher drive(see dispatcher.go) will do the copying job.
//
// If override is false, the entry with the same path encountered during the copy process should be skipped.
// If override is false, an existing destination is rejected. No name is allocated.
Copy(ctx TaskCtx, from IEntry, to string, override bool) (IEntry, error)
// Move moves 'from' to 'to'.
//
// If override is false, the entry with the same path encountered during the copy process should be skipped.
// If override is false, an existing destination is rejected. No name is allocated.
Move(ctx TaskCtx, from IEntry, to string, override bool) (IEntry, error)
// List gets all children of the path.
List(ctx context.Context, path string) ([]IEntry, error)
Expand Down
32 changes: 22 additions & 10 deletions common/utils/permission_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,36 @@ func (pm PermMap) ResolvePath(path string) types.Permission {
return resolveAcceptedPermissions(items)
}

// ResolveDescendant resolves permissions of the path's descendant
// ResolveDescendant returns permissions shared by all configured paths in the subtree,
// after resolving inheritance and subject precedence independently for each path.
func (pm PermMap) ResolveDescendant(path string) (types.Permission, bool) {
result := make([]*pathPermItem, 0)
for _, p := range pm {
node, _ := p.Get(path)
var result types.Permission
defined := false
seen := make(map[string]struct{})
for _, tree := range pm {
node, _ := tree.Get(path)
if node == nil {
continue
}
node.Visit(func(n *PathTreeNode[*pathPermItem]) {
if n.Data != nil {
result = append(result, n.Data)
if n.Data == nil {
return
}
rulePath := *n.Data.Path
if _, ok := seen[rulePath]; ok {
return
}
seen[rulePath] = struct{}{}
resolved := pm.ResolvePath(rulePath)
if !defined {
result = resolved
defined = true
} else {
result &= resolved
}
})
}
if len(result) == 0 {
return 0, false
}
return resolveAcceptedPermissions(result), true
return result, defined
}

type pathPermItem struct {
Expand Down
92 changes: 86 additions & 6 deletions common/utils/permission_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,12 @@ func TestPermMap_ResolveDescendant(t *testing.T) {
if !defined {
t.Fatal("ResolveDescendant(): want defined=true")
}
// Merges all descendants: at least read, and a has write.
// The root is read-only even though a descendant grants write.
if !perm.Readable() {
t.Errorf("root descendant: want readable, got %v", perm)
}
if !perm.Writable() {
t.Errorf("root descendant: want writable from a, got %v", perm)
if perm.Writable() {
t.Errorf("root descendant: want read-only intersection, got %v", perm)
}
})

Expand All @@ -253,6 +253,86 @@ func TestPermMap_ResolveDescendant(t *testing.T) {
})
}

func TestPermMap_ResolveDescendantEffectivePermissions(t *testing.T) {
root, folder, locked, public := "", "folder", "folder/locked", "folder/public/deeper"
user, group := types.UserSubject("alice"), types.GroupSubject("users")
tests := []struct {
name string
rules []types.PathPermission
want types.Permission
}{
{
name: "deeper sibling grant cannot hide rejection",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
{Path: &public, Subject: group, Permission: types.PermissionReadWrite, Policy: types.PolicyAccept},
},
want: types.PermissionRead,
},
{
name: "child rejection preserves inherited read",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
},
want: types.PermissionRead,
},
{
name: "user grant overrides group rejection at same path",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
{Path: &locked, Subject: user, Permission: types.PermissionWrite, Policy: types.PolicyAccept},
},
want: types.PermissionReadWrite,
},
{
name: "user grant on sibling cannot override group rejection",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
{Path: &public, Subject: user, Permission: types.PermissionWrite, Policy: types.PolicyAccept},
},
want: types.PermissionRead,
},
{
name: "descendant grant cannot hide restricted ancestor",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
{Path: ptr("folder/locked/open"), Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyAccept},
},
want: types.PermissionRead,
},
{
name: "unrelated restriction is outside subtree",
rules: []types.PathPermission{
{Path: ptr("elsewhere"), Subject: group, Permission: types.PermissionReadWrite, Policy: types.PolicyReject},
{Path: &locked, Subject: group, Permission: types.PermissionRead, Policy: types.PolicyAccept},
},
want: types.PermissionReadWrite,
},
{
name: "read and write restricted on separate branches",
rules: []types.PathPermission{
{Path: &locked, Subject: group, Permission: types.PermissionWrite, Policy: types.PolicyReject},
{Path: &public, Subject: group, Permission: types.PermissionRead, Policy: types.PolicyReject},
},
want: types.PermissionEmpty,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rules := append([]types.PathPermission{
{Path: &root, Subject: group, Permission: types.PermissionReadWrite, Policy: types.PolicyAccept},
}, tt.rules...)
pm := NewPermMap(rules).Filter(types.Principal{User: types.User{
Username: "alice", Groups: []types.Group{{Name: "users"}},
}})
got, defined := pm.ResolveDescendant(folder)
if !defined || got != tt.want {
t.Fatalf("ResolveDescendant(%q) = %v, %v; want %v, true", folder, got, defined, tt.want)
}
})
}
}

func newPermItem(path, subject string, perm types.Permission, policy uint8) *pathPermItem {
p := path
return &pathPermItem{
Expand Down Expand Up @@ -453,8 +533,8 @@ func TestPermMap_FilterThenResolve(t *testing.T) {
if !defined {
t.Fatal("driveA should have descendants")
}
// docs has write, so merged descendant should have write.
if !dp.Writable() {
t.Errorf("alice ResolveDescendant(driveA): want writable, got %v", dp)
// docs grants write, but driveA itself remains read-only.
if dp.Writable() {
t.Errorf("alice ResolveDescendant(driveA): want read-only intersection, got %v", dp)
}
}
1 change: 1 addition & 0 deletions docs/lang/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ drive:
label: CacheTTL
description: Cache time to live, if omitted, no cache. Valid time units are 'ms', 's', 'm', 'h'.
invalid_root_path: "Root path must starts with '/'"
overwrite_move_not_supported: The SFTP server must support posix-rename@openssh.com version 1 for overwrite moves
script:
name: Script
invalid_pool_config: "Invalid Pool configuration: {{ 1 }}"
Expand Down
1 change: 1 addition & 0 deletions docs/lang/ko-KR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ drive:
label: 캐시 TTL
description: "캐시 유지 시간이며, 생략하면 캐시를 사용하지 않습니다. 사용 가능한 시간 단위는 'ms', 's', 'm', 'h'입니다."
invalid_root_path: "루트 경로는 '/'로 시작해야 합니다"
overwrite_move_not_supported: 덮어쓰기 이동에는 SFTP 서버의 posix-rename@openssh.com 버전 1 지원이 필요합니다
script:
name: 스크립트
invalid_pool_config: "잘못된 Pool 설정: {{ 1 }}"
Expand Down
1 change: 1 addition & 0 deletions docs/lang/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ drive:
label: 缓存生命周期
description: 有效单位为 'ms', 's', 'm', 'h', 如果省略则没有缓存
invalid_root_path: "根路径必须以 '/' 开头"
overwrite_move_not_supported: SFTP 服务端必须支持 posix-rename@openssh.com 版本 1 才能覆盖移动
script:
name: 脚本
invalid_pool_config: "无效的 Pool 配置: {{ 1 }}"
Expand Down
Loading
Loading