Repository: benbjohnson/litestream

Issue:
feature to backup to webdav service like ownCloud
## Feature Description

ability to use webdav service similar to the sftp

## Use Cases

our cloud provider does only provide webdav protocol for storage (based on ownCloud)

the sqlite database is used by a local installation of frankenphp and a nwjs based desktop ui and needs regular sync to the cloud for reporting puposes.

## Attempted Solutions

currently there is a manual job run by the user after exiting the application using `rclone` to backup the database directory

## Proposal

`rclone` has code to use webdav service here https://github.com/rclone/rclone/tree/master/backend/webdav


Discussion:
@corylanou: @benbjohnson - I had Claude analyze this feature request to help you evaluate it. Here's the technical assessment (note: I haven't done a deep dive to validate all of this yet):

### Implementation Scope

Adding WebDAV support would follow the existing replica backend pattern:
- **~380 lines of code** (similar to SFTP's 367 lines)
- Uses `github.com/studio-b12/gowebdav` library (mature, actively maintained)
- Same 6 interface methods as all other backends
- Clean integration into existing config structure

### Maintenance Considerations

**Similar to SFTP in complexity, but with higher support risk:**

✅ **Lower burden:**
- HTTP-based protocol (easier debugging)
- Stable, well-understood standard
- Single dependency with good library support
- Stateless (no connection pooling complexity)

⚠️ **Higher burden:**
- **Server compatibility variations** - main concern
  - ownCloud/Nextcloud: full support expected
  - Apache mod_dav: basic support
  - Nginx/IIS: limited capabilities
- Timestamp preservation inconsistent across servers
- Will require compatibility matrix documentation
- **Likely to generate "works on my server but not yours" bug reports**

### Technical Challenges

1. **Timestamp preservation** - Not all WebDAV servers support custom metadata. Would fall back to server modification time (similar to S3/GCS pattern).

2. **Server variability** - Different implementations behave differently. Would need testing against multiple servers and clear documentation of supported/tested configurations.

3. **Range requests** - May need direct HTTP client usage if library doesn't expose range read API.

### Use Case

User's cloud provider (ownCloud-based) only provides WebDAV protocol. Currently manually running `rclone` for backups. Valid enterprise/private cloud scenario.

### Assessment

This appears borderline reasonable. Implementation complexity is moderate and fits existing patterns cleanly. Main concern is ongoing support burden from server compatibility issues. 

If we're comfortable with SFTP's maintenance burden, WebDAV would be similar (possibly 20-30% higher due to server variability). 

**Decision factors:**
- Does the use case justify another backend?
- Are we willing to handle server-specific compatibility issues?
- Is WebDAV common enough in our target user base?

---

@terefang - Would appreciate your input on this assessment. Did we miss anything about your use case or requirements? Any additional context about your ownCloud setup that might be relevant to the implementation?@terefang: > [@terefang](https://github.com/terefang) - Would appreciate your input on this assessment. Did we miss anything about your use case or requirements? Any additional context about your ownCloud setup that might be relevant to the implementation?

wow, that is about my perception

interesting to see that the code has already been integrated ... i am positively stunned.

keep up the good work !

Solution patch:
diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml
index e2c8286..0ffa30f 100644
--- a/.github/workflows/commit.yml
+++ b/.github/workflows/commit.yml
@@ -346,3 +346,53 @@ jobs:
 
           # Check files were created
           ssh -i /test/id_ed25519 -o StrictHostKeyChecking=accept-new -p 2222 root@localhost "ls -la /test/data/sequential/" || true
+
+  webdav-integration-test:
+    name: Run WebDAV Integration Tests
+    runs-on: ubuntu-latest
+    needs: build
+    steps:
+      - name: Start WebDAV Server
+        run: |
+          docker run -d \
+            --name webdav-test \
+            -p 8080:80 \
+            -e USERNAME=testuser \
+            -e PASSWORD=testpass \
+            -v /tmp/webdav:/var/webdav \
+            bytemark/webdav
+
+          # Wait for WebDAV to be ready
+          for i in {1..30}; do
+            if curl -s -u testuser:testpass http://localhost:8080/ >/dev/null 2>&1; then
+              echo "WebDAV server is ready"
+              break
+            fi
+            sleep 1
+          done
+
+          # Verify WebDAV is accessible
+          curl -u testuser:testpass http://localhost:8080/ || (docker logs webdav-test && exit 1)
+
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-go@v5
+        with:
+          go-version-file: "go.mod"
+
+      - run: go env
+
+      - run: go install ./cmd/litestream
+
+      - run: go test -v ./replica_client_test.go -integration webdav
+        env:
+          LITESTREAM_WEBDAV_URL:      "http://localhost:8080"
+          LITESTREAM_WEBDAV_USERNAME: "testuser"
+          LITESTREAM_WEBDAV_PASSWORD: "testpass"
+          LITESTREAM_WEBDAV_PATH:     "/testdata"
+
+      - name: Cleanup
+        if: always()
+        run: |
+          docker stop webdav-test || true
+          docker rm webdav-test || true
diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml
new file mode 100644
index 0000000..2924ac6
--- /dev/null
+++ b/.github/workflows/stale-issues.yml
@@ -0,0 +1,48 @@
+name: Stale Issue Manager
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v9
+        with:
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+          # Issues: Label after 90 days, close after 30 more days
+          days-before-stale: 90
+          days-before-close: 30
+          stale-issue-label: 'stale'
+
+          # Exempt these labels from stale marking
+          exempt-issue-labels: 'Soon,release-blocker'
+
+          # Message posted when issue becomes stale
+          stale-issue-message: >
+            This issue has been inactive for 90 days and will be automatically
+            closed in 30 days if there is no further activity. If this issue is
+            still relevant, please add a comment to keep it open. Thank you for
+            your contribution!
+
+          # Message posted when issue is closed
+          close-issue-message: >
+            This issue has been automatically closed due to inactivity. If you
+            believe this issue is still relevant, please reopen it or create a
+            new issue with updated information. Thank you!
+
+          # Disable pull request processing
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
+
+          # Limit operations per run
+          operations-per-run: 100
+
+          # Remove stale label when there is activity
+          remove-stale-when-updated: true
diff --git a/go.mod b/go.mod
index 2a0baae..b911822 100644
--- a/go.mod
+++ b/go.mod
@@ -17,12 +17,15 @@ require (
 	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.2
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.85.1
 	github.com/aws/smithy-go v1.22.5
+	github.com/dustin/go-humanize v1.0.1
+	github.com/fsouza/fake-gcs-server v1.47.3
 	github.com/mark3labs/mcp-go v0.32.0
 	github.com/mattn/go-shellwords v1.0.12
 	github.com/mattn/go-sqlite3 v1.14.19
 	github.com/nats-io/nats.go v1.44.0
 	github.com/pkg/sftp v1.13.6
 	github.com/prometheus/client_golang v1.17.0
+	github.com/studio-b12/gowebdav v0.11.0
 	github.com/superfly/ltx v0.5.0
 	golang.org/x/crypto v0.41.0
 	golang.org/x/sys v0.35.0
@@ -33,8 +36,6 @@ require (
 
 require (
 	cloud.google.com/go/pubsub v1.33.0 // indirect
-	github.com/dustin/go-humanize v1.0.1 // indirect
-	github.com/fsouza/fake-gcs-server v1.47.3 // indirect
 	github.com/google/renameio/v2 v2.0.0 // indirect
 	github.com/gorilla/handlers v1.5.1 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
diff --git a/go.sum b/go.sum
index 6c045fb..1663afb 100644
--- a/go.sum
+++ b/go.sum
@@ -7,6 +7,8 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
 cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
+cloud.google.com/go/kms v1.15.5 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM=
+cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI=
 cloud.google.com/go/pubsub v1.33.0 h1:6SPCPvWav64tj0sVX/+npCBKhUi/UjJehy9op/V3p2g=
 cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc=
 cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8=
@@ -157,10 +159,14 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
 github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
 github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
+github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -186,6 +192,16 @@ github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbW
 github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.0.61 h1:87c+x8J3jxQ5VUGimV9oHdpjsAvy3fhneEBKuoKEVUI=
+github.com/minio/minio-go/v7 v7.0.61/go.mod h1:BTu8FcrEw+HidY0zd/0eny43QnVNkXRPXrLXFuQBHXg=
+github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
+github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/nats-io/nats.go v1.44.0 h1:ECKVrDLdh/kDPV1g0gAQ+2+m2KprqZK5O/eJAyAnH2M=
 github.com/nats-io/nats.go v1.44.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
 github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
@@ -219,6 +235,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
@@ -232,6 +250,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
+github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
 github.com/superfly/ltx v0.5.0 h1:dXNrcT3ZtMb6iKZopIV7z5UBscnapg0b0F02loQsk5o=
 github.com/superfly/ltx v0.5.0/go.mod h1:Nf50QAIXU/ET4ua3AuQ2fh31MbgNQZA7r/DYx6Os77s=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -370,6 +390,8 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/store.go b/store.go
index ab330c7..f41b096 100644
--- a/store.go
+++ b/store.go
@@ -162,21 +162,21 @@ LOOP:
 					slog.Debug("recently compacted, skipping", "level", lvl.Level, "path", db.Path())
 					continue
 				} else if err != nil {
-					// Don't log context cancellation errors during shutdown
+					// Don't log or sleep on context cancellation errors during shutdown
 					if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
 						slog.Error("compaction failed", "level", lvl.Level, "error", err)
+						time.Sleep(1 * time.Second) // wait so we don't rack up S3 charges
 					}
-					time.Sleep(1 * time.Second) // wait so we don't rack up S3 charges
 				}
 
 				// Each time we snapshot, clean up everything before the oldest snapshot.
 				if lvl.Level == SnapshotLevel {
 					if err := s.EnforceSnapshotRetention(ctx, db); err != nil {
-						// Don't log context cancellation errors during shutdown
+						// Don't log or sleep on context cancellation errors during shutdown
 						if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
 							slog.Error("retention enforcement failed", "error", err)
+							time.Sleep(1 * time.Second) // wait so we don't rack up S3 charges
 						}
-						time.Sleep(1 * time.Second) // wait so we don't rack up S3 charges
 					}
 				}
 			}
diff --git a/webdav/replica_client.go b/webdav/replica_client.go
new file mode 100644
index 0000000..7f89567
--- /dev/null
+++ b/webdav/replica_client.go
@@ -0,0 +1,327 @@
+package webdav
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"path"
+	"sort"
+	"sync"
+	"time"
+
+	"github.com/studio-b12/gowebdav"
+	"github.com/superfly/ltx"
+
+	"github.com/benbjohnson/litestream"
+	"github.com/benbjohnson/litestream/internal"
+)
+
+const ReplicaClientType = "webdav"
+
+const (
+	DefaultTimeout = 30 * time.Second
+)
+
+var _ litestream.ReplicaClient = (*ReplicaClient)(nil)
+
+type ReplicaClient struct {
+	mu     sync.Mutex
+	client *gowebdav.Client
+	logger *slog.Logger
+
+	URL      string
+	Username string
+	Password string
+	Path     string
+	Timeout  time.Duration
+}
+
+func NewReplicaClient() *ReplicaClient {
+	return &ReplicaClient{
+		logger:  slog.Default().WithGroup(ReplicaClientType),
+		Timeout: DefaultTimeout,
+	}
+}
+
+func (c *ReplicaClient) Type() string {
+	return ReplicaClientType
+}
+
+func (c *ReplicaClient) Init(ctx context.Context) (_ *gowebdav.Client, err error) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if c.client != nil {
+		return c.client, nil
+	}
+
+	if c.URL == "" {
+		return nil, fmt.Errorf("webdav url required")
+	}
+
+	c.client = gowebdav.NewClient(c.URL, c.Username, c.Password)
+
+	c.client.SetTimeout(c.Timeout)
+
+	if err := c.client.Connect(); err != nil {
+		c.client = nil
+		return nil, fmt.Errorf("webdav: cannot connect to server: %w", err)
+	}
+
+	return c.client, nil
+}
+
+func (c *ReplicaClient) DeleteAll(ctx context.Context) error {
+	client, err := c.Init(ctx)
+	if err != nil {
+		return err
+	}
+
+	if err := client.RemoveAll(c.Path); err != nil && !os.IsNotExist(err) && !gowebdav.IsErrNotFound(err) {
+		return fmt.Errorf("webdav: cannot delete path %q: %w", c.Path, err)
+	}
+
+	internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
+
+	return nil
+}
+
+func (c *ReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) (_ ltx.FileIterator, err error) {
+	client, err := c.Init(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	dir := litestream.LTXLevelDir(c.Path, level)
+	files, err := client.ReadDir(dir)
+	if err != nil {
+		if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
+			return ltx.NewFileInfoSliceIterator(nil), nil
+		}
+		return nil, fmt.Errorf("webdav: cannot read directory %q: %w", dir, err)
+	}
+
+	infos := make([]*ltx.FileInfo, 0, len(files))
+	for _, fi := range files {
+		if fi.IsDir() {
+			continue
+		}
+
+		minTXID, maxTXID, err := ltx.ParseFilename(path.Base(fi.Name()))
+		if err != nil {
+			continue
+		} else if minTXID < seek {
+			continue
+		}
+
+		infos = append(infos, &ltx.FileInfo{
+			Level:     level,
+			MinTXID:   minTXID,
+			MaxTXID:   maxTXID,
+			Size:      fi.Size(),
+			CreatedAt: fi.ModTime().UTC(),
+		})
+	}
+
+	sort.Slice(infos, func(i, j int) bool {
+		if infos[i].MinTXID != infos[j].MinTXID {
+			return infos[i].MinTXID < infos[j].MinTXID
+		}
+		return infos[i].MaxTXID < infos[j].MaxTXID
+	})
+
+	return ltx.NewFileInfoSliceIterator(infos), nil
+}
+
+// WriteLTXFile writes an LTX file to the WebDAV server.
+//
+// WebDAV Upload Strategy - Temp File Approach:
+//
+// Unlike other replica backends (S3, SFTP, NATS, ABS) which stream directly using
+// internal.NewReadCounter, WebDAV requires a different approach due to library and
+// protocol constraints:
+//
+// 1. gowebdav Library Limitations:
+//
+//   - WriteStream() buffers entire payload in memory for non-seekable readers
+//
+//   - WriteStreamWithLength() requires both content-length AND seekable reader
+//
+//   - No native support for HTTP chunked transfer encoding
+//
+//     2. Server Compatibility Issues:
+//     Research shows HTTP chunked transfer encoding with WebDAV is unreliable:
+//
+//   - Nginx + FastCGI: Discards request body → 0-byte files (silent data loss)
+//
+//   - Lighttpd: Returns HTTP 411 (Length Required), rejects chunked requests
+//
+//   - Apache + FastCGI: Request body never arrives at application
+//
+//   - Only Apache + mod_php handles chunked encoding reliably (~30-40% of deployments)
+//
+// 3. LTX Header Requirement:
+//   - Must peek at LTX header to extract timestamp before upload
+//   - Peeking consumes data, making the reader non-seekable
+//   - Cannot calculate content-length without fully reading stream
+//
+// Solution: Stage to temporary file
+//
+// To ensure universal compatibility and prevent silent data loss:
+//  1. Extract timestamp from LTX header (required for file metadata)
+//  2. Stream full contents to temporary file on disk
+//  3. Seek back to start of temp file (now seekable + known size)
+//  4. Upload using WriteStreamWithLength() with Content-Length header
+//  5. Clean up temp file
+//
+// Trade-offs:
+//   - Universal compatibility with all WebDAV server configurations
+//   - No risk of silent data loss or failed uploads
+//   - Predictable, reliable behavior
+//   - Additional disk I/O overhead
+//   - Requires local disk space proportional to LTX file size
+//   - Diverges from streaming pattern used by other backends
+//
+// References:
+//   - https://github.com/studio-b12/gowebdav/issues/35 (chunked encoding issues)
+//   - https://github.com/nextcloud/server/issues/7995 (0-byte file bug)
+//   - https://evertpot.com/260/ (WebDAV chunked encoding compatibility)
+func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, rd io.Reader) (info *ltx.FileInfo, err error) {
+	client, err := c.Init(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	filename := litestream.LTXFilePath(c.Path, level, minTXID, maxTXID)
+
+	var buf bytes.Buffer
+	teeReader := io.TeeReader(rd, &buf)
+
+	hdr, _, err := ltx.PeekHeader(teeReader)
+	if err != nil {
+		return nil, fmt.Errorf("extract timestamp from LTX header: %w", err)
+	}
+	timestamp := time.UnixMilli(hdr.Timestamp).UTC()
+
+	// Stage to temporary file to get seekable reader with known size.
+	// This ensures compatibility with all WebDAV servers and avoids the
+	// unreliable chunked transfer encoding that causes silent data loss
+	// on common configurations (Nginx+FastCGI, Lighttpd, Apache+FastCGI).
+	tmpFile, err := os.CreateTemp("", "litestream-webdav-*.ltx")
+	if err != nil {
+		return nil, fmt.Errorf("webdav: cannot create temp file: %w", err)
+	}
+	defer func() {
+		_ = tmpFile.Close()
+		_ = os.Remove(tmpFile.Name())
+	}()
+
+	fullReader := io.MultiReader(&buf, rd)
+
+	size, err := io.Copy(tmpFile, fullReader)
+	if err != nil {
+		return nil, fmt.Errorf("webdav: cannot copy to temp file: %w", err)
+	}
+
+	if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
+		return nil, fmt.Errorf("webdav: cannot seek temp file: %w", err)
+	}
+
+	if err := client.MkdirAll(path.Dir(filename), 0755); err != nil {
+		return nil, fmt.Errorf("webdav: cannot create parent directory %q: %w", path.Dir(filename), err)
+	}
+
+	// Upload with Content-Length header using seekable temp file.
+	// WriteStreamWithLength requires both a seekable reader and known size,
+	// which we now have from the temp file. This avoids chunked encoding
+	// and ensures reliable uploads across all WebDAV server configurations.
+	if err := client.WriteStreamWithLength(filename, tmpFile, size, 0644); err != nil {
+		return nil, fmt.Errorf("webdav: cannot write file %q: %w", filename, err)
+	}
+
+	internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "PUT").Inc()
+	internal.OperationBytesCounterVec.WithLabelValues(ReplicaClientType, "PUT").Add(float64(size))
+
+	return &ltx.FileInfo{
+		Level:     level,
+		MinTXID:   minTXID,
+		MaxTXID:   maxTXID,
+		Size:      size,
+		CreatedAt: timestamp,
+	}, nil
+}
+
+func (c *ReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (_ io.ReadCloser, err error) {
+	client, err := c.Init(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	filename := litestream.LTXFilePath(c.Path, level, minTXID, maxTXID)
+
+	internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "GET").Inc()
+
+	if size > 0 {
+		rc, err := client.ReadStreamRange(filename, offset, size)
+		if err != nil {
+			if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
+				return nil, os.ErrNotExist
+			}
+			return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
+		}
+		return internal.LimitReadCloser(rc, size), nil
+	}
+
+	if offset > 0 {
+		rc, err := client.ReadStream(filename)
+		if err != nil {
+			if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
+				return nil, os.ErrNotExist
+			}
+			return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
+		}
+
+		if _, err := io.CopyN(io.Discard, rc, offset); err != nil {
+			if err == io.EOF || err == io.ErrUnexpectedEOF {
+				_ = rc.Close()
+				return io.NopCloser(bytes.NewReader(nil)), nil
+			}
+			_ = rc.Close()
+			return nil, fmt.Errorf("webdav: cannot skip offset in file %q: %w", filename, err)
+		}
+
+		return rc, nil
+	}
+
+	rc, err := client.ReadStream(filename)
+	if err != nil {
+		if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
+			return nil, os.ErrNotExist
+		}
+		return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
+	}
+	return rc, nil
+}
+
+func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
+	client, err := c.Init(ctx)
+	if err != nil {
+		return err
+	}
+
+	for _, info := range a {
+		filename := litestream.LTXFilePath(c.Path, info.Level, info.MinTXID, info.MaxTXID)
+
+		c.logger.Debug("deleting ltx file", "level", info.Level, "minTXID", info.MinTXID, "maxTXID", info.MaxTXID, "path", filename)
+
+		if err := client.Remove(filename); err != nil && !os.IsNotExist(err) && !gowebdav.IsErrNotFound(err) {
+			return fmt.Errorf("webdav: cannot delete ltx file %q: %w", filename, err)
+		}
+		internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
+	}
+
+	return nil
+}



