Repository: benbjohnson/litestream

Issue:
retention does not work for multi-object S3 in AWS compatible serves
<!--- Please provide as much detail as possible to help us reproduce and fix the issue -->
<!--- For general questions, please use GitHub Discussions instead -->

## Bug Description

Litestream's L0 retention enforcement fails when using OCI Object Storage's S3-compatible API. The DeleteObjects operation returns a 400 InvalidRequest error due to a missing Content-MD5 header, which OCI requires for multi-object delete requests. This prevents cleanup of old LTX files, leading to storage accumulation. Uploads (PUT) work fine after setting AWS_REQUEST_CHECKSUM_CALCULATION=when_required, but deletes fail regardless.

## Environment

**Litestream version:**
<!--- Run `litestream version` and paste output here -->
```text
v0.5.2
```

**Operating system & version:**
<!--- e.g., Ubuntu 22.04, macOS 14.0, Windows 11 -->
Linux (likely Ubuntu in Docker container)

**Installation method:**
<!--- e.g., Docker, binary download, built from source -->
Docker

**Storage backend:**
<!--- e.g., S3, GCS, Azure, SFTP, file -->
S3 (OCI Object Storage

## Steps to Reproduce
<!--- Provide a minimal reproducible example if possible -->

1. Set up OCI Object Storage bucket with S3 compatibility enabled and create access keys.
2. Configure litestream.yml with S3 replica pointing to OCI endpoint, force-path-style: true.
3. Set environment variable AWS_REQUEST_CHECKSUM_CALCULATION=when_required.
4. Run `litestream replicate` on a SQLite DB with writes to trigger compaction (level 1).
5. Wait for "compaction complete level=1" log, which triggers L0 retention enforcement.

**Expected behavior:**
<!--- What you expected to happen -->
Old LTX files in the S3 bucket (under path/0000/) are deleted successfully via DeleteObjects, reducing storage usage.

**Actual behavior:**
<!--- What actually happened -->
L0 retention enforcement fails with error: "remove ltx files: s3: delete batch of X objects: operation error S3: DeleteObjects, https response error StatusCode: 400, ... api error InvalidRequest: Missing required header for this request: Content-MD5". LTX files accumulate without cleanup.

## Configuration
<!--- Include relevant parts of your litestream.yml -->
<!--- IMPORTANT: Remove sensitive information like credentials, keys, and URLs -->
<details><summary>litestream.yml</summary>

```yaml
access-key-id: [REDACTED]
secret-access-key: [REDACTED]

dbs:
  - path: /data/cagliostr.sqlite
    replicas:
      - type: s3
        bucket: cagliostr-backup
        path: cagliostr.sqlite
        region: ap-tokyo-1
        endpoint: https://[REDACTED].compat.objectstorage.ap-tokyo-1.oraclecloud.com
        force-path-style: true
        sync-interval: 10s
        max_parts_count: 400000
        part_size: 5242880
```

</details>

## Logs
<!--- Include relevant error messages or logs -->
<!--- For more detail, run litestream with -trace flag -->
<!--- For long logs, consider using https://gist.github.com -->
<details><summary>Log output</summary>

```text
time=2025-10-22T14:30:30.054Z level=ERROR msg="enforce L0 retention" db=cagliostr.sqlite error="remove ltx files: s3: delete batch of 23 objects: operation error S3: DeleteObjects, https response error StatusCode: 400, RequestID: nrt-1:Ceyr9QK9yfJpcd2tDbk39REw05oNuGQ0yX5ENmQJqQ-1fWgAbIBQ5ZRg2mrmBtlE, HostID: , api error InvalidRequest: Missing required header for this request: Content-MD5"
time=2025-10-22T14:30:30.055Z level=INFO msg="compaction complete" level=1 txid.min=0000000000000018 txid.max=0000000000000018 size=10153
```

</details>

## Additional Context

- Environment variable AWS_REQUEST_CHECKSUM_CALCULATION=when_required fixes PUT upload issues (x-amz-content-sha256 errors), but does not resolve DeleteObjects Content-MD5 problem.
- Workaround: Set retention: 0h or extend compaction intervals (e.g., level 1 to 24h) to reduce enforcement frequency, but this is not ideal for storage management.
- OCI S3 compatibility requires Content-MD5 for DeleteObjects body hash, per AWS spec, but AWS SDK v2 (used by Litestream) does not add it by default.
- Similar issues reported in other S3-compatible storages (e.g., MinIO, Trino), but no open Litestream issues found. Tested on OCI Tokyo region (ap-tokyo-1).


Hints from issue comments:
@mattn: https://github.com/aws/aws-sdk-go-v2/discussions/2960

It seems easy to add Content-MD5
```diff
diff --git a/s3/replica_client.go b/s3/replica_client.go
index ecd21d8..ccc0512 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -378,6 +378,16 @@ func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, ma
 	return info, nil
 }
 
+func withContentMD5(o *s3.Options) {
+	o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
+		stack.Initialize.Remove("AWSChecksum:SetupInputContext")
+		stack.Build.Remove("AWSChecksum:RequestMetricsTracking")
+		stack.Finalize.Remove("AWSChecksum:ComputeInputPayloadChecksum")
+		stack.Finalize.Remove("addInputChecksumTrailer")
+		return smithyhttp.AddContentChecksumMiddleware(stack)
+	})
+}
+
 // DeleteLTXFiles deletes one or more LTX files.
 func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
 	if err := c.Init(ctx); err != nil {
@@ -405,7 +415,7 @@ func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) e
 		out, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
 			Bucket: aws.String(c.Bucket),
 			Delete: &types.Delete{Objects: objIDs[:n], Quiet: aws.Bool(true)},
-		})
+		}, withContentMD5)
 		if err != nil {
 			return fmt.Errorf("s3: delete batch of %d objects: %w", n, err)
 		} else if err := deleteOutputError(out); err != nil {
@@ -452,7 +462,7 @@ func (c *ReplicaClient) DeleteAll(ctx context.Context) error {
 		out, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
 			Bucket: aws.String(c.Bucket),
 			Delete: &types.Delete{Objects: objIDs[:n], Quiet: aws.Bool(true)},
-		})
+		}, withContentMD5)
 		if err != nil {
 			return fmt.Errorf("s3: delete all batch of %d objects: %w", n, err)
 		} else if err := deleteOutputError(out); err != nil {
```@bbaterdene-gh: @mattn 
> Environment variable AWS_REQUEST_CHECKSUM_CALCULATION=when_required fixes PUT upload issues (x-amz-content-sha256 errors), but does not resolve DeleteObjects Content-MD5 problem.

Yeah, I also encountered this problem. PUT request was failing because of x-amz-content-sha256 is not set, and I saw [this blog post](https://www.ateam-oracle.com/post/using-oci-os-s3-interface) and set the environment variables. But DELETE operations fail because OCI requires Content-MD5.

Solution patch:
diff --git a/go.mod b/go.mod
index 8bbb05c..2a0baae 100644
--- a/go.mod
+++ b/go.mod
@@ -32,9 +32,16 @@ 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
 	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/pkg/xattr v0.4.9 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/sirupsen/logrus v1.9.3 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/sync v0.16.0 // indirect
 	modernc.org/libc v1.66.3 // indirect
diff --git a/go.sum b/go.sum
index d5bb101..6c045fb 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/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=
 cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
 filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
@@ -92,10 +94,13 @@ github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBF
 github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsouza/fake-gcs-server v1.47.3 h1:L1mZfEd7cU8hnoHU2Rch9bq9HPS0CR0oySZdGMHygU4=
+github.com/fsouza/fake-gcs-server v1.47.3/go.mod h1:vqUZbI12uy9IkRQ54Q4p5AniQsSiUq8alO9Nv2egMmA=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
 github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -135,6 +140,8 @@ github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdf
 github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
+github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
 github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -144,6 +151,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF
 github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
 github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
 github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
+github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
+github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
+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/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
@@ -189,6 +200,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
 github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
 github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
+github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
+github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
@@ -206,11 +219,14 @@ 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/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=
 github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -280,7 +296,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/gs/replica_client.go b/gs/replica_client.go
index 3180475..9ae6902 100644
--- a/gs/replica_client.go
+++ b/gs/replica_client.go
@@ -173,7 +173,15 @@ func (c *ReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, max
 
 	key := litestream.LTXFilePath(c.Path, level, minTXID, maxTXID)
 
-	r, err := c.bkt.Object(key).NewRangeReader(ctx, offset, size)
+	// In the GCS client, a length of -1 reads to EOF while 0 returns no data.
+	// Callers pass size=0 to indicate "entire object" so translate that here.
+	// See: https://pkg.go.dev/cloud.google.com/go/storage#ObjectHandle.NewRangeReader
+	length := size
+	if length <= 0 {
+		length = -1
+	}
+
+	r, err := c.bkt.Object(key).NewRangeReader(ctx, offset, length)
 	if isNotExists(err) {
 		return nil, os.ErrNotExist
 	} else if err != nil {
diff --git a/s3/replica_client.go b/s3/replica_client.go
index ecd21d8..e7dad3c 100644
--- a/s3/replica_client.go
+++ b/s3/replica_client.go
@@ -3,7 +3,9 @@ package s3
 import (
 	"bytes"
 	"context"
+	"crypto/md5"
 	"crypto/tls"
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"io"
@@ -25,7 +27,9 @@ import (
 	"github.com/aws/aws-sdk-go-v2/service/s3"
 	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 	"github.com/aws/smithy-go"
+	smithyxml "github.com/aws/smithy-go/encoding/xml"
 	"github.com/aws/smithy-go/middleware"
+	smithytime "github.com/aws/smithy-go/time"
 	smithyhttp "github.com/aws/smithy-go/transport/http"
 	"github.com/superfly/ltx"
 
@@ -45,6 +49,10 @@ const MaxKeys = 1000
 // DefaultRegion is the region used if one is not specified.
 const DefaultRegion = "us-east-1"
 
+// contentMD5StackKey is used to pass the precomputed Content-MD5 checksum
+// through the middleware stack from Serialize to Finalize phase.
+type contentMD5StackKey struct{}
+
 var _ litestream.ReplicaClient = (*ReplicaClient)(nil)
 
 // ReplicaClient is a client for writing LTX files to S3.
@@ -170,23 +178,8 @@ func (c *ReplicaClient) Init(ctx context.Context) (err error) {
 	s3Opts := []func(*s3.Options){
 		func(o *s3.Options) {
 			o.UsePathStyle = c.ForcePathStyle
-			// Add User-Agent for telemetry (similar to Azure's ApplicationID)
-			o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
-				return stack.Build.Add(
-					middleware.BuildMiddlewareFunc(
-						"LitestreamUserAgent",
-						func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (
-							out middleware.BuildOutput, metadata middleware.Metadata, err error,
-						) {
-							if req, ok := in.Request.(*smithyhttp.Request); ok {
-								req.Header.Add("User-Agent", "litestream")
-							}
-							return next.HandleBuild(ctx, in)
-						},
-					),
-					middleware.After,
-				)
-			})
+			// Add User-Agent and Content-MD5 middleware.
+			o.APIOptions = append(o.APIOptions, litestreamAPIOption())
 		},
 	}
 
@@ -378,6 +371,230 @@ func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, ma
 	return info, nil
 }
 
+func litestreamAPIOption() func(*middleware.Stack) error {
+	return func(stack *middleware.Stack) error {
+		if err := stack.Serialize.Add(
+			middleware.SerializeMiddlewareFunc(
+				"LitestreamComputeDeleteContentMD5",
+				func(ctx context.Context, in middleware.SerializeInput, next middleware.SerializeHandler) (
+					out middleware.SerializeOutput, metadata middleware.Metadata, err error,
+				) {
+					if middleware.GetOperationName(ctx) != "DeleteObjects" {
+						return next.HandleSerialize(ctx, in)
+					}
+
+					input, ok := in.Parameters.(*s3.DeleteObjectsInput)
+					if !ok || input == nil || input.Delete == nil || len(input.Delete.Objects) == 0 {
+						return next.HandleSerialize(ctx, in)
+					}
+
+					checksum, err := computeDeleteObjectsContentMD5(input.Delete)
+					if err != nil {
+						return out, metadata, err
+					}
+					if checksum != "" {
+						ctx = middleware.WithStackValue(ctx, contentMD5StackKey{}, checksum)
+					}
+
+					return next.HandleSerialize(ctx, in)
+				},
+			),
+			middleware.Before,
+		); err != nil {
+			return err
+		}
+
+		if err := stack.Build.Add(
+			middleware.BuildMiddlewareFunc(
+				"LitestreamUserAgent",
+				func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (
+					out middleware.BuildOutput, metadata middleware.Metadata, err error,
+				) {
+					if req, ok := in.Request.(*smithyhttp.Request); ok {
+						current := req.Header.Get("User-Agent")
+						if current == "" {
+							req.Header.Set("User-Agent", "litestream")
+						} else if !strings.Contains(current, "litestream") {
+							req.Header.Set("User-Agent", "litestream "+current)
+						}
+					}
+					return next.HandleBuild(ctx, in)
+				},
+			),
+			middleware.After,
+		); err != nil {
+			return err
+		}
+
+		md5Middleware := func() middleware.FinalizeMiddleware {
+			return middleware.FinalizeMiddlewareFunc(
+				"LitestreamDeleteContentMD5",
+				func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
+					out middleware.FinalizeOutput, metadata middleware.Metadata, err error,
+				) {
+					if middleware.GetOperationName(ctx) != "DeleteObjects" {
+						return next.HandleFinalize(ctx, in)
+					}
+
+					checksum, _ := middleware.GetStackValue(ctx, contentMD5StackKey{}).(string)
+					if checksum == "" {
+						return next.HandleFinalize(ctx, in)
+					}
+
+					req, ok := in.Request.(*smithyhttp.Request)
+					if !ok {
+						return next.HandleFinalize(ctx, in)
+					}
+					if req.Header.Get("Content-MD5") == "" {
+						req.Header.Set("Content-MD5", checksum)
+					}
+
+					return next.HandleFinalize(ctx, in)
+				},
+			)
+		}
+
+		// Try to insert before AWS's checksum middleware for optimal ordering.
+		// If that middleware doesn't exist (e.g., different SDK version), add at the end.
+		// Our middleware checks if Content-MD5 is already set, so order is not critical.
+		if err := stack.Finalize.Insert(md5Middleware(), "AWSChecksum:ComputeInputPayloadChecksum", middleware.Before); err != nil {
+			if err := stack.Finalize.Add(md5Middleware(), middleware.After); err != nil {
+				return err
+			}
+		}
+
+		return nil
+	}
+}
+
+func computeDeleteObjectsContentMD5(deleteInput *types.Delete) (string, error) {
+	if deleteInput == nil {
+		return "", nil
+	}
+
+	payload, err := marshalDeleteObjects(deleteInput)
+	if err != nil {
+		return "", err
+	}
+	if len(payload) == 0 {
+		return "", nil
+	}
+
+	sum := md5.Sum(payload)
+	return base64.StdEncoding.EncodeToString(sum[:]), nil
+}
+
+func marshalDeleteObjects(deleteInput *types.Delete) ([]byte, error) {
+	if deleteInput == nil {
+		return nil, nil
+	}
+
+	var buf bytes.Buffer
+	encoder := smithyxml.NewEncoder(&buf)
+	root := smithyxml.StartElement{
+		Name: smithyxml.Name{
+			Local: "Delete",
+		},
+		Attr: []smithyxml.Attr{
+			smithyxml.NewNamespaceAttribute("", "http://s3.amazonaws.com/doc/2006-03-01/"),
+		},
+	}
+
+	if err := encodeDeleteDocument(deleteInput, encoder.RootElement(root)); err != nil {
+		return nil, err
+	}
+
+	return encoder.Bytes(), nil
+}
+
+func encodeDeleteDocument(v *types.Delete, value smithyxml.Value) error {
+	defer value.Close()
+	if v.Objects != nil {
+		root := smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "Object",
+			},
+		}
+		el := value.FlattenedElement(root)
+		if err := encodeObjectIdentifierList(v.Objects, el); err != nil {
+			return err
+		}
+	}
+	if v.Quiet != nil {
+		root := smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "Quiet",
+			},
+		}
+		el := value.MemberElement(root)
+		el.Boolean(*v.Quiet)
+	}
+	return nil
+}
+
+func encodeObjectIdentifierList(v []types.ObjectIdentifier, value smithyxml.Value) error {
+	if !value.IsFlattened() {
+		defer value.Close()
+	}
+
+	array := value.Array()
+	for i := range v {
+		member := array.Member()
+		if err := encodeObjectIdentifier(&v[i], member); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// encodeObjectIdentifier mirrors the AWS SDK's XML serializer for DeleteObjects.
+// This ensures our precomputed Content-MD5 matches the actual request body.
+// Includes all ObjectIdentifier fields as of AWS SDK v2 (2024).
+func encodeObjectIdentifier(v *types.ObjectIdentifier, value smithyxml.Value) error {
+	defer value.Close()
+	if v.ETag != nil {
+		el := value.MemberElement(smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "ETag",
+			},
+		})
+		el.String(*v.ETag)
+	}
+	if v.Key != nil {
+		el := value.MemberElement(smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "Key",
+			},
+		})
+		el.String(*v.Key)
+	}
+	if v.LastModifiedTime != nil {
+		el := value.MemberElement(smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "LastModifiedTime",
+			},
+		})
+		el.String(smithytime.FormatHTTPDate(*v.LastModifiedTime))
+	}
+	if v.Size != nil {
+		el := value.MemberElement(smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "Size",
+			},
+		})
+		el.Long(*v.Size)
+	}
+	if v.VersionId != nil {
+		el := value.MemberElement(smithyxml.StartElement{
+			Name: smithyxml.Name{
+				Local: "VersionId",
+			},
+		})
+		el.String(*v.VersionId)
+	}
+	return nil
+}
+
 // DeleteLTXFiles deletes one or more LTX files.
 func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
 	if err := c.Init(ctx); err != nil {



Test patch:
diff --git a/gs/replica_client_test.go b/gs/replica_client_test.go
new file mode 100644
index 0000000..e54d60a
--- /dev/null
+++ b/gs/replica_client_test.go
@@ -0,0 +1,81 @@
+package gs
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/fsouza/fake-gcs-server/fakestorage"
+	"github.com/superfly/ltx"
+)
+
+func ltxTestData(tb testing.TB, minTXID, maxTXID ltx.TXID, payload []byte) []byte {
+	tb.Helper()
+
+	hdr := ltx.Header{
+		Version:   1,
+		PageSize:  4096,
+		Commit:    1,
+		MinTXID:   minTXID,
+		MaxTXID:   maxTXID,
+		Timestamp: time.Now().UnixMilli(),
+	}
+
+	buf, err := hdr.MarshalBinary()
+	if err != nil {
+		tb.Fatalf("marshal header: %v", err)
+	}
+
+	return append(buf, payload...)
+}
+
+func setupTestClient(tb testing.TB) (*ReplicaClient, *fakestorage.Server) {
+	tb.Helper()
+
+	server, err := fakestorage.NewServerWithOptions(fakestorage.Options{NoListener: true})
+	if err != nil {
+		tb.Fatalf("new server: %v", err)
+	}
+
+	bucket := "litestream-test"
+	server.CreateBucketWithOpts(fakestorage.CreateBucketOpts{Name: bucket})
+
+	client := server.Client()
+
+	rc := NewReplicaClient()
+	rc.client = client
+	rc.bkt = client.Bucket(bucket)
+	rc.Bucket = bucket
+	rc.Path = "integration"
+
+	return rc, server
+}
+
+func TestReplicaClient_OpenLTXFileReadsFullObject(t *testing.T) {
+	rc, server := setupTestClient(t)
+	defer server.Stop()
+
+	ctx := context.Background()
+	data := ltxTestData(t, ltx.TXID(1), ltx.TXID(1), []byte("hello"))
+
+	if _, err := rc.WriteLTXFile(ctx, 0, ltx.TXID(1), ltx.TXID(1), bytes.NewReader(data)); err != nil {
+		t.Fatalf("WriteLTXFile: %v", err)
+	}
+
+	r, err := rc.OpenLTXFile(ctx, 0, ltx.TXID(1), ltx.TXID(1), 0, 0)
+	if err != nil {
+		t.Fatalf("OpenLTXFile: %v", err)
+	}
+	defer r.Close()
+
+	out, err := io.ReadAll(r)
+	if err != nil {
+		t.Fatalf("ReadAll: %v", err)
+	}
+
+	if !bytes.Equal(out, data) {
+		t.Fatalf("unexpected replica content: got %q, want %q", out, data)
+	}
+}
diff --git a/s3/replica_client_test.go b/s3/replica_client_test.go
index 314f024..d6721e6 100644
--- a/s3/replica_client_test.go
+++ b/s3/replica_client_test.go
@@ -2,12 +2,23 @@ package s3
 
 import (
 	"context"
+	"crypto/md5"
+	"encoding/base64"
 	"errors"
+	"io"
+	"log/slog"
+	"net/http"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/s3"
+	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 	"github.com/aws/smithy-go"
+	"github.com/aws/smithy-go/middleware"
+	smithyhttp "github.com/aws/smithy-go/transport/http"
+	"github.com/superfly/ltx"
 )
 
 // mockAPIError implements smithy.APIError for testing
