diff --git a/chfs-ng/internal/storage/storage.go b/chfs-ng/internal/storage/storage.go index 809265d..e260cb4 100644 --- a/chfs-ng/internal/storage/storage.go +++ b/chfs-ng/internal/storage/storage.go @@ -3,10 +3,13 @@ package storage import ( "errors" "fmt" + "io" "io/fs" "os" "path/filepath" "strings" + "syscall" + "time" "github.com/Bios-Marcel/wastebasket" ) @@ -33,6 +36,10 @@ func (s Local) real(path string) (string, error) { if err != nil { return "", err } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } p := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator)) full := filepath.Join(root, p) rel, err := filepath.Rel(root, full) @@ -42,6 +49,25 @@ func (s Local) real(path string) (string, error) { if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", errors.New("path escapes storage root") } + check := full + for { + if _, err = os.Lstat(check); !os.IsNotExist(err) { + break + } + next := filepath.Dir(check) + if next == check { + break + } + check = next + } + resolved, err := filepath.EvalSymlinks(check) + if err != nil { + return "", err + } + resolvedRel, err := filepath.Rel(root, resolved) + if err != nil || resolvedRel == ".." || strings.HasPrefix(resolvedRel, ".."+string(filepath.Separator)) { + return "", errors.New("path escapes storage root") + } return full, nil } func (s Local) Stat(path string) (os.FileInfo, error) { @@ -89,19 +115,121 @@ func (s Local) MoveToTrash(path, trashRoot string) error { if e != nil { return e } + root, e := filepath.Abs(s.Root) + if e != nil { + return e + } + rel, e := filepath.Rel(root, p) + if e != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return errors.New("path escapes storage root") + } if e = os.MkdirAll(trashRoot, 0755); e != nil { return e } - name := filepath.Base(p) - target := filepath.Join(trashRoot, name) + // Preserve the original relative path under the CHFS trash root so + // identically named files from different directories cannot collide. + target := filepath.Join(trashRoot, rel) + if e = os.MkdirAll(filepath.Dir(target), 0755); e != nil { + return e + } for i := 1; ; i++ { if _, e = os.Lstat(target); os.IsNotExist(e) { break } - target = filepath.Join(trashRoot, fmt.Sprintf("%s.%d", name, i)) + target = filepath.Join(filepath.Dir(target), fmt.Sprintf("%s.%d", filepath.Base(p), i)) } - return os.Rename(p, target) + if e = movePath(p, target); e != nil { + return e + } + return cleanupTrash(trashRoot, 30*24*time.Hour) } + +func cleanupTrash(root string, maxAge time.Duration) error { + now := time.Now() + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == root || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if now.Sub(info.ModTime()) > maxAge { + return os.Remove(path) + } + return nil + }) +} + +func movePath(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } else if !errors.Is(err, syscall.EXDEV) { + return err + } + if _, err := os.Lstat(dst); err == nil { + return fmt.Errorf("destination already exists: %s", dst) + } else if !os.IsNotExist(err) { + return err + } + if err := copyPath(src, dst); err != nil { + return err + } + return os.RemoveAll(src) +} + +func copyPath(src, dst string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(src) + if err != nil { + return err + } + return os.Symlink(target, dst) + } + if info.IsDir() { + if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chtimes(dst, info.ModTime(), info.ModTime()) + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(dst) + return copyErr + } + if closeErr != nil { + _ = os.Remove(dst) + return closeErr + } + return os.Chtimes(dst, info.ModTime(), info.ModTime()) +} + func (s Local) Rename(a, b string) error { pa, e := s.real(a) if e != nil { @@ -111,7 +239,7 @@ func (s Local) Rename(a, b string) error { if e != nil { return e } - return os.Rename(pa, pb) + return movePath(pa, pb) } func (s Local) MkdirAll(path string) error { p, e := s.real(path) diff --git a/chfs-ng/internal/storage/storage_test.go b/chfs-ng/internal/storage/storage_test.go new file mode 100644 index 0000000..9e85e7c --- /dev/null +++ b/chfs-ng/internal/storage/storage_test.go @@ -0,0 +1,78 @@ +package storage + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLocalRejectsSymlinkEscapeAndAllowsNestedCreate(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { + t.Fatal(err) + } + s := Local{Root: root} + if _, err := s.Open("escape/secret.txt"); err == nil { + t.Fatal("expected symlink escape to be rejected") + } + f, err := s.Create("a/b/c.txt") + if err != nil { + t.Fatalf("nested create failed: %v", err) + } + _ = f.Close() +} + +func TestMoveToTrashPreservesRelativePathAndCleansOldEntries(t *testing.T) { + root := t.TempDir() + trash := t.TempDir() + s := Local{Root: root} + if err := os.MkdirAll(filepath.Join(root, "one", "two"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "one", "two", "same.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(trash, "old"), 0o755); err != nil { + t.Fatal(err) + } + old := filepath.Join(trash, "old", "stale.txt") + if err := os.WriteFile(old, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + oldTime := time.Now().Add(-31 * 24 * time.Hour) + if err := os.Chtimes(old, oldTime, oldTime); err != nil { + t.Fatal(err) + } + if err := s.MoveToTrash("one/two/same.txt", trash); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(trash, "one", "two", "same.txt")); err != nil { + t.Fatalf("trash path not preserved: %v", err) + } + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("stale trash entry was not cleaned: %v", err) + } +} + +func TestCopyPathPreservesSymlink(t *testing.T) { + src := filepath.Join(t.TempDir(), "link") + dst := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("target", src); err != nil { + t.Fatal(err) + } + if err := copyPath(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.Readlink(dst) + if err != nil { + t.Fatal(err) + } + if got != "target" { + t.Fatalf("got symlink target %q", got) + } +}