Test patch:
diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go
index fc83f28..8c5b4b4 100644
--- a/cmd/litestream/main.go
+++ b/cmd/litestream/main.go
@@ -31,6 +31,7 @@ import (
 	"github.com/benbjohnson/litestream/nats"
 	"github.com/benbjohnson/litestream/s3"
 	"github.com/benbjohnson/litestream/sftp"
+	"github.com/benbjohnson/litestream/webdav"
 )
 
 // Build information.
@@ -586,6 +587,11 @@ type ReplicaConfig struct {
 	ConcurrentWrites *bool  `yaml:"concurrent-writes"`
 	HostKey          string `yaml:"host-key"`
 
+	// WebDAV settings
+	WebDAVURL      string `yaml:"webdav-url"`
+	WebDAVUsername string `yaml:"webdav-username"`
+	WebDAVPassword string `yaml:"webdav-password"`
+
 	// NATS settings
 	JWT           string         `yaml:"jwt"`
 	Seed          string         `yaml:"seed"`
@@ -668,6 +674,10 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
 		if r.Client, err = newSFTPReplicaClientFromConfig(c, r); err != nil {
 			return nil, err
 		}
+	case "webdav":
+		if r.Client, err = newWebDAVReplicaClientFromConfig(c, r); err != nil {
+			return nil, err
+		}
 	case "nats":
 		if r.Client, err = newNATSReplicaClientFromConfig(c, r); err != nil {
 			return nil, err
@@ -921,6 +931,60 @@ func newSFTPReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_
 	return client, nil
 }
 
+// newWebDAVReplicaClientFromConfig returns a new instance of webdav.ReplicaClient built from config.
+func newWebDAVReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *webdav.ReplicaClient, err error) {
+	// Ensure URL & constituent parts are not both specified.
+	if c.URL != "" && c.Path != "" {
+		return nil, fmt.Errorf("cannot specify url & path for webdav replica")
+	} else if c.URL != "" && c.WebDAVURL != "" {
+		return nil, fmt.Errorf("cannot specify url & webdav-url for webdav replica")
+	}
+
+	webdavURL, username, password, path := c.WebDAVURL, c.WebDAVUsername, c.WebDAVPassword, c.Path
+
+	// Apply settings from URL, if specified.
+	if c.URL != "" {
+		u, err := url.Parse(c.URL)
+		if err != nil {
+			return nil, err
+		}
+
+		// Build WebDAV URL from scheme and host
+		scheme := "http"
+		if u.Scheme == "webdavs" {
+			scheme = "https"
+		}
+		if webdavURL == "" && u.Host != "" {
+			webdavURL = fmt.Sprintf("%s://%s", scheme, u.Host)
+		}
+
+		// Extract credentials from URL
+		if username == "" && u.User != nil {
+			username = u.User.Username()
+		}
+		if password == "" && u.User != nil {
+			password, _ = u.User.Password()
+		}
+		if path == "" {
+			path = u.Path
+		}
+	}
+
+	// Ensure required settings are set.
+	if webdavURL == "" {
+		return nil, fmt.Errorf("webdav-url required for webdav replica")
+	}
+
+	// Build replica.
+	client := webdav.NewReplicaClient()
+	client.URL = webdavURL
+	client.Username = username
+	client.Password = password
+	client.Path = path
+
+	return client, nil
+}
+
 // newNATSReplicaClientFromConfig returns a new instance of nats.ReplicaClient built from config.
 func newNATSReplicaClientFromConfig(c *ReplicaConfig, _ *litestream.Replica) (_ *nats.ReplicaClient, err error) {
 	// Parse URL if provided to extract bucket name and server URL
@@ -1039,6 +1103,9 @@ func isURL(s string) bool {
 func (c *ReplicaConfig) ReplicaType() string {
 	scheme, _, _, _ := ParseReplicaURL(c.URL)
 	if scheme != "" {
+		if scheme == "webdavs" {
+			return "webdav"
+		}
 		return scheme
 	} else if c.Type != "" {
 		return c.Type
diff --git a/internal/testingutil/testingutil.go b/internal/testingutil/testingutil.go
index 52ea211..e417c35 100644
--- a/internal/testingutil/testingutil.go
+++ b/internal/testingutil/testingutil.go
@@ -26,6 +26,7 @@ import (
 	"github.com/benbjohnson/litestream/nats"
 	"github.com/benbjohnson/litestream/s3"
 	"github.com/benbjohnson/litestream/sftp"
+	"github.com/benbjohnson/litestream/webdav"
 )
 
 var (
@@ -73,6 +74,14 @@ var (
 	sftpPath     = flag.String("sftp-path", os.Getenv("LITESTREAM_SFTP_PATH"), "")
 )
 
+// WebDAV settings
+var (
+	webdavURL      = flag.String("webdav-url", os.Getenv("LITESTREAM_WEBDAV_URL"), "")
+	webdavUsername = flag.String("webdav-username", os.Getenv("LITESTREAM_WEBDAV_USERNAME"), "")
+	webdavPassword = flag.String("webdav-password", os.Getenv("LITESTREAM_WEBDAV_PASSWORD"), "")
+	webdavPath     = flag.String("webdav-path", os.Getenv("LITESTREAM_WEBDAV_PATH"), "")
+)
+
 // NATS settings
 var (
 	natsURL      = flag.String("nats-url", os.Getenv("LITESTREAM_NATS_URL"), "")
@@ -160,6 +169,8 @@ func MustOpenSQLDB(tb testing.TB, path string) *sql.DB {
 		tb.Fatal(err)
 	} else if _, err := d.ExecContext(context.Background(), `PRAGMA journal_mode = wal;`); err != nil {
 		tb.Fatal(err)
+	} else if _, err := d.ExecContext(context.Background(), `PRAGMA busy_timeout = 5000;`); err != nil {
+		tb.Fatal(err)
 	}
 	return d
 }
@@ -187,6 +198,8 @@ func NewReplicaClient(tb testing.TB, typ string) litestream.ReplicaClient {
 		return NewABSReplicaClient(tb)
 	case sftp.ReplicaClientType:
 		return NewSFTPReplicaClient(tb)
+	case webdav.ReplicaClientType:
+		return NewWebDAVReplicaClient(tb)
 	case nats.ReplicaClientType:
 		return NewNATSReplicaClient(tb)
 	default:
@@ -272,6 +285,18 @@ func NewSFTPReplicaClient(tb testing.TB) *sftp.ReplicaClient {
 	return c
 }
 
+// NewWebDAVReplicaClient returns a new client for integration testing.
+func NewWebDAVReplicaClient(tb testing.TB) *webdav.ReplicaClient {
+	tb.Helper()
+
+	c := webdav.NewReplicaClient()
+	c.URL = *webdavURL
+	c.Username = *webdavUsername
+	c.Password = *webdavPassword
+	c.Path = path.Join(*webdavPath, fmt.Sprintf("%016x", rand.Uint64()))
+	return c
+}
+
 // NewNATSReplicaClient returns a new client for integration testing.
 func NewNATSReplicaClient(tb testing.TB) *nats.ReplicaClient {
 	tb.Helper()
diff --git a/store_test.go b/store_test.go
index 16fd4d3..7dcec35 100644
--- a/store_test.go
+++ b/store_test.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"path/filepath"
+	"sync"
 	"testing"
 	"time"
 
@@ -137,8 +138,13 @@ func TestStore_Integration(t *testing.T) {
 	// Channel for insert errors
 	insertErr := make(chan error, 1)
 
-	// Check for any insert errors that occurred before shutdown
+	// WaitGroup to ensure insert goroutine completes before cleanup
+	var wg sync.WaitGroup
+
+	// Wait for insert goroutine to finish before cleanup & surface any errors.
 	defer func() {
+		wg.Wait()
+
 		select {
 		case err := <-insertErr:
 			t.Fatalf("insert error during test: %v", err)
@@ -148,7 +154,9 @@ func TestStore_Integration(t *testing.T) {
 	}()
 
 	// Start goroutine to continuously insert records
+	wg.Add(1)
 	go func() {
+		defer wg.Done()
 		ticker := time.NewTicker(factor * 10 * time.Millisecond)
 		defer ticker.Stop()
 
diff --git a/webdav/replica_client_test.go b/webdav/replica_client_test.go
new file mode 100644
index 0000000..1f7a0b3
--- /dev/null
+++ b/webdav/replica_client_test.go
@@ -0,0 +1,299 @@
+package webdav_test
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/superfly/ltx"
+
+	"github.com/benbjohnson/litestream/webdav"
+)
+
+func TestReplicaClient_Type(t *testing.T) {
+	c := webdav.NewReplicaClient()
+	if got, want := c.Type(), "webdav"; got != want {
+		t.Fatalf("Type()=%v, want %v", got, want)
+	}
+}
+
+func TestReplicaClient_Init_RequiresURL(t *testing.T) {
+	c := webdav.NewReplicaClient()
+	c.URL = ""
+
+	if _, err := c.Init(context.TODO()); err == nil {
+		t.Fatal("expected error when URL is empty")
+	} else if got, want := err.Error(), "webdav url required"; got != want {
+		t.Fatalf("error=%v, want %v", got, want)
+	}
+}
+
+func TestReplicaClient_DeleteAll_NotFound(t *testing.T) {
+	srv := newFakeWebDAVServer()
+	srv.deleteReturn404["/missing"] = true
+
+	ts := httptest.NewServer(srv)
+	defer ts.Close()
+
+	c := newTestReplicaClient(ts.URL)
+	c.Path = "/missing"
+
+	if err := c.DeleteAll(context.Background()); err != nil {
+		t.Fatalf("DeleteAll returned error: %v", err)
+	}
+}
+
+func TestReplicaClient_LTXFiles_PathNotFound(t *testing.T) {
+	srv := newFakeWebDAVServer()
+	srv.propfindReturn404["/missing/ltx/0"] = true
+
+	ts := httptest.NewServer(srv)
+	defer ts.Close()
+
+	c := newTestReplicaClient(ts.URL)
+	c.Path = "/missing"
+
+	itr, err := c.LTXFiles(context.Background(), 0, 0, false)
+	if err != nil {
+		t.Fatalf("LTXFiles returned error: %v", err)
+	}
+	defer itr.Close()
+
+	if itr.Next() {
+		t.Fatal("expected no iterator items when directory is missing")
+	}
+}
+
+func TestReplicaClient_OpenLTXFile_RangeFallback(t *testing.T) {
+	srv := newFakeWebDAVServer()
+	srv.ignoreRange = true // simulate server ignoring Range requests
+
+	ts := httptest.NewServer(srv)
+	defer ts.Close()
+
+	c := newTestReplicaClient(ts.URL)
+	c.Path = "/db"
+
+	payload := []byte("ABCDEFGHIJ")
+	ltxData := buildLTXPayload(1, 2, payload)
+
+	if _, err := c.WriteLTXFile(context.Background(), 0, 1, 2, bytes.NewReader(ltxData)); err != nil {
+		t.Fatalf("WriteLTXFile failed: %v", err)
+	}
+
+	rc, err := c.OpenLTXFile(context.Background(), 0, 1, 2, 0, int64(len(payload)))
+	if err != nil {
+		t.Fatalf("OpenLTXFile failed: %v", err)
+	}
+	defer rc.Close()
+
+	got, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatalf("ReadAll failed: %v", err)
+	}
+
+	if want := ltxData[:len(payload)]; !bytes.Equal(got, want) {
+		t.Fatalf("OpenLTXFile range fallback returned %q, want %q", got, want)
+	}
+}
+
+func TestReplicaClient_OpenLTXFile_OffsetOnly(t *testing.T) {
+	srv := newFakeWebDAVServer()
+
+	ts := httptest.NewServer(srv)
+	defer ts.Close()
+
+	c := newTestReplicaClient(ts.URL)
+	c.Path = "/db"
+
+	payload := []byte("ABCDEFGHIJ")
+	ltxData := buildLTXPayload(10, 12, payload)
+
+	if _, err := c.WriteLTXFile(context.Background(), 0, 10, 12, bytes.NewReader(ltxData)); err != nil {
+		t.Fatalf("WriteLTXFile failed: %v", err)
+	}
+
+	offset := int64(len(ltxData) - len(payload)/2)
+	rc, err := c.OpenLTXFile(context.Background(), 0, 10, 12, offset, 0)
+	if err != nil {
+		t.Fatalf("OpenLTXFile failed: %v", err)
+	}
+	defer rc.Close()
+
+	got, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatalf("ReadAll failed: %v", err)
+	}
+
+	if want := ltxData[offset:]; !bytes.Equal(got, want) {
+		t.Fatalf("OpenLTXFile offset reader returned %q, want %q", got, want)
+	}
+}
+
+// newTestReplicaClient returns a replica client pointed at a test server.
+func newTestReplicaClient(baseURL string) *webdav.ReplicaClient {
+	c := webdav.NewReplicaClient()
+	c.URL = baseURL
+	c.Timeout = time.Second
+	return c
+}
+
+// buildLTXPayload constructs a minimal valid LTX file with payload data.
+func buildLTXPayload(minTXID, maxTXID ltx.TXID, payload []byte) []byte {
+	hdr := ltx.Header{
+		Version:   1,
+		PageSize:  4096,
+		Commit:    1,
+		MinTXID:   minTXID,
+		MaxTXID:   maxTXID,
+		Timestamp: time.Now().UnixMilli(),
+	}
+	headerBytes, _ := hdr.MarshalBinary()
+	return append(headerBytes, payload...)
+}
+
+// fakeWebDAVServer provides a minimal in-memory WebDAV implementation for tests.
+type fakeWebDAVServer struct {
+	mu                sync.Mutex
+	files             map[string][]byte
+	ignoreRange       bool
+	deleteReturn404   map[string]bool
+	propfindReturn404 map[string]bool
+}
+
+func newFakeWebDAVServer() *fakeWebDAVServer {
+	return &fakeWebDAVServer{
+		files:             make(map[string][]byte),
+		deleteReturn404:   make(map[string]bool),
+		propfindReturn404: make(map[string]bool),
+	}
+}
+
+func (s *fakeWebDAVServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	switch r.Method {
+	case http.MethodOptions:
+		w.WriteHeader(http.StatusOK)
+	case "MKCOL":
+		w.WriteHeader(http.StatusCreated)
+	case http.MethodPut:
+		body, _ := io.ReadAll(r.Body)
+		s.files[r.URL.Path] = body
+		w.WriteHeader(http.StatusCreated)
+	case http.MethodGet:
+		s.handleGet(w, r)
+	case http.MethodDelete:
+		s.handleDelete(w, r)
+	case "PROPFIND":
+		s.handlePropfind(w, r)
+	default:
+		w.WriteHeader(http.StatusNotImplemented)
+	}
+}
+
+func (s *fakeWebDAVServer) handleGet(w http.ResponseWriter, r *http.Request) {
+	data, ok := s.files[r.URL.Path]
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	rangeHeader := r.Header.Get("Range")
+	if rangeHeader != "" && !s.ignoreRange {
+		start, end, err := parseRange(rangeHeader, len(data))
+		if err != nil {
+			w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
+			return
+		}
+
+		w.Header().Set("Content-Length", strconv.Itoa(end-start))
+		w.WriteHeader(http.StatusPartialContent)
+		_, _ = w.Write(data[start:end])
+		return
+	}
+
+	w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write(data)
+}
+
+func (s *fakeWebDAVServer) handleDelete(w http.ResponseWriter, r *http.Request) {
+	trimmed := strings.TrimSuffix(r.URL.Path, "/")
+	if s.deleteReturn404[trimmed] {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	if _, ok := s.files[r.URL.Path]; ok {
+		delete(s.files, r.URL.Path)
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+
+	w.WriteHeader(http.StatusNotFound)
+}
+
+func (s *fakeWebDAVServer) handlePropfind(w http.ResponseWriter, r *http.Request) {
+	trimmed := strings.TrimSuffix(r.URL.Path, "/")
+	if s.propfindReturn404[trimmed] {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	response := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
+<d:multistatus xmlns:d="DAV:">
+  <d:response>
+    <d:href>%s/</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:resourcetype><d:collection/></d:resourcetype>
+        <d:getcontentlength>0</d:getcontentlength>
+        <d:getlastmodified>%s</d:getlastmodified>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+</d:multistatus>`, trimmed, time.Now().UTC().Format(http.TimeFormat))
+
+	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+	w.Header().Set("Content-Length", strconv.Itoa(len(response)))
+	w.WriteHeader(207)
+	_, _ = w.Write([]byte(response))
+}
+
+func parseRange(header string, size int) (start, end int, err error) {
+	if !strings.HasPrefix(header, "bytes=") {
+		return 0, 0, fmt.Errorf("unsupported range header: %s", header)
+	}
+	parts := strings.Split(strings.TrimPrefix(header, "bytes="), "-")
+	if len(parts) != 2 {
+		return 0, 0, fmt.Errorf("invalid range header: %s", header)
+	}
+	start, err = strconv.Atoi(parts[0])
+	if err != nil {
+		return 0, 0, err
+	}
+	if parts[1] == "" {
+		end = size
+	} else {
+		end, err = strconv.Atoi(parts[1])
+		if err != nil {
+			return 0, 0, err
+		}
+		end++ // range end is inclusive
+	}
+
+	if start < 0 || end > size || start >= end {
+		return 0, 0, fmt.Errorf("invalid range [%d, %d) for size %d", start, end, size)
+	}
+	return start, end, nil
+}