diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 05c1115..8f0b8f5 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -1256,21 +1256,43 @@ func (f *webdavStorageFS) Copy(ctx context.Context, name, dest string, opts *web if err != nil { return false, err } - out, err := f.store.Create(dst) - if err != nil { - in.Close() + defer in.Close() + if err := copyFileToDestination(f.store, in, dst, dstExists); err != nil { return false, err } - _, copyErr := io.Copy(out, in) - in.Close() + return !dstExists, nil +} + +func copyFileToDestination(st storage.Storage, src io.Reader, dst string, dstExists bool) error { + tmp := fmt.Sprintf("%s.chfs-copy-%d", dst, time.Now().UnixNano()) + for i := 0; i < 16; i++ { + if _, err := st.Stat(tmp); os.IsNotExist(err) { + break + } else if err != nil { + return err + } + tmp = fmt.Sprintf("%s.chfs-copy-%d-%d", dst, time.Now().UnixNano(), i) + } + out, err := createExclusive(st, tmp) + if err != nil { + return err + } + _, copyErr := io.Copy(out, src) closeErr := out.Close() if copyErr != nil { - return false, copyErr + _ = st.Remove(tmp) + return copyErr } if closeErr != nil { - return false, closeErr + _ = st.Remove(tmp) + return closeErr } - return !dstExists, nil + if err := st.Rename(tmp, dst); err != nil { + _ = st.Remove(tmp) + return err + } + _ = dstExists + return nil } func (f *webdavStorageFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) { diff --git a/chfs-ng/internal/api/webdav_test.go b/chfs-ng/internal/api/webdav_test.go index 4f482e0..7b302a2 100644 --- a/chfs-ng/internal/api/webdav_test.go +++ b/chfs-ng/internal/api/webdav_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "net/http/httptest" "os" "path/filepath" @@ -113,6 +114,64 @@ func TestWebDAVAuthFSMoveMissingSourcePrecedence(t *testing.T) { } } +func TestWebDAVStorageFSCopyPreservesExistingDestinationOnSourceReadFailure(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + store := storage.Local{Root: root} + if err := os.WriteFile(filepath.Join(root, "src.txt"), []byte("replacement"), 0o644); err != nil { + t.Fatal(err) + } + fs := &webdavStorageFS{store: store} + if _, err := fs.Copy(context.Background(), "/src.txt", "/dst.txt", &webdav.CopyOptions{}); err != nil { + t.Fatalf("copy: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "dst.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "replacement" { + t.Fatalf("destination content=%q", got) + } + matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary copy files leaked: %v", matches) + } +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("source read failed") } + +func TestWebDAVStorageFSCopyFailureDoesNotCorruptDestination(t *testing.T) { + root := t.TempDir() + store := storage.Local{Root: root} + if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + if err := copyFileToDestination(store, failingReader{}, "dst.txt", true); err == nil { + t.Fatal("expected source read failure") + } + got, err := os.ReadFile(filepath.Join(root, "dst.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("destination was corrupted: %q", got) + } + matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary copy files leaked: %v", matches) + } +} + func TestWebDAVStorageFSCopyAndMove(t *testing.T) { root1 := t.TempDir() root2 := t.TempDir()