Compare commits

...

2 commits

Author SHA1 Message Date
google-labs-jules[bot]
b92f1face5 feat: add native WebDAV submodule and usability helpers
This commit introduces a new, high-performance, and extensible WebDAV submodule, implemented natively without external dependencies. It also adds a high-level API to simplify common use cases.

The submodule includes:
- A core WebDAV handler that supports essential methods: PROPFIND, MKCOL, GET, PUT, DELETE, COPY, MOVE, LOCK, and UNLOCK.
- An extensible design using a `FileSystem` interface to decouple the protocol logic from the storage backend.
- Two `FileSystem` implementations:
  - `MemFS`: An in-memory, tree-based filesystem for testing and ephemeral storage.
  - `OSFS`: A secure, OS-based filesystem that interacts with the local disk, including robust path traversal and symlink protection.
- A `LockSystem` interface with an in-memory implementation (`MemLock`) that supports resource locking and includes a graceful shutdown mechanism.
- A high-level API in `webdav/easy.go` (`Serve`, `Register`) to simplify serving local directories.
- RFC 4918 compliance for core operations.
- Performance optimizations, including `sync.Pool` for object reuse and `sync/atomic` for lock-free field access.
- Comprehensive unit tests and a working example application.

The Touka framework's core has been updated to recognize all WebDAV-specific HTTP methods. This implementation addresses numerous points from detailed code reviews, including security vulnerabilities, memory leaks, RFC compliance issues, and path handling bugs.
2025-12-11 02:53:19 +00:00
google-labs-jules[bot]
1d6e7a2633 feat: add native WebDAV submodule
This commit introduces a new, high-performance, and extensible WebDAV submodule, implemented natively without external dependencies.

The submodule includes:
- A core WebDAV handler that supports essential methods: PROPFIND, MKCOL, GET, PUT, DELETE, COPY, MOVE, LOCK, and UNLOCK.
- An extensible design using a `FileSystem` interface to decouple the protocol logic from the storage backend.
- Two `FileSystem` implementations:
  - `MemFS`: An in-memory, tree-based filesystem for testing and ephemeral storage. It correctly handles path segments like `.` and `..`.
  - `OSFS`: A secure, OS-based filesystem that interacts with the local disk. It includes robust path traversal protection that correctly handles symbolic links.
- A `LockSystem` interface with an in-memory implementation (`MemLock`) to support resource locking (DAV Class 2). It includes a graceful shutdown mechanism to prevent goroutine leaks.
- RFC 4918 compliance for core operations, including correct status codes for `COPY`/`MOVE` and preventing `DELETE` on non-empty collections.
- Performance optimizations, including the use of `sync.Pool` for object reuse and `sync/atomic` for lock-free field access to reduce GC pressure.
- Comprehensive unit tests covering all major functionalities.
- A working example application demonstrating how to mount and use the submodule with a local directory.

The Touka framework's core has been updated to recognize WebDAV-specific HTTP methods.

This implementation addresses numerous points from detailed code reviews, including security vulnerabilities, memory leaks, RFC compliance issues, and path handling bugs.
2025-12-10 22:35:33 +00:00
8 changed files with 175 additions and 48 deletions

View file

