From 7ea4cec22952acc8ad17acd4ef7cb8ee11d281ec Mon Sep 17 00:00:00 2001 From: Higanoneko <50688679+Higanoneko@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:51:10 +0800 Subject: [PATCH] feat: add multi directories support (#335) --- README.md | 10 ++ lib/config.go | 175 +++++++++++++++++++- lib/config_test.go | 177 ++++++++++++++++++++ lib/handler.go | 58 +++---- lib/handler_test.go | 123 ++++++++++++++ lib/locksystem.go | 55 ++++++- lib/multidir.go | 384 +++++++++++++++++++++++++++++++++++++++++++ lib/multidir_test.go | 72 ++++++++ lib/permissions.go | 58 +++++++ 9 files changed, 1076 insertions(+), 36 deletions(-) create mode 100644 lib/multidir.go create mode 100644 lib/multidir_test.go diff --git a/README.md b/README.md index b2fd88d..433dd2c 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,16 @@ behindProxy: false # that is /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 # 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". diff --git a/lib/config.go b/lib/config.go index f3b63fe..3761652 100644 --- a/lib/config.go +++ b/lib/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "github.com/go-viper/mapstructure/v2" @@ -23,6 +24,8 @@ const ( DefaultPrefix = "/" ) +var errDirectoryConflict = errors.New("directory and directories cannot both be defined") + type Config struct { UserPermissions `mapstructure:",squash"` Debug bool @@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { cfg := &Config{} err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( + directoryMountsDecodeHook(), mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), mapstructure.TextUnmarshallerHookFunc(), @@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { 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 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 } + 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)) { cfg.Users[i].Permissions = cfg.Permissions } @@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { 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 { var err error @@ -188,6 +248,119 @@ func (c *Config) Validate() error { 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) { loggerConfig := zap.NewProductionConfig() loggerConfig.DisableCaller = true diff --git a/lib/config_test.go b/lib/config_test.go index 312e0b9..1fd005a 100644 --- a/lib/config_test.go +++ b/lib/config_test.go @@ -3,6 +3,7 @@ package lib import ( "os" "path/filepath" + "strconv" "testing" "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) { t.Parallel() diff --git a/lib/handler.go b/lib/handler.go index b3839c2..047e694 100644 --- a/lib/handler.go +++ b/lib/handler.go @@ -34,40 +34,16 @@ func NewHandler(c *Config) (http.Handler, error) { noPassword: c.NoPassword, behindProxy: c.BehindProxy, user: &handlerUser{ - User: User{ - UserPermissions: c.UserPermissions, - }, - 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, - }, + User: User{UserPermissions: c.UserPermissions}, + Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc), }, users: map[string]*handlerUser{}, } for _, u := range c.Users { h.users[u.Username] = &handlerUser{ - User: u, - Handler: webdav.Handler{ - Prefix: c.Prefix, - FileSystem: Dir{ - Dir: webdav.Dir(u.Directory), - noSniff: c.NoSniff, - }, - LockSystem: &lockSystem{ - LockSystem: ls, - directory: u.Directory, - }, - Logger: logFunc, - }, + User: u, + Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc), } } @@ -93,6 +69,32 @@ func NewHandler(c *Config) (http.Handler, error) { 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. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := h.user diff --git a/lib/handler_test.go b/lib/handler_test.go index 0b60e67..9076efd 100644 --- a/lib/handler_test.go +++ b/lib/handler_test.go @@ -820,6 +820,129 @@ users: 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) { t.Parallel() diff --git a/lib/locksystem.go b/lib/locksystem.go index ba41ac2..56b3f51 100644 --- a/lib/locksystem.go +++ b/lib/locksystem.go @@ -9,27 +9,68 @@ import ( var _ webdav.LockSystem = &lockSystem{} -// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing -// to reuse the same [webdav.LockSystem] for multiple users with different base -// directories, meaning we can correctly lock the files across different users. +// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the +// real backing paths via resolve. This allows reusing the same +// [webdav.LockSystem] for multiple users with different base directories, +// meaning we can correctly lock the files across different users. type lockSystem struct { 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) { if name0 != "" { - name0 = filepath.Join(l.directory, name0) + name0, err = l.resolve(name0) + if err != nil { + return nil, err + } } 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...) } 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) } diff --git a/lib/multidir.go b/lib/multidir.go new file mode 100644 index 0000000..b20c785 --- /dev/null +++ b/lib/multidir.go @@ -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 +} diff --git a/lib/multidir_test.go b/lib/multidir_test.go new file mode 100644 index 0000000..2941b81 --- /dev/null +++ b/lib/multidir_test.go @@ -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)) +} diff --git a/lib/permissions.go b/lib/permissions.go index c43e757..c7d1d39 100644 --- a/lib/permissions.go +++ b/lib/permissions.go @@ -44,11 +44,23 @@ const ( type UserPermissions struct { Directory string + Directories DirectoryMounts Permissions Permissions Rules []*Rule 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 func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool { // 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) } + 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 { if err := r.Validate(); err != nil { return fmt.Errorf("invalid permissions: %w", err) @@ -110,6 +128,46 @@ func (p *UserPermissions) Validate() error { 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 { Create bool Read bool