@@ -213,6 +224,143 @@ func TestReplicaClient_HTTPClientConfiguration(t *testing.T) {
 	})
 }
 
+func TestReplicaClientDeleteLTXFiles_ContentMD5(t *testing.T) {
+	var callCount int
+
+	httpClient := smithyhttp.ClientDoFunc(func(r *http.Request) (*http.Response, error) {
+		t.Helper()
+		callCount++
+
+		if r.Method != http.MethodPost {
+			t.Fatalf("unexpected method: %s", r.Method)
+		}
+		if !strings.Contains(r.URL.RawQuery, "delete") {
+			t.Fatalf("unexpected query: %s", r.URL.RawQuery)
+		}
+
+		if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "litestream") {
+			t.Fatalf("expected User-Agent to contain litestream, got %q", ua)
+		}
+
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("read body: %v", err)
+		}
+		r.Body.Close()
+
+		got := r.Header.Get("Content-MD5")
+		if got == "" {
+			t.Fatal("expected Content-MD5 header")
+		}
+
+		sum := md5.Sum(body)
+		want := base64.StdEncoding.EncodeToString(sum[:])
+		if got != want {
+			t.Fatalf("unexpected Content-MD5 header: got %q, want %q", got, want)
+		}
+
+		resp := &http.Response{
+			StatusCode: http.StatusOK,
+			Header:     http.Header{"Content-Type": []string{"application/xml"}},
+			Body: io.NopCloser(strings.NewReader(
+				`<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`,
+			)),
+		}
+		return resp, nil
+	})
+
+	cfg := aws.Config{
+		Region:      "us-east-1",
+		Credentials: aws.NewCredentialsCache(aws.AnonymousCredentials{}),
+		HTTPClient:  httpClient,
+	}
+
+	c := NewReplicaClient()
+	c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+	c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
+		o.APIOptions = append(o.APIOptions, litestreamAPIOption())
+	})
+	c.Bucket = "test-bucket"
+	c.Path = "test-path"
+
+	files := []*ltx.FileInfo{
+		{Level: 0, MinTXID: 1, MaxTXID: 1},
+		{Level: 0, MinTXID: 2, MaxTXID: 2},
+	}
+
+	if err := c.DeleteLTXFiles(context.Background(), files); err != nil {
+		t.Fatalf("DeleteLTXFiles: %v", err)
+	}
+	if callCount != 1 {
+		t.Fatalf("unexpected call count: %d", callCount)
+	}
+}
+
+func TestReplicaClientDeleteLTXFiles_PreexistingContentMD5(t *testing.T) {
+	const preexistingMD5 = "preexisting-checksum-value"
+	var callCount int
+
+	httpClient := smithyhttp.ClientDoFunc(func(r *http.Request) (*http.Response, error) {
+		t.Helper()
+		callCount++
+
+		got := r.Header.Get("Content-MD5")
+		if got != preexistingMD5 {
+			t.Fatalf("middleware should not override existing Content-MD5: got %q, want %q", got, preexistingMD5)
+		}
+
+		resp := &http.Response{
+			StatusCode: http.StatusOK,
+			Header:     http.Header{"Content-Type": []string{"application/xml"}},
+			Body: io.NopCloser(strings.NewReader(
+				`<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`,
+			)),
+		}
+		return resp, nil
+	})
+
+	cfg := aws.Config{
+		Region:      "us-east-1",
+		Credentials: aws.NewCredentialsCache(aws.AnonymousCredentials{}),
+		HTTPClient:  httpClient,
+	}
+
+	c := NewReplicaClient()
+	c.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+	c.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
+		o.APIOptions = append(o.APIOptions, litestreamAPIOption())
+		o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
+			return stack.Finalize.Add(
+				middleware.FinalizeMiddlewareFunc(
+					"InjectPreexistingContentMD5",
+					func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
+						out middleware.FinalizeOutput, metadata middleware.Metadata, err error,
+					) {
+						if req, ok := in.Request.(*smithyhttp.Request); ok {
+							req.Header.Set("Content-MD5", preexistingMD5)
+						}
+						return next.HandleFinalize(ctx, in)
+					},
+				),
+				middleware.Before,
+			)
+		})
+	})
+	c.Bucket = "test-bucket"
+	c.Path = "test-path"
+
+	files := []*ltx.FileInfo{
+		{Level: 0, MinTXID: 1, MaxTXID: 1},
+	}
+
+	if err := c.DeleteLTXFiles(context.Background(), files); err != nil {
+		t.Fatalf("DeleteLTXFiles: %v", err)
+	}
+	if callCount != 1 {
+		t.Fatalf("unexpected call count: %d", callCount)
+	}
+}
+
 // TestReplicaClient_CredentialConfiguration tests credential setup
 func TestReplicaClient_CredentialConfiguration(t *testing.T) {
 	t.Run("WithStaticCredentials", func(t *testing.T) {
@@ -265,3 +413,234 @@ func TestReplicaClient_DefaultRegionUsage(t *testing.T) {
 		}
 	})
 }
