package leasetest
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/carlosframework/platform/internal/lease"
)
type xmlVersion struct {
XMLName xml.Name `xml:"Version"`
Key string
VersionId string
IsLatest bool
LastModified string
ETag string
Size int64
}
type xmlMarker struct {
XMLName xml.Name `xml:"DeleteMarker"`
Key string
VersionId string
IsLatest bool
LastModified string
}
type listVersionsResult struct {
XMLName xml.Name `xml:"ListVersionsResult"`
Name string
Prefix string
KeyMarker string
VersionIdMarker string
NextKeyMarker string `xml:",omitempty"`
NextVersionIdMarker string `xml:",omitempty"`
MaxKeys int
IsTruncated bool
Entries []any
}
func s3err(w http.ResponseWriter, status int, code string) { s3errMsg(w, status, code, code) }
func s3errMsg(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(status)
io.WriteString(w, ""+code+""+msg+"")
}
// readValidatedBody reads a PUT's body and checks it as real S3 does: its
// length must equal the declared Content-Length (when one is declared), and,
// when x-amz-content-sha256 is a hex digest, its sha256 must equal it. Any
// other value of that header (UNSIGNED-PAYLOAD, a streaming marker) or none
// is not checked. On a mismatch it answers 400 with S3's code and reports
// false, and the caller stores nothing.
func readValidatedBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if err != nil || (r.ContentLength >= 0 && int64(len(body)) != r.ContentLength) {
s3errMsg(w, http.StatusBadRequest, "IncompleteBody", "You did not provide the number of bytes specified by the Content-Length HTTP header.")
return nil, false
}
if want := r.Header.Get("x-amz-content-sha256"); isSHA256Hex(want) {
sum := sha256.Sum256(body)
if !strings.EqualFold(want, hex.EncodeToString(sum[:])) {
s3errMsg(w, http.StatusBadRequest, "XAmzContentSHA256Mismatch", "The provided 'x-amz-content-sha256' header does not match what was computed.")
return nil, false
}
}
return body, true
}
func isSHA256Hex(s string) bool {
if len(s) != 2*sha256.Size {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// copyMarkerMsg is S3's answer to a COPY naming a delete marker's version.
const copyMarkerMsg = "The source of a copy request may not specifically refer to a delete marker by version id."
// writeErr maps a model error onto the S3 response real S3 gives.
func writeErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, lease.ErrMirrorNotFound):
s3err(w, http.StatusNotFound, "NoSuchVersion")
case errors.Is(err, lease.ErrIsDeleteMarker):
w.Header().Set("x-amz-delete-marker", "true")
s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
case errors.Is(err, lease.ErrDestinationExists), errors.Is(err, lease.ErrExists):
s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
default:
s3err(w, http.StatusInternalServerError, "InternalError")
}
}
// NewS3Server speaks the subset of the S3 wire protocol VersionedStore uses,
// and ListObjectsV2 (serveListV2), path-style, over f, holding f.mu for each request. The second result
// counts requests served. With serves non-empty, the endpoint answers only
// for those request buckets (a regional endpoint); any other bucket gets
// 301 PermanentRedirect, as S3 answers a wrong-region request. A copy's
// source bucket may be any bucket in f.
func NewS3Server(f *VersionedBucket, serves ...string) (http.Handler, func() int) {
var n atomic.Int64
allowed := map[string]bool{}
for _, b := range serves {
allowed[b] = true
}
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
bucket, rawKey, _ := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
key, err := url.PathUnescape(rawKey)
if err != nil {
s3err(w, http.StatusBadRequest, "InvalidURI")
return
}
q := r.URL.Query()
if len(allowed) > 0 && !allowed[bucket] {
s3err(w, http.StatusMovedPermanently, "PermanentRedirect")
return
}
var body []byte
if r.Method == http.MethodPut { // every PUT, a copy included, is validated before it is applied
var ok bool
if body, ok = readValidatedBody(w, r); !ok {
return
}
}
f.mu.Lock()
defer f.mu.Unlock()
if err := f.known(bucket); err != nil {
s3err(w, http.StatusNotFound, "NoSuchBucket")
return
}
switch {
case r.Method == http.MethodGet && key == "" && q.Has("versions"):
f.serveList(w, bucket, q)
case r.Method == http.MethodGet && key == "" && q.Get("list-type") == "2":
f.serveListV2(w, bucket, q)
case r.Method == http.MethodGet && key != "":
versionID := q.Get("versionId")
if versionID == "" { // no version named: the current one
cur := f.buckets[bucket][key]
if len(cur) == 0 || cur[0].deleteMarker {
s3err(w, http.StatusNotFound, "NoSuchKey")
return
}
versionID = cur[0].id
}
body, err := f.read(bucket, key, versionID)
if err != nil {
writeErr(w, err)
return
}
if v, ok := f.find(bucket, key, versionID); ok {
w.Header().Set("ETag", `"`+v.etag+`"`)
}
if hdr := r.Header.Get("Range"); hdr != "" {
span, ok := strings.CutPrefix(hdr, "bytes=")
start, end, dash := strings.Cut(span, "-")
offset, startErr := strconv.ParseInt(start, 10, 64)
length := int64(-1)
if end != "" {
last, endErr := strconv.ParseInt(end, 10, 64)
if endErr != nil || last < offset || last-offset == 1<<63-1 {
s3err(w, http.StatusRequestedRangeNotSatisfiable, "InvalidRange")
return
}
length = last - offset + 1
}
total := len(body)
body, err = versionRange(body, offset, length)
if !ok || !dash || startErr != nil || err != nil {
s3err(w, http.StatusRequestedRangeNotSatisfiable, "InvalidRange")
return
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+int64(len(body))-1, total))
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusPartialContent)
}
w.Write(body)
case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
f.serveCopy(w, r, bucket, key)
case r.Method == http.MethodPut:
if match := r.Header.Get("If-Match"); match != "" {
current := f.buckets[bucket][key]
if len(current) == 0 || current[0].deleteMarker || current[0].etag != strings.Trim(match, `"`) {
s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
return
}
}
var id string
var err error
if r.Header.Get("If-None-Match") == "*" {
id, err = f.putIfAbsentLocked(bucket, key, body)
} else {
id, err = f.putLocked(bucket, key, body) // a new version, as S3 with versioning on
}
if err != nil {
writeErr(w, err)
return
}
w.Header().Set("x-amz-version-id", id)
w.Header().Set("ETag", `"`+md5hex(body)+`"`)
case r.Method == http.MethodDelete && key != "" && q.Get("versionId") != "":
if err := f.deleteLocked(bucket, key, q.Get("versionId")); err != nil {
writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodDelete && key != "":
if match := r.Header.Get("If-Match"); match != "" { // as S3 enforces it on DeleteObject
current := f.buckets[bucket][key]
if len(current) == 0 || current[0].deleteMarker {
s3err(w, http.StatusNotFound, "NoSuchKey")
return
}
if current[0].etag != strings.Trim(match, `"`) {
s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
return
}
}
id, err := f.bareDeleteLocked(bucket, key) // versioning on: a delete marker
if err != nil {
writeErr(w, err)
return
}
w.Header().Set("x-amz-delete-marker", "true")
w.Header().Set("x-amz-version-id", id)
w.WriteHeader(http.StatusNoContent)
default:
s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
}
})
return h, func() int { return int(n.Load()) }
}
func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.Values) {
if err := f.fail("list", q.Get("prefix")); err != nil {
writeErr(w, err)
return
}
all := f.listAll(bucket, q.Get("prefix"))
km, vm := q.Get("key-marker"), q.Get("version-id-marker")
start := 0
if km != "" {
start = len(all) // a marker not found resumes at the end
for i, v := range all {
if v.Key == km && v.VersionID == vm {
start = i + 1
break
}
}
}
size := f.PageSize
if size <= 0 {
size = 1000
}
page := (start + size - 1) / size // pages already served before this one
vs, more, err := f.pageOf(all[start:], 0)
if f.FailListAfterPages > 0 && page >= f.FailListAfterPages {
err = errors.New("leasetest: injected listing failure")
}
if err != nil {
writeErr(w, err)
return
}
res := listVersionsResult{Name: bucket, Prefix: q.Get("prefix"), KeyMarker: km, VersionIdMarker: vm, MaxKeys: size, IsTruncated: more}
for _, v := range vs {
lm := v.LastModified.UTC().Format(time.RFC3339)
if v.DeleteMarker {
res.Entries = append(res.Entries, xmlMarker{Key: v.Key, VersionId: v.VersionID, IsLatest: v.IsLatest, LastModified: lm})
} else {
res.Entries = append(res.Entries, xmlVersion{Key: v.Key, VersionId: v.VersionID, IsLatest: v.IsLatest, LastModified: lm, ETag: `"` + v.ETag + `"`, Size: v.Size})
}
}
if more && len(vs) > 0 {
res.NextKeyMarker, res.NextVersionIdMarker = vs[len(vs)-1].Key, vs[len(vs)-1].VersionID
}
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(res)
}
type xmlObject struct {
Key string
LastModified string
ETag string
Size int64
StorageClass string
}
type xmlCommonPrefix struct {
Prefix string
}
type listBucketResult struct {
XMLName xml.Name `xml:"ListBucketResult"`
Name string
Prefix string
Delimiter string `xml:",omitempty"`
MaxKeys int
KeyCount int
IsTruncated bool
ContinuationToken string `xml:",omitempty"`
NextContinuationToken string `xml:",omitempty"`
StartAfter string `xml:",omitempty"`
Contents []xmlObject
CommonPrefixes []xmlCommonPrefix
}
// serveListV2 is ListObjectsV2, the subset litestream's replica client
// (its restore lists with a delimiter) and lease.NewS3Mirror page through:
// the current objects under prefix in key order (an object whose newest
// version is a delete marker is not listed), a delimiter rolling every key
// that holds it after the prefix into one common prefix, max-keys (at most
// 1000, and at most PageSize when that is set lower; keys and common
// prefixes both count) and continuation-token pagination. The token is
// opaque to the client; here it is the last key or common prefix of the
// previous page, and a page resumes after it (after every key under it,
// for a common prefix). start-after is honoured on the first page.
func (f *VersionedBucket) serveListV2(w http.ResponseWriter, bucket string, q url.Values) {
prefix, delim := q.Get("prefix"), q.Get("delimiter")
if err := f.fail("list", prefix); err != nil {
writeErr(w, err)
return
}
size := 1000
if f.PageSize > 0 && f.PageSize < size {
size = f.PageSize
}
if raw := q.Get("max-keys"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
s3err(w, http.StatusBadRequest, "InvalidArgument")
return
}
size = min(size, n)
}
after := q.Get("start-after")
token := q.Get("continuation-token")
if token != "" {
last, err := base64.StdEncoding.DecodeString(token)
if err != nil || len(last) == 0 {
s3errMsg(w, http.StatusBadRequest, "InvalidArgument", "The continuation token provided is incorrect")
return
}
after = string(last)
}
// entries are the page candidates in key order: a key, or a common
// prefix (isPrefix) standing for every key under it.
type entry struct {
name string
isPrefix bool
}
var keys []string
for k, vs := range f.buckets[bucket] {
if strings.HasPrefix(k, prefix) && len(vs) > 0 && !vs[0].deleteMarker {
keys = append(keys, k)
}
}
sort.Strings(keys)
var entries []entry
for _, k := range keys {
e := entry{name: k}
if delim != "" {
if i := strings.Index(k[len(prefix):], delim); i >= 0 {
e = entry{name: k[:len(prefix)+i+len(delim)], isPrefix: true}
}
}
if n := len(entries); n > 0 && entries[n-1] == e {
continue
}
// A common prefix that ended the last page is that page's token
// itself, and so is every key rolled into it.
if e.name <= after {
continue
}
entries = append(entries, e)
}
res := listBucketResult{Name: bucket, Prefix: prefix, Delimiter: delim, MaxKeys: size, ContinuationToken: token}
if token == "" {
res.StartAfter = q.Get("start-after")
}
page := entries
if len(page) > size {
page = page[:size]
res.IsTruncated = size > 0
}
for _, e := range page {
if e.isPrefix {
res.CommonPrefixes = append(res.CommonPrefixes, xmlCommonPrefix{Prefix: e.name})
continue
}
v := f.buckets[bucket][e.name][0]
res.Contents = append(res.Contents, xmlObject{Key: e.name, LastModified: v.modified.UTC().Format(time.RFC3339), ETag: `"` + v.etag + `"`, Size: int64(len(v.body)), StorageClass: "STANDARD"})
}
res.KeyCount = len(page)
if res.IsTruncated && len(page) > 0 {
res.NextContinuationToken = base64.StdEncoding.EncodeToString([]byte(page[len(page)-1].name))
}
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(res)
}
func (f *VersionedBucket) serveCopy(w http.ResponseWriter, r *http.Request, bucket, key string) {
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/")
srcPath, rawQ, _ := strings.Cut(src, "?")
if strings.ContainsAny(srcPath, "+? ") {
s3err(w, http.StatusBadRequest, "InvalidArgument") // AWS's encoder escapes these; a raw one is a client bug
return
}
sq, err := url.ParseQuery(rawQ)
if err != nil {
s3err(w, http.StatusBadRequest, "InvalidArgument")
return
}
unesc, err := url.PathUnescape(srcPath)
if err != nil {
s3err(w, http.StatusBadRequest, "InvalidArgument")
return
}
sb, sk, _ := strings.Cut(unesc, "/")
srcVersion := sq.Get("versionId")
if srcVersion == "" { // no version named: the source's current one
cur := f.buckets[sb][sk]
if len(cur) == 0 || cur[0].deleteMarker {
s3err(w, http.StatusNotFound, "NoSuchKey")
return
}
srcVersion = cur[0].id
}
if im := r.Header.Get("x-amz-copy-source-if-match"); im != "" {
if v, ok := f.find(sb, sk, srcVersion); ok && strings.Trim(im, `"`) != v.etag {
s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
return
}
}
id, etag, err := f.copyLocked(sb, sk, srcVersion, bucket, key, r.Header.Get("If-None-Match") == "*")
if errors.Is(err, lease.ErrIsDeleteMarker) {
s3errMsg(w, http.StatusBadRequest, "InvalidRequest", copyMarkerMsg) // a COPY is 400, not GET's 405
return
}
if err != nil {
writeErr(w, err)
return
}
w.Header().Set("x-amz-version-id", id)
w.Header().Set("Content-Type", "application/xml")
io.WriteString(w, `"`+etag+`"2026-10-05T00:00:00Z`)
}