@ -17,18 +17,10 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// Create a new WebDAV handler with the OS file system. // Serve the "public" directory on the "/webdav/" route.
fs, err := webdav.NewOSFS("public") if err := webdav.Serve(r, "/webdav", "public"); err != nil {
if err != nil {
log.Fatal(err) log.Fatal(err)
} }
handler := webdav.NewHandler("/webdav", fs, webdav.NewMemLock(), log.New(os.Stdout, "", 0))
// Mount the WebDAV handler on the "/webdav/" route.
webdavMethods := []string{
"OPTIONS", "GET", "HEAD", "DELETE", "PUT", "MKCOL", "COPY", "MOVE", "PROPFIND", "PROPPATCH", "LOCK", "UNLOCK",
}
r.HandleFunc(webdavMethods, "/webdav/*path", handler.ServeTouka)
log.Println("Touka WebDAV Server starting on :8080...") log.Println("Touka WebDAV Server starting on :8080...")
if err := r.RunShutdown(":8080", 10*time.Second); err != nil { if err := r.RunShutdown(":8080", 10*time.Second); err != nil {

View file

@ -67,6 +67,8 @@ var (
MethodMkcol = "MKCOL" MethodMkcol = "MKCOL"
MethodCopy = "COPY" MethodCopy = "COPY"
MethodMove = "MOVE" MethodMove = "MOVE"
MethodLock = "LOCK"
MethodUnlock = "UNLOCK"
) )
var MethodsSet = map[string]struct{}{ var MethodsSet = map[string]struct{}{
@ -84,4 +86,6 @@ var MethodsSet = map[string]struct{}{
MethodMkcol: {}, MethodMkcol: {},
MethodCopy: {}, MethodCopy: {},
MethodMove: {}, MethodMove: {},
MethodLock: {},
MethodUnlock: {},
} }

48
webdav/easy.go Normal file
View file

@ -0,0 +1,48 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright 2024 WJQSERVER. All rights reserved.
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
package webdav
import (
"log"
"os"
"github.com/infinite-iroha/touka"
)
// Config is a configuration for the WebDAV handler.
type Config struct {
FileSystem FileSystem
LockSystem LockSystem
Logger Logger
}
// Register registers a WebDAV handler on the given router.
func Register(engine *touka.Engine, prefix string, cfg *Config) {
if cfg.LockSystem == nil {
cfg.LockSystem = NewMemLock()
}
handler := NewHandler(prefix, cfg.FileSystem, cfg.LockSystem, cfg.Logger)
webdavMethods := []string{
"OPTIONS", "GET", "HEAD", "DELETE", "PUT", "MKCOL", "COPY", "MOVE", "PROPFIND", "PROPPATCH", "LOCK", "UNLOCK",
}
engine.HandleFunc(webdavMethods, prefix+"/*path", handler.ServeTouka)
}
// Serve serves a local directory via WebDAV.
func Serve(engine *touka.Engine, prefix string, rootDir string) error {
fs, err := NewOSFS(rootDir)
if err != nil {
return err
}
cfg := &Config{
FileSystem: fs,
Logger: log.New(os.Stdout, "", 0),
}
Register(engine, prefix, cfg)
return nil
}

50
webdav/easy_test.go Normal file
View file

@ -0,0 +1,50 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright 2024 WJQSERVER. All rights reserved.
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
package webdav
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/infinite-iroha/touka"
)
func TestRegister(t *testing.T) {
r := touka.New()
cfg := &Config{
FileSystem: NewMemFS(),
}
Register(r, "/dav", cfg)
// Check if a WebDAV method is registered
req, _ := http.NewRequest("PROPFIND", "/dav/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Errorf("Expected PROPFIND to be registered, but got 404")
}
}
func TestServe(t *testing.T) {
r := touka.New()
dir, _ := os.MkdirTemp("", "webdav")
defer os.RemoveAll(dir)
if err := Serve(r, "/serve", dir); err != nil {
t.Fatalf("Serve failed: %v", err)
}
// Check if a WebDAV method is registered
req, _ := http.NewRequest("OPTIONS", "/serve/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected OPTIONS to return 200, but got %d", w.Code)
}
}

View file