+
+func TestMarshalDeleteObjects_EdgeCases(t *testing.T) {
+	t.Run("EmptyObjects", func(t *testing.T) {
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		if !strings.Contains(string(xml), "<Delete") {
+			t.Error("expected XML to contain Delete element")
+		}
+	})
+
+	t.Run("KeyWithSpecialCharacters", func(t *testing.T) {
+		key := "test/path with spaces & <special> chars.txt"
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{Key: aws.String(key)},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+		if !strings.Contains(xmlStr, "test/path with spaces &amp; &lt;special&gt; chars.txt") {
+			t.Errorf("expected XML to properly escape special characters, got: %s", xmlStr)
+		}
+	})
+
+	t.Run("KeyWithUnicode", func(t *testing.T) {
+		key := "test/文件.txt"
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{Key: aws.String(key)},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+		if !strings.Contains(xmlStr, key) {
+			t.Errorf("expected XML to contain unicode key, got: %s", xmlStr)
+		}
+	})
+
+	t.Run("LargeBatch", func(t *testing.T) {
+		const count = 1000
+		objects := make([]types.ObjectIdentifier, count)
+		for i := 0; i < count; i++ {
+			objects[i] = types.ObjectIdentifier{
+				Key: aws.String(string(rune('a' + (i % 26)))),
+			}
+		}
+		deleteInput := &types.Delete{
+			Objects: objects,
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed for %d objects: %v", count, err)
+		}
+		if len(xml) == 0 {
+			t.Error("expected non-empty XML output")
+		}
+	})
+
+	t.Run("NilOptionalFields", func(t *testing.T) {
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{
+					Key: aws.String("test-key"),
+				},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+		if !strings.Contains(xmlStr, "<Key>test-key</Key>") {
+			t.Errorf("expected Key element in XML, got: %s", xmlStr)
+		}
+		if strings.Contains(xmlStr, "<ETag>") {
+			t.Error("expected no ETag element when nil")
+		}
+		if strings.Contains(xmlStr, "<VersionId>") {
+			t.Error("expected no VersionId element when nil")
+		}
+	})
+
+	t.Run("QuietFlag", func(t *testing.T) {
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{Key: aws.String("test")},
+			},
+			Quiet: aws.Bool(true),
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+		if !strings.Contains(xmlStr, "<Quiet>true</Quiet>") {
+			t.Errorf("expected Quiet element to be true, got: %s", xmlStr)
+		}
+	})
+}
+
+func TestEncodeObjectIdentifier_AllFields(t *testing.T) {
+	t.Run("AllFieldsPopulated", func(t *testing.T) {
+		timestamp, err := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
+		if err != nil {
+			t.Fatalf("failed to parse timestamp: %v", err)
+		}
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{
+					Key:              aws.String("my-object-key"),
+					ETag:             aws.String("abc123etag"),
+					VersionId:        aws.String("version-456"),
+					LastModifiedTime: aws.Time(timestamp),
+					Size:             aws.Int64(12345),
+				},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+
+		if !strings.Contains(xmlStr, "<Key>my-object-key</Key>") {
+			t.Error("expected Key element")
+		}
+		if !strings.Contains(xmlStr, "<ETag>abc123etag</ETag>") {
+			t.Error("expected ETag element")
+		}
+		if !strings.Contains(xmlStr, "<VersionId>version-456</VersionId>") {
+			t.Error("expected VersionId element")
+		}
+		if !strings.Contains(xmlStr, "<LastModifiedTime>") {
+			t.Error("expected LastModifiedTime element")
+		}
+		if !strings.Contains(xmlStr, "<Size>12345</Size>") {
+			t.Error("expected Size element with value 12345")
+		}
+	})
+
+	t.Run("OnlyRequiredKey", func(t *testing.T) {
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{
+					Key: aws.String("only-key"),
+				},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+
+		if !strings.Contains(xmlStr, "<Key>only-key</Key>") {
+			t.Error("expected Key element")
+		}
+		if strings.Contains(xmlStr, "<ETag>") {
+			t.Error("expected no ETag element when nil")
+		}
+		if strings.Contains(xmlStr, "<VersionId>") {
+			t.Error("expected no VersionId element when nil")
+		}
+	})
+
+	t.Run("FieldOrder", func(t *testing.T) {
+		deleteInput := &types.Delete{
+			Objects: []types.ObjectIdentifier{
+				{
+					Key:       aws.String("test"),
+					ETag:      aws.String("etag1"),
+					VersionId: aws.String("v1"),
+				},
+			},
+		}
+		xml, err := marshalDeleteObjects(deleteInput)
+		if err != nil {
+			t.Fatalf("marshalDeleteObjects failed: %v", err)
+		}
+		xmlStr := string(xml)
+
+		keyIdx := strings.Index(xmlStr, "<Key>")
+		etagIdx := strings.Index(xmlStr, "<ETag>")
+		versionIdx := strings.Index(xmlStr, "<VersionId>")
+
+		if keyIdx == -1 || etagIdx == -1 || versionIdx == -1 {
+			t.Fatal("missing expected elements")
+		}
+		if etagIdx > keyIdx || keyIdx > versionIdx {
+			t.Errorf("expected field order: ETag, Key, VersionId, got ETag@%d, Key@%d, VersionId@%d", etagIdx, keyIdx, versionIdx)
+		}
+	})
+}
+
+func TestComputeDeleteObjectsContentMD5_Deterministic(t *testing.T) {
+	deleteInput := &types.Delete{
+		Objects: []types.ObjectIdentifier{
+			{Key: aws.String("key1")},
+			{Key: aws.String("key2")},
+		},
+	}
+
+	md51, err := computeDeleteObjectsContentMD5(deleteInput)
+	if err != nil {
+		t.Fatalf("first call failed: %v", err)
+	}
+
+	md52, err := computeDeleteObjectsContentMD5(deleteInput)
+	if err != nil {
+		t.Fatalf("second call failed: %v", err)
+	}
+
+	if md51 != md52 {
+		t.Errorf("MD5 computation not deterministic: %q != %q", md51, md52)
+	}
+
+	if md51 == "" {
+		t.Error("expected non-empty MD5")
+	}
+}