carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package leasetest
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "encoding/hex"
7 "encoding/xml"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "net/url"
13 "sort"
14 "strconv"
15 "strings"
16 "sync/atomic"
17 "time"
18
19 "github.com/carlosframework/platform/internal/lease"
20)
21
22type xmlVersion struct {
23 XMLName xml.Name `xml:"Version"`
24 Key string
25 VersionId string
26 IsLatest bool
27 LastModified string
28 ETag string
29 Size int64
30}
31
32type xmlMarker struct {
33 XMLName xml.Name `xml:"DeleteMarker"`
34 Key string
35 VersionId string
36 IsLatest bool
37 LastModified string
38}
39
40type listVersionsResult struct {
41 XMLName xml.Name `xml:"ListVersionsResult"`
42 Name string
43 Prefix string
44 KeyMarker string
45 VersionIdMarker string
46 NextKeyMarker string `xml:",omitempty"`
47 NextVersionIdMarker string `xml:",omitempty"`
48 MaxKeys int
49 IsTruncated bool
50 Entries []any
51}
52
53func s3err(w http.ResponseWriter, status int, code string) { s3errMsg(w, status, code, code) }
54
55func s3errMsg(w http.ResponseWriter, status int, code, msg string) {
56 w.Header().Set("Content-Type", "application/xml")
57 w.WriteHeader(status)
58 io.WriteString(w, "<Error><Code>"+code+"</Code><Message>"+msg+"</Message></Error>")
59}
60
61// readValidatedBody reads a PUT's body and checks it as real S3 does: its
62// length must equal the declared Content-Length (when one is declared), and,
63// when x-amz-content-sha256 is a hex digest, its sha256 must equal it. Any
64// other value of that header (UNSIGNED-PAYLOAD, a streaming marker) or none
65// is not checked. On a mismatch it answers 400 with S3's code and reports
66// false, and the caller stores nothing.
67func readValidatedBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
68 body, err := io.ReadAll(r.Body)
69 if err != nil || (r.ContentLength >= 0 && int64(len(body)) != r.ContentLength) {
70 s3errMsg(w, http.StatusBadRequest, "IncompleteBody", "You did not provide the number of bytes specified by the Content-Length HTTP header.")
71 return nil, false
72 }
73 if want := r.Header.Get("x-amz-content-sha256"); isSHA256Hex(want) {
74 sum := sha256.Sum256(body)
75 if !strings.EqualFold(want, hex.EncodeToString(sum[:])) {
76 s3errMsg(w, http.StatusBadRequest, "XAmzContentSHA256Mismatch", "The provided 'x-amz-content-sha256' header does not match what was computed.")
77 return nil, false
78 }
79 }
80 return body, true
81}
82
83func isSHA256Hex(s string) bool {
84 if len(s) != 2*sha256.Size {
85 return false
86 }
87 _, err := hex.DecodeString(s)
88 return err == nil
89}
90
91// copyMarkerMsg is S3's answer to a COPY naming a delete marker's version.
92const copyMarkerMsg = "The source of a copy request may not specifically refer to a delete marker by version id."
93
94// writeErr maps a model error onto the S3 response real S3 gives.
95func writeErr(w http.ResponseWriter, err error) {
96 switch {
97 case errors.Is(err, lease.ErrMirrorNotFound):
98 s3err(w, http.StatusNotFound, "NoSuchVersion")
99 case errors.Is(err, lease.ErrIsDeleteMarker):
100 w.Header().Set("x-amz-delete-marker", "true")
101 s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
102 case errors.Is(err, lease.ErrDestinationExists), errors.Is(err, lease.ErrExists):
103 s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
104 default:
105 s3err(w, http.StatusInternalServerError, "InternalError")
106 }
107}
108
109// NewS3Server speaks the subset of the S3 wire protocol VersionedStore uses,
110// and ListObjectsV2 (serveListV2), path-style, over f, holding f.mu for each request. The second result
111// counts requests served. With serves non-empty, the endpoint answers only
112// for those request buckets (a regional endpoint); any other bucket gets
113// 301 PermanentRedirect, as S3 answers a wrong-region request. A copy's
114// source bucket may be any bucket in f.
115func NewS3Server(f *VersionedBucket, serves ...string) (http.Handler, func() int) {
116 var n atomic.Int64
117 allowed := map[string]bool{}
118 for _, b := range serves {
119 allowed[b] = true
120 }
121 h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 n.Add(1)
123 bucket, rawKey, _ := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
124 key, err := url.PathUnescape(rawKey)
125 if err != nil {
126 s3err(w, http.StatusBadRequest, "InvalidURI")
127 return
128 }
129 q := r.URL.Query()
130 if len(allowed) > 0 && !allowed[bucket] {
131 s3err(w, http.StatusMovedPermanently, "PermanentRedirect")
132 return
133 }
134 var body []byte
135 if r.Method == http.MethodPut { // every PUT, a copy included, is validated before it is applied
136 var ok bool
137 if body, ok = readValidatedBody(w, r); !ok {
138 return
139 }
140 }
141 f.mu.Lock()
142 defer f.mu.Unlock()
143 if err := f.known(bucket); err != nil {
144 s3err(w, http.StatusNotFound, "NoSuchBucket")
145 return
146 }
147 switch {
148 case r.Method == http.MethodGet && key == "" && q.Has("versions"):
149 f.serveList(w, bucket, q)
150 case r.Method == http.MethodGet && key == "" && q.Get("list-type") == "2":
151 f.serveListV2(w, bucket, q)
152 case r.Method == http.MethodGet && key != "":
153 versionID := q.Get("versionId")
154 if versionID == "" { // no version named: the current one
155 cur := f.buckets[bucket][key]
156 if len(cur) == 0 || cur[0].deleteMarker {
157 s3err(w, http.StatusNotFound, "NoSuchKey")
158 return
159 }
160 versionID = cur[0].id
161 }
162 body, err := f.read(bucket, key, versionID)
163 if err != nil {
164 writeErr(w, err)
165 return
166 }
167 if v, ok := f.find(bucket, key, versionID); ok {
168 w.Header().Set("ETag", `"`+v.etag+`"`)
169 }
170 if hdr := r.Header.Get("Range"); hdr != "" {
171 span, ok := strings.CutPrefix(hdr, "bytes=")
172 start, end, dash := strings.Cut(span, "-")
173 offset, startErr := strconv.ParseInt(start, 10, 64)
174 length := int64(-1)
175 if end != "" {
176 last, endErr := strconv.ParseInt(end, 10, 64)
177 if endErr != nil || last < offset || last-offset == 1<<63-1 {
178 s3err(w, http.StatusRequestedRangeNotSatisfiable, "InvalidRange")
179 return
180 }
181 length = last - offset + 1
182 }
183 total := len(body)
184 body, err = versionRange(body, offset, length)
185 if !ok || !dash || startErr != nil || err != nil {
186 s3err(w, http.StatusRequestedRangeNotSatisfiable, "InvalidRange")
187 return
188 }
189 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+int64(len(body))-1, total))
190 w.Header().Set("Content-Length", strconv.Itoa(len(body)))
191 w.WriteHeader(http.StatusPartialContent)
192 }
193 w.Write(body)
194 case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
195 f.serveCopy(w, r, bucket, key)
196 case r.Method == http.MethodPut:
197 if match := r.Header.Get("If-Match"); match != "" {
198 current := f.buckets[bucket][key]
199 if len(current) == 0 || current[0].deleteMarker || current[0].etag != strings.Trim(match, `"`) {
200 s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
201 return
202 }
203 }
204 var id string
205 var err error
206 if r.Header.Get("If-None-Match") == "*" {
207 id, err = f.putIfAbsentLocked(bucket, key, body)
208 } else {
209 id, err = f.putLocked(bucket, key, body) // a new version, as S3 with versioning on
210 }
211 if err != nil {
212 writeErr(w, err)
213 return
214 }
215 w.Header().Set("x-amz-version-id", id)
216 w.Header().Set("ETag", `"`+md5hex(body)+`"`)
217 case r.Method == http.MethodDelete && key != "" && q.Get("versionId") != "":
218 if err := f.deleteLocked(bucket, key, q.Get("versionId")); err != nil {
219 writeErr(w, err)
220 return
221 }
222 w.WriteHeader(http.StatusNoContent)
223 case r.Method == http.MethodDelete && key != "":
224 if match := r.Header.Get("If-Match"); match != "" { // as S3 enforces it on DeleteObject
225 current := f.buckets[bucket][key]
226 if len(current) == 0 || current[0].deleteMarker {
227 s3err(w, http.StatusNotFound, "NoSuchKey")
228 return
229 }
230 if current[0].etag != strings.Trim(match, `"`) {
231 s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
232 return
233 }
234 }
235 id, err := f.bareDeleteLocked(bucket, key) // versioning on: a delete marker
236 if err != nil {
237 writeErr(w, err)
238 return
239 }
240 w.Header().Set("x-amz-delete-marker", "true")
241 w.Header().Set("x-amz-version-id", id)
242 w.WriteHeader(http.StatusNoContent)
243 default:
244 s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
245 }
246 })
247 return h, func() int { return int(n.Load()) }
248}
249
250func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.Values) {
251 if err := f.fail("list", q.Get("prefix")); err != nil {
252 writeErr(w, err)
253 return
254 }
255 all := f.listAll(bucket, q.Get("prefix"))
256 km, vm := q.Get("key-marker"), q.Get("version-id-marker")
257 start := 0
258 if km != "" {
259 start = len(all) // a marker not found resumes at the end
260 for i, v := range all {
261 if v.Key == km && v.VersionID == vm {
262 start = i + 1
263 break
264 }
265 }
266 }
267 size := f.PageSize
268 if size <= 0 {
269 size = 1000
270 }
271 page := (start + size - 1) / size // pages already served before this one
272 vs, more, err := f.pageOf(all[start:], 0)
273 if f.FailListAfterPages > 0 && page >= f.FailListAfterPages {
274 err = errors.New("leasetest: injected listing failure")
275 }
276 if err != nil {
277 writeErr(w, err)
278 return
279 }
280 res := listVersionsResult{Name: bucket, Prefix: q.Get("prefix"), KeyMarker: km, VersionIdMarker: vm, MaxKeys: size, IsTruncated: more}
281 for _, v := range vs {
282 lm := v.LastModified.UTC().Format(time.RFC3339)
283 if v.DeleteMarker {
284 res.Entries = append(res.Entries, xmlMarker{Key: v.Key, VersionId: v.VersionID, IsLatest: v.IsLatest, LastModified: lm})
285 } else {
286 res.Entries = append(res.Entries, xmlVersion{Key: v.Key, VersionId: v.VersionID, IsLatest: v.IsLatest, LastModified: lm, ETag: `"` + v.ETag + `"`, Size: v.Size})
287 }
288 }
289 if more && len(vs) > 0 {
290 res.NextKeyMarker, res.NextVersionIdMarker = vs[len(vs)-1].Key, vs[len(vs)-1].VersionID
291 }
292 w.Header().Set("Content-Type", "application/xml")
293 xml.NewEncoder(w).Encode(res)
294}
295
296type xmlObject struct {
297 Key string
298 LastModified string
299 ETag string
300 Size int64
301 StorageClass string
302}
303
304type xmlCommonPrefix struct {
305 Prefix string
306}
307
308type listBucketResult struct {
309 XMLName xml.Name `xml:"ListBucketResult"`
310 Name string
311 Prefix string
312 Delimiter string `xml:",omitempty"`
313 MaxKeys int
314 KeyCount int
315 IsTruncated bool
316 ContinuationToken string `xml:",omitempty"`
317 NextContinuationToken string `xml:",omitempty"`
318 StartAfter string `xml:",omitempty"`
319 Contents []xmlObject
320 CommonPrefixes []xmlCommonPrefix
321}
322
323// serveListV2 is ListObjectsV2, the subset litestream's replica client
324// (its restore lists with a delimiter) and lease.NewS3Mirror page through:
325// the current objects under prefix in key order (an object whose newest
326// version is a delete marker is not listed), a delimiter rolling every key
327// that holds it after the prefix into one common prefix, max-keys (at most
328// 1000, and at most PageSize when that is set lower; keys and common
329// prefixes both count) and continuation-token pagination. The token is
330// opaque to the client; here it is the last key or common prefix of the
331// previous page, and a page resumes after it (after every key under it,
332// for a common prefix). start-after is honoured on the first page.
333func (f *VersionedBucket) serveListV2(w http.ResponseWriter, bucket string, q url.Values) {
334 prefix, delim := q.Get("prefix"), q.Get("delimiter")
335 if err := f.fail("list", prefix); err != nil {
336 writeErr(w, err)
337 return
338 }
339 size := 1000
340 if f.PageSize > 0 && f.PageSize < size {
341 size = f.PageSize
342 }
343 if raw := q.Get("max-keys"); raw != "" {
344 n, err := strconv.Atoi(raw)
345 if err != nil || n < 0 {
346 s3err(w, http.StatusBadRequest, "InvalidArgument")
347 return
348 }
349 size = min(size, n)
350 }
351 after := q.Get("start-after")
352 token := q.Get("continuation-token")
353 if token != "" {
354 last, err := base64.StdEncoding.DecodeString(token)
355 if err != nil || len(last) == 0 {
356 s3errMsg(w, http.StatusBadRequest, "InvalidArgument", "The continuation token provided is incorrect")
357 return
358 }
359 after = string(last)
360 }
361 // entries are the page candidates in key order: a key, or a common
362 // prefix (isPrefix) standing for every key under it.
363 type entry struct {
364 name string
365 isPrefix bool
366 }
367 var keys []string
368 for k, vs := range f.buckets[bucket] {
369 if strings.HasPrefix(k, prefix) && len(vs) > 0 && !vs[0].deleteMarker {
370 keys = append(keys, k)
371 }
372 }
373 sort.Strings(keys)
374 var entries []entry
375 for _, k := range keys {
376 e := entry{name: k}
377 if delim != "" {
378 if i := strings.Index(k[len(prefix):], delim); i >= 0 {
379 e = entry{name: k[:len(prefix)+i+len(delim)], isPrefix: true}
380 }
381 }
382 if n := len(entries); n > 0 && entries[n-1] == e {
383 continue
384 }
385 // A common prefix that ended the last page is that page's token
386 // itself, and so is every key rolled into it.
387 if e.name <= after {
388 continue
389 }
390 entries = append(entries, e)
391 }
392 res := listBucketResult{Name: bucket, Prefix: prefix, Delimiter: delim, MaxKeys: size, ContinuationToken: token}
393 if token == "" {
394 res.StartAfter = q.Get("start-after")
395 }
396 page := entries
397 if len(page) > size {
398 page = page[:size]
399 res.IsTruncated = size > 0
400 }
401 for _, e := range page {
402 if e.isPrefix {
403 res.CommonPrefixes = append(res.CommonPrefixes, xmlCommonPrefix{Prefix: e.name})
404 continue
405 }
406 v := f.buckets[bucket][e.name][0]
407 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"})
408 }
409 res.KeyCount = len(page)
410 if res.IsTruncated && len(page) > 0 {
411 res.NextContinuationToken = base64.StdEncoding.EncodeToString([]byte(page[len(page)-1].name))
412 }
413 w.Header().Set("Content-Type", "application/xml")
414 xml.NewEncoder(w).Encode(res)
415}
416
417func (f *VersionedBucket) serveCopy(w http.ResponseWriter, r *http.Request, bucket, key string) {
418 src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/")
419 srcPath, rawQ, _ := strings.Cut(src, "?")
420 if strings.ContainsAny(srcPath, "+? ") {
421 s3err(w, http.StatusBadRequest, "InvalidArgument") // AWS's encoder escapes these; a raw one is a client bug
422 return
423 }
424 sq, err := url.ParseQuery(rawQ)
425 if err != nil {
426 s3err(w, http.StatusBadRequest, "InvalidArgument")
427 return
428 }
429 unesc, err := url.PathUnescape(srcPath)
430 if err != nil {
431 s3err(w, http.StatusBadRequest, "InvalidArgument")
432 return
433 }
434 sb, sk, _ := strings.Cut(unesc, "/")
435 srcVersion := sq.Get("versionId")
436 if srcVersion == "" { // no version named: the source's current one
437 cur := f.buckets[sb][sk]
438 if len(cur) == 0 || cur[0].deleteMarker {
439 s3err(w, http.StatusNotFound, "NoSuchKey")
440 return
441 }
442 srcVersion = cur[0].id
443 }
444 if im := r.Header.Get("x-amz-copy-source-if-match"); im != "" {
445 if v, ok := f.find(sb, sk, srcVersion); ok && strings.Trim(im, `"`) != v.etag {
446 s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
447 return
448 }
449 }
450 id, etag, err := f.copyLocked(sb, sk, srcVersion, bucket, key, r.Header.Get("If-None-Match") == "*")
451 if errors.Is(err, lease.ErrIsDeleteMarker) {
452 s3errMsg(w, http.StatusBadRequest, "InvalidRequest", copyMarkerMsg) // a COPY is 400, not GET's 405
453 return
454 }
455 if err != nil {
456 writeErr(w, err)
457 return
458 }
459 w.Header().Set("x-amz-version-id", id)
460 w.Header().Set("Content-Type", "application/xml")
461 io.WriteString(w, `<CopyObjectResult><ETag>"`+etag+`"</ETag><LastModified>2026-10-05T00:00:00Z</LastModified></CopyObjectResult>`)
462}
463