wip
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a resource does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrConflict is returned when trying to create a resource that already exists.
|
||||
var ErrConflict = errors.New("conflict")
|
||||
|
||||
// Store is a filesystem-backed key/value store for DAV objects.
|
||||
// Each "collection" maps to a directory; each "object" maps to a file.
|
||||
type Store struct {
|
||||
rootDir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStore creates or opens a Store rooted at rootDir.
|
||||
func NewStore(rootDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating store root %q: %w", rootDir, err)
|
||||
}
|
||||
return &Store{rootDir: rootDir}, nil
|
||||
}
|
||||
|
||||
// collectionPath returns the filesystem path for a collection.
|
||||
func (s *Store) collectionPath(user, collection string) string {
|
||||
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection))
|
||||
}
|
||||
|
||||
// objectPath returns the filesystem path for an object within a collection.
|
||||
func (s *Store) objectPath(user, collection, objectID string) string {
|
||||
return filepath.Join(s.collectionPath(user, collection), sanitize(objectID))
|
||||
}
|
||||
|
||||
// EnsureCollection creates the collection directory if it does not exist.
|
||||
func (s *Store) EnsureCollection(user, collection string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dir := s.collectionPath(user, collection)
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
// ListCollections returns all collection names for a user.
|
||||
func (s *Store) ListCollections(user string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
userDir := filepath.Join(s.rootDir, sanitize(user))
|
||||
entries, err := os.ReadDir(userDir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// GetCollection returns metadata about a collection.
|
||||
func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
info, err := os.Stat(s.collectionPath(user, collection))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// PutObject writes data to an object, creating or replacing it.
|
||||
func (s *Store) PutObject(user, collection, objectID string, data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
dir := s.collectionPath(user, collection)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := s.objectPath(user, collection, objectID)
|
||||
// Write to a temp file then rename for atomicity.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return fmt.Errorf("writing object: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// GetObject reads an object's raw bytes.
|
||||
func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// DeleteObject removes an object.
|
||||
func (s *Store) DeleteObject(user, collection, objectID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
err := os.Remove(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListObjects returns all object filenames in a collection.
|
||||
func (s *Store) ListObjects(user, collection string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
dir := s.collectionPath(user, collection)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && !strings.HasSuffix(e.Name(), ".tmp") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// StatObject returns FileInfo for an object.
|
||||
func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
info, err := os.Stat(s.objectPath(user, collection, objectID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// DeleteCollection removes an entire collection directory.
|
||||
func (s *Store) DeleteCollection(user, collection string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err := os.RemoveAll(s.collectionPath(user, collection))
|
||||
return err
|
||||
}
|
||||
|
||||
// sanitize removes path-traversal characters from a path segment.
|
||||
func sanitize(s string) string {
|
||||
s = filepath.Base(s)
|
||||
s = strings.ReplaceAll(s, "..", "")
|
||||
if s == "." || s == "" {
|
||||
return "_"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/yourusername/caldav-server/internal/store"
|
||||
)
|
||||
|
||||
func TestStoreRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
user = "alice"
|
||||
col = "cal-personal"
|
||||
objectID = "event-001.ics"
|
||||
)
|
||||
|
||||
data := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
// Write
|
||||
if err := st.PutObject(user, col, objectID, data); err != nil {
|
||||
t.Fatalf("PutObject: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
got, err := st.GetObject(user, col, objectID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetObject: %v", err)
|
||||
}
|
||||
if string(got) != string(data) {
|
||||
t.Errorf("data mismatch: got %q, want %q", got, data)
|
||||
}
|
||||
|
||||
// List
|
||||
ids, err := st.ListObjects(user, col)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObjects: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != objectID {
|
||||
t.Errorf("unexpected ids: %v", ids)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := st.DeleteObject(user, col, objectID); err != nil {
|
||||
t.Fatalf("DeleteObject: %v", err)
|
||||
}
|
||||
|
||||
// Not found
|
||||
_, err = st.GetObject(user, col, objectID)
|
||||
if err != store.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
// Traversal attempts should not escape rootDir
|
||||
dangerous := "../../../etc/passwd"
|
||||
data := []byte("test")
|
||||
if err := st.PutObject("user", "col", dangerous, data); err != nil {
|
||||
t.Fatalf("PutObject with dangerous ID: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was NOT written outside rootDir
|
||||
_, err = os.Stat("/etc/passwd.tmp")
|
||||
if err == nil {
|
||||
t.Fatal("path traversal succeeded — security issue!")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user