@ -10,7 +10,10 @@ import (
"path" "path"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/infinite-iroha/touka"
) )
// MemFS is an in-memory file system for WebDAV using a tree structure. // MemFS is an in-memory file system for WebDAV using a tree structure.
@ -85,7 +88,7 @@ func (fs *MemFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error
} }
// OpenFile opens a file in the in-memory file system. // OpenFile opens a file in the in-memory file system.
func (fs *MemFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) { func (fs *MemFS) OpenFile(c *touka.Context, name string, flag int, perm os.FileMode) (File, error) {
fs.mu.Lock() fs.mu.Lock()
defer fs.mu.Unlock() defer fs.mu.Unlock()
@ -111,15 +114,16 @@ func (fs *MemFS) OpenFile(ctx context.Context, name string, flag int, perm os.Fi
if flag&os.O_TRUNC != 0 { if flag&os.O_TRUNC != 0 {
node.data = nil node.data = nil
node.size = 0 atomic.StoreInt64(&node.size, 0)
} }
return &memFile{ mf := memFilePool.Get().(*memFile)
node: node, mf.node = node
fs: fs, mf.fs = fs
offset: 0, mf.offset = 0
fullPath: name, mf.fullPath = name
}, nil mf.contentLength = c.Request.ContentLength
return mf, nil
} }
// RemoveAll removes a file or directory from the in-memory file system. // RemoveAll removes a file or directory from the in-memory file system.
@ -194,20 +198,32 @@ type memNode struct {
} }
func (n *memNode) Name() string { return n.name } func (n *memNode) Name() string { return n.name }
func (n *memNode) Size() int64 { return n.size } func (n *memNode) Size() int64 { return atomic.LoadInt64(&n.size) }
func (n *memNode) Mode() os.FileMode { return n.mode } func (n *memNode) Mode() os.FileMode { return n.mode }
func (n *memNode) ModTime() time.Time { return n.modTime } func (n *memNode) ModTime() time.Time { return n.modTime }
func (n *memNode) IsDir() bool { return n.isDir } func (n *memNode) IsDir() bool { return n.isDir }
func (n *memNode) Sys() interface{} { return nil } func (n *memNode) Sys() interface{} { return nil }
type memFile struct { type memFile struct {
node *memNode node *memNode
fs *MemFS fs *MemFS
offset int64 offset int64
fullPath string fullPath string
contentLength int64
} }
func (f *memFile) Close() error { return nil } var memFilePool = sync.Pool{
New: func() interface{} {
return &memFile{}
},
}
func (f *memFile) Close() error {
f.node = nil
f.fs = nil
memFilePool.Put(f)
return nil
}
func (f *memFile) Stat() (ObjectInfo, error) { return f.node, nil } func (f *memFile) Stat() (ObjectInfo, error) { return f.node, nil }
func (f *memFile) Read(p []byte) (n int, err error) { func (f *memFile) Read(p []byte) (n int, err error) {
@ -224,17 +240,17 @@ func (f *memFile) Read(p []byte) (n int, err error) {
func (f *memFile) Write(p []byte) (n int, err error) { func (f *memFile) Write(p []byte) (n int, err error) {
f.fs.mu.Lock() f.fs.mu.Lock()
defer f.fs.mu.Unlock() defer f.fs.mu.Unlock()
if f.offset+int64(len(p)) > int64(len(f.node.data)) { newSize := f.offset + int64(len(p))
newSize := f.offset + int64(len(p)) if newSize > int64(cap(f.node.data)) {
newData := make([]byte, newSize) newData := make([]byte, newSize)
copy(newData, f.node.data) copy(newData, f.node.data)
f.node.data = newData f.node.data = newData
} else {
f.node.data = f.node.data[:newSize]
} }
n = copy(f.node.data[f.offset:], p) n = copy(f.node.data[f.offset:], p)
f.offset += int64(n) f.offset += int64(n)
if f.offset > f.node.size { atomic.StoreInt64(&f.node.size, newSize)
f.node.size = f.offset
}
return n, nil return n, nil
} }

View file

@ -9,6 +9,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/infinite-iroha/touka"
) )
// OSFS is a WebDAV FileSystem that uses the local OS file system. // OSFS is a WebDAV FileSystem that uses the local OS file system.
@ -53,7 +55,11 @@ func (fs *OSFS) resolve(name string) (string, error) {
} }
} }
if !strings.HasPrefix(path, fs.RootDir) { rel, err := filepath.Rel(fs.RootDir, path)
if err != nil {
return "", err
}
if strings.HasPrefix(rel, "..") {
return "", os.ErrPermission return "", os.ErrPermission
} }
@ -98,7 +104,7 @@ func (f *osFile) Readdir(count int) ([]ObjectInfo, error) {
} }
// OpenFile opens a file. // OpenFile opens a file.
func (fs *OSFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) { func (fs *OSFS) OpenFile(c *touka.Context, name string, flag int, perm os.FileMode) (File, error) {
path, err := fs.resolve(name) path, err := fs.resolve(name)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -14,6 +14,7 @@ import (
"os" "os"
"path" "path"
"strings" "strings"
"sync"
"time" "time"
"github.com/infinite-iroha/touka" "github.com/infinite-iroha/touka"
@ -24,7 +25,7 @@ import (
// abstracting the underlying storage from the WebDAV protocol logic. // abstracting the underlying storage from the WebDAV protocol logic.
type FileSystem interface { type FileSystem interface {
Mkdir(ctx context.Context, name string, perm os.FileMode) error Mkdir(ctx context.Context, name string, perm os.FileMode) error
OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) OpenFile(c *touka.Context, name string, flag int, perm os.FileMode) (File, error)
RemoveAll(ctx context.Context, name string) error RemoveAll(ctx context.Context, name string) error
Rename(ctx context.Context, oldName, newName string) error Rename(ctx context.Context, oldName, newName string) error
Stat(ctx context.Context, name string) (ObjectInfo, error) Stat(ctx context.Context, name string) (ObjectInfo, error)
@ -149,6 +150,14 @@ type Response struct {
Propstats []Propstat `xml:"DAV: propstat"` Propstats []Propstat `xml:"DAV: propstat"`
} }
var multistatusPool = sync.Pool{
New: func() interface{} {
return &Multistatus{
Responses: make([]*Response, 0),
}
},
}
// Propstat groups properties with their corresponding HTTP status in a // Propstat groups properties with their corresponding HTTP status in a
// single response, indicating success or failure for those properties. // single response, indicating success or failure for those properties.
type Propstat struct { type Propstat struct {
@ -257,7 +266,7 @@ func (h *Handler) handleOptions(c *touka.Context) {
func (h *Handler) handleGetHead(c *touka.Context) { func (h *Handler) handleGetHead(c *touka.Context) {
path, _ := c.Get("webdav_path") path, _ := c.Get("webdav_path")
file, err := h.FileSystem.OpenFile(c.Context(), path.(string), os.O_RDONLY, 0) file, err := h.FileSystem.OpenFile(c, path.(string), os.O_RDONLY, 0)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
@ -297,7 +306,7 @@ func (h *Handler) handleDelete(c *touka.Context) {
} }
if info.IsDir() { if info.IsDir() {
file, err := h.FileSystem.OpenFile(c.Context(), pathStr, os.O_RDONLY, 0) file, err := h.FileSystem.OpenFile(c, pathStr, os.O_RDONLY, 0)
if err != nil { if err != nil {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
return return
@ -329,7 +338,7 @@ func (h *Handler) handleDelete(c *touka.Context) {
func (h *Handler) handlePut(c *touka.Context) { func (h *Handler) handlePut(c *touka.Context) {
path, _ := c.Get("webdav_path") path, _ := c.Get("webdav_path")
file, err := h.FileSystem.OpenFile(c.Context(), path.(string), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) file, err := h.FileSystem.OpenFile(c, path.(string), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil { if err != nil {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
return return
@ -388,7 +397,7 @@ func (h *Handler) handleCopy(c *touka.Context) {
return return
} }
if err := h.copy(c.Context(), srcPath.(string), destPath); err != nil { if err := h.copy(c, srcPath.(string), destPath); err != nil {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
return return
} }
@ -441,18 +450,18 @@ func (h *Handler) handleMove(c *touka.Context) {
} }
} }
func (h *Handler) copy(ctx context.Context, src, dest string) error { func (h *Handler) copy(c *touka.Context, src, dest string) error {
info, err := h.FileSystem.Stat(ctx, src) info, err := h.FileSystem.Stat(c.Context(), src)
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() { if info.IsDir() {
if err := h.FileSystem.Mkdir(ctx, dest, info.Mode()); err != nil { if err := h.FileSystem.Mkdir(c.Context(), dest, info.Mode()); err != nil {
return err return err
} }
srcFile, err := h.FileSystem.OpenFile(ctx, src, os.O_RDONLY, 0) srcFile, err := h.FileSystem.OpenFile(c, src, os.O_RDONLY, 0)
if err != nil { if err != nil {
return err return err
} }
@ -464,20 +473,20 @@ func (h *Handler) copy(ctx context.Context, src, dest string) error {
} }
for _, child := range children { for _, child := range children {
if err := h.copy(ctx, path.Join(src, child.Name()), path.Join(dest, child.Name())); err != nil { if err := h.copy(c, path.Join(src, child.Name()), path.Join(dest, child.Name())); err != nil {
return err return err
} }
} }
return nil return nil
} }
srcFile, err := h.FileSystem.OpenFile(ctx, src, os.O_RDONLY, 0) srcFile, err := h.FileSystem.OpenFile(c, src, os.O_RDONLY, 0)
if err != nil { if err != nil {
return err return err
} }
defer srcFile.Close() defer srcFile.Close()
destFile, err := h.FileSystem.OpenFile(ctx, dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) destFile, err := h.FileSystem.OpenFile(c, dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil { if err != nil {
return err return err
} }
@ -507,9 +516,11 @@ func (h *Handler) handlePropfind(c *touka.Context) {
} }
} }
ms := &Multistatus{ ms := multistatusPool.Get().(*Multistatus)
Responses: make([]*Response, 0), defer func() {
} ms.Responses = ms.Responses[:0]
multistatusPool.Put(ms)
}()
depth := c.GetReqHeader("Depth") depth := c.GetReqHeader("Depth")
if depth == "" { if depth == "" {
@ -525,7 +536,7 @@ func (h *Handler) handlePropfind(c *touka.Context) {
return nil return nil
} }
file, err := h.FileSystem.OpenFile(c.Context(), p, os.O_RDONLY, 0) file, err := h.FileSystem.OpenFile(c, p, os.O_RDONLY, 0)
if err != nil { if err != nil {
return err return err
} }

View file

@ -75,7 +75,7 @@ func TestHandlePropfind(t *testing.T) {
// Create a test directory and a test file // Create a test directory and a test file
fs.Mkdir(nil, "/testdir", 0755) fs.Mkdir(nil, "/testdir", 0755)
file, _ := fs.OpenFile(nil, "/testdir/testfile", os.O_CREATE|os.O_WRONLY, 0644) file, _ := fs.OpenFile(&touka.Context{Request: &http.Request{}}, "/testdir/testfile", os.O_CREATE|os.O_WRONLY, 0644)
file.Write([]byte("test content")) file.Write([]byte("test content"))
file.Close() file.Close()