feat: add multi directories support (#335)

This commit is contained in:
Higanoneko
2026-07-12 09:51:10 +02:00
committed by GitHub
parent de2ac9d327
commit 7ea4cec229
9 changed files with 1076 additions and 36 deletions
+10
View File
@@ -107,6 +107,16 @@ behindProxy: false
# that is /data. # that is /data.
directory: /data directory: /data
# Alternatively, replace 'directory' with 'directories' to expose multiple
# directories as virtual root entries. This option is mutually exclusive with
# 'directory' in the same scope. Rules should include the virtual mount name,
# such as /media/public/access/.
# directories:
# - media: /data/media
# - /data/archive
# - name: backups
# path: /data/backups
# The default permissions for users. This is a case insensitive option. Possible # The default permissions for users. This is a case insensitive option. Possible
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple # permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# permissions. For example, to allow to read and create, set "RC". Default is "R". # permissions. For example, to allow to read and create, set "RC". Default is "R".
+174 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
@@ -23,6 +24,8 @@ const (
DefaultPrefix = "/" DefaultPrefix = "/"
) )
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
type Config struct { type Config struct {
UserPermissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Debug bool Debug bool
@@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg := &Config{} cfg := &Config{}
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
directoryMountsDecodeHook(),
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(), mapstructure.TextUnmarshallerHookFunc(),
@@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
return nil, err return nil, err
} }
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
// Cascade user settings // Cascade user settings
for i := range cfg.Users { for i := range cfg.Users {
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) { userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
if !v.IsSet(userDirectoryKey) {
cfg.Users[i].Directory = cfg.Directory cfg.Users[i].Directory = cfg.Directory
} }
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
if err != nil {
if errors.Is(err, errDirectoryConflict) {
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
}
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
}
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Permissions = cfg.Permissions cfg.Users[i].Permissions = cfg.Permissions
} }
@@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
return cfg, nil return cfg, nil
} }
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
if permissions.directoryExplicit && permissions.directoriesExplicit {
return errDirectoryConflict
}
switch {
case permissions.directoryExplicit:
permissions.Directory = v.GetString(directoryKey)
permissions.useDirectories = false
case permissions.directoriesExplicit:
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
if err != nil {
return err
}
permissions.Directories = directories
permissions.useDirectories = true
case inherited != nil:
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
permissions.useDirectories = inherited.useDirectories
}
return nil
}
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
if flags != nil && flags.Changed(key) {
return true
}
if v.InConfig(key) {
return true
}
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
value, ok := os.LookupEnv(envKey)
return ok && value != ""
}
func (c *Config) Validate() error { func (c *Config) Validate() error {
var err error var err error
@@ -188,6 +248,119 @@ func (c *Config) Validate() error {
return nil return nil
} }
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
mountsType := reflect.TypeOf(DirectoryMounts{})
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != mountsType {
return data, nil
}
return decodeDirectoryMounts(data)
}
}
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
value := v.Get(key)
if value == nil {
return fallback, nil
}
return decodeDirectoryMounts(value)
}
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
switch value := data.(type) {
case nil:
return DirectoryMounts{}, nil
case DirectoryMounts:
return value, nil
case []DirectoryMount:
return DirectoryMounts(value), nil
case string:
if value == "" {
return DirectoryMounts{}, nil
}
parts := strings.Split(value, ",")
mounts := make(DirectoryMounts, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
mounts = append(mounts, DirectoryMount{Path: part})
}
return mounts, nil
case []any:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mount, err := decodeDirectoryMount(item)
if err != nil {
return nil, err
}
mounts = append(mounts, mount)
}
return mounts, nil
case []string:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mounts = append(mounts, DirectoryMount{Path: item})
}
return mounts, nil
default:
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
}
}
func decodeDirectoryMount(data any) (DirectoryMount, error) {
switch value := data.(type) {
case string:
return DirectoryMount{Path: value}, nil
case map[string]any:
return decodeDirectoryMountMap(value)
case map[any]any:
m := map[string]any{}
for key, value := range value {
keyString, ok := key.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
}
m[keyString] = value
}
return decodeDirectoryMountMap(m)
default:
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
}
}
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
_, hasName := data["name"]
_, hasPath := data["path"]
if hasName || hasPath {
name, nameOK := data["name"].(string)
path, pathOK := data["path"].(string)
if !nameOK || !pathOK || len(data) != 2 {
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
}
return DirectoryMount{Name: name, Path: path}, nil
}
if len(data) != 1 {
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
}
for name, path := range data {
pathString, ok := path.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
}
return DirectoryMount{Name: name, Path: pathString}, nil
}
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
}
func (cfg *Config) GetLogger() (*zap.Logger, error) { func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig() loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true loggerConfig.DisableCaller = true
+177
View File
@@ -3,6 +3,7 @@ package lib
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -165,6 +166,182 @@ rules = []
}) })
} }
func TestConfigDirectories(t *testing.T) {
t.Parallel()
t.Run("Mixed Entries", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- `+dirC+`
- d2: `+dirD+`
- name: archive
path: `+dirE+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("JSON", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `{
"directories": [
`+strconv.Quote(dirC)+`,
{ "d2": `+strconv.Quote(dirD)+` },
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
]
}`, ".json")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("TOML", func(t *testing.T) {
t.Parallel()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
[[directories]]
d2 = `+strconv.Quote(dirD)+`
[[directories]]
name = "archive"
path = `+strconv.Quote(dirE)+`
`, ".toml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, "d2", cfg.Directories[0].Name)
require.Equal(t, dirD, cfg.Directories[0].Path)
require.Equal(t, "archive", cfg.Directories[1].Name)
require.Equal(t, dirE, cfg.Directories[1].Path)
})
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
directory: /tmp
directories:
- /tmp
`, ".yml", "directory and directories cannot both be defined")
})
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
users:
- username: basic
password: basic
directory: /tmp
directories:
- /tmp
`, ".yml", "cannot define both directory and directories")
})
t.Run("Duplicate Mount Names", func(t *testing.T) {
t.Parallel()
parent := t.TempDir()
dir := filepath.Join(parent, "dup")
require.NoError(t, os.Mkdir(dir, 0775))
writeAndParseConfigWithError(t, `
directories:
- `+dir+`
- dup: /tmp
`, ".yml", "duplicate mount name")
})
t.Run("Cascade Mode", func(t *testing.T) {
t.Parallel()
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- global: `+global+`
users:
- username: inherited
password: inherited
- username: single
password: single
directory: `+single+`
- username: multi
password: multi
directories:
- owned: `+userMulti+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
})
}
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
t.Setenv("WD_DIRECTORIES", global)
t.Setenv("WD_USERS_1_DIRECTORY", single)
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
cfg := writeAndParseConfig(t, `
users:
- username: inherited
password: inherited
- username: single
password: single
- username: multi
password: multi
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
}
func TestConfigKeys(t *testing.T) { func TestConfigKeys(t *testing.T) {
t.Parallel() t.Parallel()
+29 -27
View File
@@ -34,21 +34,8 @@ func NewHandler(c *Config) (http.Handler, error) {
noPassword: c.NoPassword, noPassword: c.NoPassword,
behindProxy: c.BehindProxy, behindProxy: c.BehindProxy,
user: &handlerUser{ user: &handlerUser{
User: User{ User: User{UserPermissions: c.UserPermissions},
UserPermissions: c.UserPermissions, Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
},
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(c.Directory),
noSniff: c.NoSniff,
},
LockSystem: &lockSystem{
LockSystem: ls,
directory: c.Directory,
},
Logger: logFunc,
},
}, },
users: map[string]*handlerUser{}, users: map[string]*handlerUser{},
} }
@@ -56,18 +43,7 @@ func NewHandler(c *Config) (http.Handler, error) {
for _, u := range c.Users { for _, u := range c.Users {
h.users[u.Username] = &handlerUser{ h.users[u.Username] = &handlerUser{
User: u, User: u,
Handler: webdav.Handler{ Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(u.Directory),
noSniff: c.NoSniff,
},
LockSystem: &lockSystem{
LockSystem: ls,
directory: u.Directory,
},
Logger: logFunc,
},
} }
} }
@@ -93,6 +69,32 @@ func NewHandler(c *Config) (http.Handler, error) {
return h, nil return h, nil
} }
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
// selecting between single-directory and multi-directory backing depending on
// whether directories are configured.
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
h := webdav.Handler{
Prefix: prefix,
Logger: logFunc,
}
if p.useDirectories {
h.FileSystem = multiDir{
mounts: p.Directories,
noSniff: noSniff,
}
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
} else {
h.FileSystem = Dir{
Dir: webdav.Dir(p.Directory),
noSniff: noSniff,
}
h.LockSystem = newLockSystem(ls, p.Directory)
}
return h
}
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. // ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.user user := h.user
+123
View File
@@ -820,6 +820,129 @@ users:
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
} }
func TestServerMultiDirectories(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"folder/nested.txt": []byte("nested"),
"public/access/ok.txt": []byte("ok"),
})
dirD := makeTestDirectory(t, map[string][]byte{
"bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- c: %s
- d: %s
`, dirC, dirD))
client := gowebdav.NewClient(srv.URL, "", "")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
require.Equal(t, "c", files[0].Name())
require.Equal(t, "d", files[1].Name())
data, err := client.Read("/c/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
data, err = client.Read("/d/bar.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
err = client.Copy("/c/foo.txt", "/d/copied.txt", false)
require.NoError(t, err)
data, err = os.ReadFile(filepath.Join(dirD, "copied.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
err = client.Rename("/c/foo.txt", "/d/moved.txt", false)
require.NoError(t, err)
require.NoFileExists(t, filepath.Join(dirC, "foo.txt"))
data, err = os.ReadFile(filepath.Join(dirD, "moved.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
err = client.Rename("/d/bar.txt", "/d/renamed.txt", false)
require.NoError(t, err)
require.NoFileExists(t, filepath.Join(dirD, "bar.txt"))
data, err = os.ReadFile(filepath.Join(dirD, "renamed.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
err = client.Rename("/c/folder", "/d/folder", false)
require.NoError(t, err)
require.NoDirExists(t, filepath.Join(dirC, "folder"))
data, err = os.ReadFile(filepath.Join(dirD, "folder", "nested.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("nested"), data)
require.ErrorContains(t, client.Remove("/c"), "405")
require.Error(t, client.Write("/c", []byte("blocked"), 0666))
require.ErrorContains(t, client.Rename("/d", "/c/d", false), "403")
}
func TestServerMultiDirectoriesRules(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"public/access/ok.txt": []byte("ok"),
})
dirD := makeTestDirectory(t, map[string][]byte{
"public/access/no.txt": []byte("no"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: none
directories:
- c: %s
- d: %s
rules:
- path: /c/public/access/
permissions: R
`, dirC, dirD))
client := gowebdav.NewClient(srv.URL, "", "")
data, err := client.Read("/c/public/access/ok.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("ok"), data)
_, err = client.Read("/d/public/access/no.txt")
require.ErrorContains(t, err, "403")
_, err = client.Read("/public/access/ok.txt")
require.ErrorContains(t, err, "403")
}
func TestServerMultiDirectoriesPrefix(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: R
prefix: /prefix
directories:
- c: %s
`, dirC))
client := gowebdav.NewClient(srv.URL, "", "")
files, err := client.ReadDir("/prefix")
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, "c", files[0].Name())
data, err := client.Read("/prefix/c/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
}
func TestServerPermissions(t *testing.T) { func TestServerPermissions(t *testing.T) {
t.Parallel() t.Parallel()
+48 -7
View File
@@ -9,27 +9,68 @@ import (
var _ webdav.LockSystem = &lockSystem{} var _ webdav.LockSystem = &lockSystem{}
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing // lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
// to reuse the same [webdav.LockSystem] for multiple users with different base // real backing paths via resolve. This allows reusing the same
// directories, meaning we can correctly lock the files across different users. // [webdav.LockSystem] for multiple users with different base directories,
// meaning we can correctly lock the files across different users.
type lockSystem struct { type lockSystem struct {
webdav.LockSystem webdav.LockSystem
directory string resolve func(name string) (string, error)
}
// newLockSystem returns a lockSystem for a single-directory user, resolving
// names relative to directory.
func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
return filepath.Join(directory, name), nil
},
}
}
// newMultiDirLockSystem returns a lockSystem for a multi-directory user,
// resolving names against the real backing path of each mount.
func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
if cleanName(name) == "/" {
return "/", nil
}
mount, rest, err := multiDir{mounts: mounts}.resolve(name)
if err != nil {
return "", err
}
return mount.filePath(rest), nil
},
}
} }
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) { func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
if name0 != "" { if name0 != "" {
name0 = filepath.Join(l.directory, name0) name0, err = l.resolve(name0)
if err != nil {
return nil, err
}
} }
if name1 != "" { if name1 != "" {
name1 = filepath.Join(l.directory, name1) name1, err = l.resolve(name1)
if err != nil {
return nil, err
}
} }
return l.LockSystem.Confirm(now, name0, name1, conditions...) return l.LockSystem.Confirm(now, name0, name1, conditions...)
} }
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) { func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
details.Root = filepath.Join(l.directory, details.Root) details.Root, err = l.resolve(details.Root)
if err != nil {
return "", err
}
return l.LockSystem.Create(now, details) return l.LockSystem.Create(now, details)
} }
+384
View File
@@ -0,0 +1,384 @@
package lib
import (
"context"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/net/webdav"
)
var _ webdav.FileSystem = multiDir{}
const windowsErrorNotSameDevice = syscall.Errno(17)
type multiDir struct {
mounts DirectoryMounts
noSniff bool
}
func (m multiDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrExist
}
return mount.dir(m.noSniff).Mkdir(ctx, rest, perm)
}
func (m multiDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if cleanName(name) == "/" {
if writeFlag(flag) {
return nil, os.ErrPermission
}
return &multiDirRootFile{
entries: m.rootEntries(ctx),
info: virtualDirInfo{name: "/"},
}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
if rest == "/" && writeFlag(flag) {
return nil, os.ErrPermission
}
file, err := mount.dir(m.noSniff).OpenFile(ctx, rest, flag, perm)
if err != nil {
return nil, err
}
if rest == "/" {
return mountRootFile{File: file, name: mount.Name}, nil
}
return file, nil
}
func (m multiDir) RemoveAll(ctx context.Context, name string) error {
if cleanName(name) == "/" {
return os.ErrInvalid
}
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrInvalid
}
return mount.dir(m.noSniff).RemoveAll(ctx, rest)
}
func (m multiDir) Rename(ctx context.Context, oldName, newName string) error {
oldMount, oldRest, err := m.resolve(oldName)
if err != nil {
return err
}
newMount, newRest, err := m.resolve(newName)
if err != nil {
return err
}
if oldRest == "/" || newRest == "/" {
return os.ErrInvalid
}
if oldMount.Name == newMount.Name {
return oldMount.dir(m.noSniff).Rename(ctx, oldRest, newRest)
}
oldPath := oldMount.filePath(oldRest)
newPath := newMount.filePath(newRest)
if err := os.Rename(oldPath, newPath); err != nil {
if isCrossDeviceError(err) {
return renameAcrossMount(oldPath, newPath)
}
return err
}
return nil
}
func renameAcrossMount(oldPath, newPath string) error {
info, err := os.Lstat(oldPath)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(oldPath)
if err != nil {
return err
}
if err := os.Symlink(target, newPath); err != nil {
return err
}
return os.Remove(oldPath)
}
if info.Mode().IsRegular() {
if err := copyRegularFile(oldPath, newPath); err != nil {
return err
}
} else {
source, err := fs.Sub(os.DirFS(filepath.Dir(oldPath)), filepath.Base(oldPath))
if err != nil {
return err
}
if err := os.CopyFS(newPath, source); err != nil {
_ = os.RemoveAll(newPath)
return err
}
}
if err := copyMetadata(oldPath, newPath); err != nil {
_ = os.RemoveAll(newPath)
return err
}
return os.RemoveAll(oldPath)
}
func copyRegularFile(oldPath, newPath string) error {
source, err := os.Open(oldPath)
if err != nil {
return err
}
target, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
_ = source.Close()
return err
}
_, copyErr := io.Copy(target, source)
copyErr = errors.Join(copyErr, target.Close(), source.Close())
if copyErr != nil {
_ = os.Remove(newPath)
return copyErr
}
return nil
}
func copyMetadata(oldPath, newPath string) error {
return filepath.Walk(oldPath, func(name string, info os.FileInfo, err error) error {
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return err
}
rel, err := filepath.Rel(oldPath, name)
if err != nil {
return err
}
newName := filepath.Join(newPath, rel)
if err := os.Chmod(newName, info.Mode().Perm()); err != nil {
return err
}
return os.Chtimes(newName, info.ModTime(), info.ModTime())
})
}
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV) || runtime.GOOS == "windows" && errors.Is(err, windowsErrorNotSameDevice)
}
func (m multiDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
if cleanName(name) == "/" {
return virtualDirInfo{name: "/"}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
info, err := mount.dir(m.noSniff).Stat(ctx, rest)
if err != nil {
return nil, err
}
if rest == "/" {
return namedFileInfo{FileInfo: info, name: mount.Name}, nil
}
return info, nil
}
func (m multiDir) resolve(name string) (DirectoryMount, string, error) {
name = cleanName(name)
if name == "/" {
return DirectoryMount{}, "", os.ErrInvalid
}
trimmed := strings.TrimPrefix(name, "/")
mountName, rest, _ := strings.Cut(trimmed, "/")
for _, mount := range m.mounts {
if mount.Name == mountName {
if rest == "" {
return mount, "/", nil
}
return mount, "/" + rest, nil
}
}
return DirectoryMount{}, "", os.ErrNotExist
}
func (m multiDir) rootEntries(ctx context.Context) []os.FileInfo {
entries := make([]os.FileInfo, 0, len(m.mounts))
for _, mount := range m.mounts {
info, err := mount.dir(m.noSniff).Stat(ctx, "/")
if err != nil {
entries = append(entries, virtualDirInfo{name: mount.Name})
continue
}
entries = append(entries, namedFileInfo{FileInfo: info, name: mount.Name})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
return entries
}
func (d DirectoryMount) dir(noSniff bool) Dir {
return Dir{
Dir: webdav.Dir(d.Path),
noSniff: noSniff,
}
}
func (d DirectoryMount) filePath(name string) string {
return filepath.Join(d.Path, filepath.FromSlash(strings.TrimPrefix(name, "/")))
}
func cleanName(name string) string {
if name == "" || !strings.HasPrefix(name, "/") {
name = "/" + name
}
return path.Clean(name)
}
func writeFlag(flag int) bool {
return flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0
}
type multiDirRootFile struct {
entries []os.FileInfo
info os.FileInfo
offset int
}
func (f *multiDirRootFile) Close() error {
return nil
}
func (f *multiDirRootFile) Read([]byte) (int, error) {
return 0, io.EOF
}
func (f *multiDirRootFile) Seek(offset int64, whence int) (int64, error) {
var next int64
switch whence {
case io.SeekStart:
next = offset
case io.SeekCurrent:
next = int64(f.offset) + offset
case io.SeekEnd:
next = int64(len(f.entries)) + offset
default:
return 0, os.ErrInvalid
}
if next < 0 {
return 0, os.ErrInvalid
}
f.offset = int(next)
return next, nil
}
func (f *multiDirRootFile) Readdir(count int) ([]os.FileInfo, error) {
if count <= 0 {
entries := f.entries[f.offset:]
f.offset = len(f.entries)
return entries, nil
}
if f.offset >= len(f.entries) {
return nil, io.EOF
}
end := f.offset + count
if end > len(f.entries) {
end = len(f.entries)
}
entries := f.entries[f.offset:end]
f.offset = end
return entries, nil
}
func (f *multiDirRootFile) Stat() (os.FileInfo, error) {
return f.info, nil
}
func (f *multiDirRootFile) Write([]byte) (int, error) {
return 0, os.ErrPermission
}
type mountRootFile struct {
webdav.File
name string
}
func (f mountRootFile) Stat() (os.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return namedFileInfo{FileInfo: info, name: f.name}, nil
}
type namedFileInfo struct {
os.FileInfo
name string
}
func (i namedFileInfo) Name() string {
return i.name
}
type virtualDirInfo struct {
name string
}
func (i virtualDirInfo) Name() string {
return i.name
}
func (i virtualDirInfo) Size() int64 {
return 0
}
func (i virtualDirInfo) Mode() os.FileMode {
return os.ModeDir | 0555
}
func (i virtualDirInfo) ModTime() time.Time {
return time.Time{}
}
func (i virtualDirInfo) IsDir() bool {
return true
}
func (i virtualDirInfo) Sys() any {
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package lib
import (
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRenameAcrossMount(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("cross mount"),
"folder/empty": nil,
"folder/nested/file.txt": []byte("nested"),
})
target := t.TempDir()
sourceFile := filepath.Join(source, "file.txt")
modTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, os.Chmod(sourceFile, 0600))
require.NoError(t, os.Chtimes(sourceFile, modTime, modTime))
require.NoError(t, renameAcrossMount(sourceFile, filepath.Join(target, "file.txt")))
require.NoFileExists(t, filepath.Join(source, "file.txt"))
data, err := os.ReadFile(filepath.Join(target, "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("cross mount"), data)
info, err := os.Stat(filepath.Join(target, "file.txt"))
require.NoError(t, err)
if runtime.GOOS != "windows" {
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
require.WithinDuration(t, modTime, info.ModTime(), time.Second)
require.NoError(t, renameAcrossMount(filepath.Join(source, "folder"), filepath.Join(target, "folder")))
require.NoDirExists(t, filepath.Join(source, "folder"))
require.DirExists(t, filepath.Join(target, "folder", "empty"))
data, err = os.ReadFile(filepath.Join(target, "folder", "nested", "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("nested"), data)
}
func TestRenameAcrossMountPreservesSymlink(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("target"),
})
oldPath := filepath.Join(source, "link.txt")
if err := os.Symlink("file.txt", oldPath); err != nil {
t.Skipf("symbolic links are unavailable: %v", err)
}
newPath := filepath.Join(t.TempDir(), "link.txt")
require.NoError(t, renameAcrossMount(oldPath, newPath))
info, err := os.Lstat(newPath)
require.NoError(t, err)
require.NotZero(t, info.Mode()&os.ModeSymlink)
target, err := os.Readlink(newPath)
require.NoError(t, err)
require.Equal(t, "file.txt", target)
}
func TestIsCrossDeviceError(t *testing.T) {
err := syscall.EXDEV
if runtime.GOOS == "windows" {
err = windowsErrorNotSameDevice
}
require.True(t, isCrossDeviceError(err))
require.False(t, isCrossDeviceError(os.ErrPermission))
}
+58
View File
@@ -44,11 +44,23 @@ const (
type UserPermissions struct { type UserPermissions struct {
Directory string Directory string
Directories DirectoryMounts
Permissions Permissions Permissions Permissions
Rules []*Rule Rules []*Rule
RulesBehavior RulesBehavior RulesBehavior RulesBehavior
directoryExplicit bool
directoriesExplicit bool
useDirectories bool
} }
type DirectoryMount struct {
Name string
Path string
}
type DirectoryMounts []DirectoryMount
// Allowed checks if the user has permission to access a directory/file // Allowed checks if the user has permission to access a directory/file
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool { func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
// For COPY and MOVE requests, we first check the permissions for the destination // For COPY and MOVE requests, we first check the permissions for the destination
@@ -94,6 +106,12 @@ func (p *UserPermissions) Validate() error {
return fmt.Errorf("invalid permissions: %w", err) return fmt.Errorf("invalid permissions: %w", err)
} }
if p.useDirectories || len(p.Directories) > 0 {
if err := (&p.Directories).Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
}
for _, r := range p.Rules { for _, r := range p.Rules {
if err := r.Validate(); err != nil { if err := r.Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err) return fmt.Errorf("invalid permissions: %w", err)
@@ -110,6 +128,46 @@ func (p *UserPermissions) Validate() error {
return nil return nil
} }
func (d *DirectoryMounts) Validate() error {
names := map[string]struct{}{}
for i := range *d {
mount := &(*d)[i]
if mount.Path == "" {
return errors.New("invalid directories: path must be defined")
}
path, err := filepath.Abs(mount.Path)
if err != nil {
return fmt.Errorf("invalid directories: %w", err)
}
mount.Path = path
if mount.Name == "" {
mount.Name = filepath.Base(path)
}
if !validDirectoryMountName(mount.Name) {
return fmt.Errorf("invalid directories: invalid mount name %q", mount.Name)
}
if _, ok := names[mount.Name]; ok {
return fmt.Errorf("invalid directories: duplicate mount name %q", mount.Name)
}
names[mount.Name] = struct{}{}
}
return nil
}
func validDirectoryMountName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
return !strings.ContainsAny(name, `/\`)
}
type Permissions struct { type Permissions struct {
Create bool Create bool
Read bool Read bool