leblob 0.4.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
leblob/handlers.py ADDED
@@ -0,0 +1,1119 @@
1
+ """One async function per S3 operation: the orchestration of everything else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import secrets
8
+ import time
9
+ from contextlib import aclosing
10
+ from dataclasses import dataclass, replace
11
+ from typing import TYPE_CHECKING
12
+ from urllib.parse import quote, urlencode
13
+
14
+ from leblob import blobstore, meta, streams
15
+ from leblob.model import (
16
+ NULL_VERSION_ID,
17
+ ByteSpan,
18
+ ObjectRecord,
19
+ ObjectRef,
20
+ UploadExtras,
21
+ UploadRef,
22
+ )
23
+ from leblob.s3 import (
24
+ access,
25
+ conditions,
26
+ cors,
27
+ errors,
28
+ listing,
29
+ multipart,
30
+ payload,
31
+ post_form,
32
+ routing,
33
+ sigv4,
34
+ sse,
35
+ tagging,
36
+ xml,
37
+ xml_config,
38
+ )
39
+ from leblob.s3.access import Level
40
+ from leblob.s3.errors import S3Error
41
+ from leblob.s3.routing import Operation
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from leblob.blobstore import BlobStore
49
+ from leblob.meta import Database
50
+ from leblob.model import (
51
+ Blob,
52
+ Checksum,
53
+ CorsRule,
54
+ DeleteOutcome,
55
+ )
56
+ from leblob.s3.access import Scope
57
+ from leblob.s3.sigv4 import StreamSeed
58
+
59
+ MAX_XML_BYTES = 4 * 1024 * 1024
60
+ MAX_METADATA_BYTES = 2048
61
+
62
+ _STORED_HEADERS = (
63
+ "cache-control",
64
+ "content-disposition",
65
+ "content-language",
66
+ "content-type",
67
+ "expires",
68
+ )
69
+ _DEFAULT_CONTENT_TYPE = "binary/octet-stream"
70
+ _META_PREFIX = "x-amz-meta-"
71
+ _XML_CONTENT_TYPE = "application/xml"
72
+ # Tab and printable Latin-1. Anything else couldn't be sent as a header.
73
+ _VALID_HEADER_VALUE = re.compile(r"[\t\x20-\x7e\x80-\xff]*")
74
+ _UPLOAD_ID_BYTES = 24
75
+ _RESPONSE_OVERRIDES = {
76
+ "response-cache-control": "cache-control",
77
+ "response-content-disposition": "content-disposition",
78
+ "response-content-encoding": "content-encoding",
79
+ "response-content-language": "content-language",
80
+ "response-content-type": "content-type",
81
+ "response-expires": "expires",
82
+ }
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class _CopySource:
87
+ """What a copy reads: the object, where it is, and the version named, if any."""
88
+
89
+ bucket: str
90
+ version_id: str | None
91
+ record: ObjectRecord
92
+ key: sse.CustomerKey | None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Context:
97
+ db: Database
98
+ blobs: BlobStore
99
+ data_dir: Path
100
+ lifecycle_day_ms: int = meta.DAY_MS
101
+ sse_over_http: bool = False
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class Request:
106
+ """A request that has passed authentication."""
107
+
108
+ headers: Mapping[str, str]
109
+ query: Mapping[str, str]
110
+ receive: Callable[[], Awaitable[Mapping[str, Any]]]
111
+ stream_seed: StreamSeed | None # None when it carries no signature.
112
+ scope: Scope
113
+ secure: bool = False
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class Response:
118
+ status: int = 200
119
+ headers: tuple[tuple[str, str], ...] = ()
120
+ body: bytes | AsyncGenerator[bytes] = b""
121
+
122
+
123
+ #
124
+ # Service and buckets
125
+ #
126
+ async def list_buckets(ctx: Context, request: Request) -> Response:
127
+ buckets = await meta.list_buckets(ctx.db)
128
+ visible = [bucket for bucket in buckets if request.scope.sees(bucket.name)]
129
+ return _xml_response(xml.render_buckets(visible))
130
+
131
+
132
+ async def create_bucket(ctx: Context, request: Request, bucket: str) -> Response:
133
+ # The body only names a region, and a single store has one location.
134
+ await _read_small_body(request)
135
+ await meta.create_bucket(
136
+ ctx.db, bucket, public=request.headers.get("x-amz-acl") == "public-read"
137
+ )
138
+ return Response(headers=(("location", f"/{bucket}"),))
139
+
140
+
141
+ async def delete_bucket(ctx: Context, _request: Request, bucket: str) -> Response:
142
+ await meta.delete_bucket(ctx.db, bucket)
143
+ return Response(status=204)
144
+
145
+
146
+ async def head_bucket(ctx: Context, _request: Request, bucket: str) -> Response:
147
+ await meta.require_bucket(ctx.db, bucket)
148
+ return Response()
149
+
150
+
151
+ async def get_bucket_location(ctx: Context, _request: Request, bucket: str) -> Response:
152
+ await meta.require_bucket(ctx.db, bucket)
153
+ return _xml_response(xml.render_location())
154
+
155
+
156
+ async def get_bucket_versioning(
157
+ ctx: Context, _request: Request, bucket: str
158
+ ) -> Response:
159
+ return _xml_response(
160
+ xml.render_versioning(await meta.get_versioning(ctx.db, bucket))
161
+ )
162
+
163
+
164
+ async def put_bucket_versioning(
165
+ ctx: Context, request: Request, bucket: str
166
+ ) -> Response:
167
+ state = xml.parse_versioning(await _read_small_body(request))
168
+ if state is None:
169
+ await meta.require_bucket(ctx.db, bucket)
170
+ else:
171
+ await meta.set_versioning(ctx.db, bucket, state)
172
+ return Response()
173
+
174
+
175
+ async def get_bucket_lifecycle(
176
+ ctx: Context, _request: Request, bucket: str
177
+ ) -> Response:
178
+ configuration = await meta.get_lifecycle(ctx.db, bucket)
179
+ return _xml_response(configuration.encode())
180
+
181
+
182
+ async def put_bucket_lifecycle(ctx: Context, request: Request, bucket: str) -> Response:
183
+ rules = xml_config.parse_lifecycle(await _read_small_body(request))
184
+ await meta.set_lifecycle(
185
+ ctx.db, bucket, xml_config.render_lifecycle(rules).decode()
186
+ )
187
+ return Response()
188
+
189
+
190
+ async def delete_bucket_lifecycle(
191
+ ctx: Context, _request: Request, bucket: str
192
+ ) -> Response:
193
+ await meta.set_lifecycle(ctx.db, bucket, None)
194
+ return Response(status=204)
195
+
196
+
197
+ async def get_bucket_acl(ctx: Context, _request: Request, bucket: str) -> Response:
198
+ await meta.require_bucket(ctx.db, bucket)
199
+ public = await meta.bucket_is_public(ctx.db, bucket)
200
+ return _xml_response(xml_config.render_acl(public=public))
201
+
202
+
203
+ async def put_bucket_acl(ctx: Context, request: Request, bucket: str) -> Response:
204
+ await meta.set_bucket_public(
205
+ ctx.db, bucket, public=await _public_in_acl_request(request)
206
+ )
207
+ return Response()
208
+
209
+
210
+ async def _public_in_acl_request(request: Request) -> bool:
211
+ """Read an ACL from its header, or if there is none, from the body."""
212
+ canned = request.headers.get("x-amz-acl")
213
+ if canned is not None:
214
+ return canned == "public-read"
215
+ return xml_config.parse_acl(await _read_small_body(request))
216
+
217
+
218
+ async def get_bucket_cors(ctx: Context, _request: Request, bucket: str) -> Response:
219
+ await meta.require_bucket(ctx.db, bucket)
220
+ configuration = await meta.get_cors(ctx.db, bucket)
221
+ if configuration is None:
222
+ raise S3Error(errors.NO_SUCH_CORS)
223
+ return _xml_response(configuration.encode())
224
+
225
+
226
+ async def put_bucket_cors(ctx: Context, request: Request, bucket: str) -> Response:
227
+ rules = xml_config.parse_cors(await _read_small_body(request))
228
+ await meta.set_cors(ctx.db, bucket, xml_config.render_cors(rules).decode())
229
+ return Response()
230
+
231
+
232
+ async def delete_bucket_cors(ctx: Context, _request: Request, bucket: str) -> Response:
233
+ await meta.set_cors(ctx.db, bucket, None)
234
+ return Response(status=204)
235
+
236
+
237
+ async def cors_preflight(
238
+ ctx: Context, headers: Mapping[str, str], bucket: str
239
+ ) -> Response:
240
+ """Answer a browser asking whether it may send a request. It can't sign this."""
241
+ origin = headers.get("origin")
242
+ method = headers.get("access-control-request-method")
243
+ if origin is None or method is None:
244
+ raise S3Error(errors.INVALID_REQUEST, "This isn't a CORS preflight request.")
245
+ wanted = cors.split_header_names(headers.get("access-control-request-headers", ""))
246
+ rule = cors.find_rule(await _cors_rules(ctx, bucket), origin, method, wanted)
247
+ if rule is None:
248
+ raise S3Error(errors.ACCESS_FORBIDDEN)
249
+ return Response(headers=cors.preflight_headers(rule, origin, wanted))
250
+
251
+
252
+ async def cors_headers_for(
253
+ ctx: Context, method: str, origin: str, bucket: str
254
+ ) -> tuple[tuple[str, str], ...]:
255
+ """What a browser needs to be allowed to read the answer to a request.
256
+
257
+ With rules, an answer depends on the origin, allowed or not, so it says so to
258
+ caches. Otherwise a cache could give one origin what was meant for another.
259
+ """
260
+ rules = await _cors_rules(ctx, bucket)
261
+ if not rules:
262
+ return ()
263
+ rule = cors.find_rule(rules, origin, method)
264
+ return cors.VARY if rule is None else cors.response_headers(rule, origin)
265
+
266
+
267
+ async def _cors_rules(ctx: Context, bucket: str) -> tuple[CorsRule, ...]:
268
+ configuration = await meta.get_cors(ctx.db, bucket)
269
+ return (
270
+ () if configuration is None else xml_config.parse_cors(configuration.encode())
271
+ )
272
+
273
+
274
+ async def list_objects_v1(ctx: Context, request: Request, bucket: str) -> Response:
275
+ query = request.query
276
+ list_query = _list_query(query, query.get("marker", ""))
277
+ page = await meta.list_objects(ctx.db, bucket, list_query)
278
+ return _xml_response(xml.render_list_v1(bucket, query, page, list_query.max_keys))
279
+
280
+
281
+ async def list_objects_v2(ctx: Context, request: Request, bucket: str) -> Response:
282
+ query = request.query
283
+ after = (
284
+ listing.decode_token(query["continuation-token"])
285
+ if "continuation-token" in query
286
+ else query.get("start-after", "")
287
+ )
288
+ list_query = _list_query(query, after)
289
+ page = await meta.list_objects(ctx.db, bucket, list_query)
290
+ next_token = (
291
+ listing.encode_token(page.next_marker) if page.next_marker is not None else None
292
+ )
293
+ return _xml_response(
294
+ xml.render_list_v2(bucket, query, page, list_query.max_keys, next_token)
295
+ )
296
+
297
+
298
+ async def list_object_versions(ctx: Context, request: Request, bucket: str) -> Response:
299
+ query = request.query
300
+ list_query = _list_query(query, query.get("key-marker", ""))
301
+ page = await meta.list_versions(
302
+ ctx.db, bucket, list_query, query.get("version-id-marker", "")
303
+ )
304
+ return _xml_response(xml.render_versions(bucket, query, page, list_query.max_keys))
305
+
306
+
307
+ async def delete_objects(ctx: Context, request: Request, bucket: str) -> Response:
308
+ targets, quiet = xml.parse_delete(await _read_small_body(request))
309
+ if any(target.version_id is not None for target in targets):
310
+ access.require(request.scope, bucket, Level.ADMIN) # As for one such delete.
311
+ parsed = [
312
+ replace(target, key=routing.parse_decoded_key(target.key.encode()))
313
+ for target in targets
314
+ ]
315
+ outcomes = await meta.delete_objects(ctx.db, bucket, parsed)
316
+ return _xml_response(xml.render_deleted(outcomes, quiet=quiet))
317
+
318
+
319
+ #
320
+ # Objects
321
+ #
322
+ async def put_object(ctx: Context, request: Request, bucket: str, key: str) -> Response:
323
+ await meta.require_bucket(ctx.db, bucket)
324
+ condition = conditions.parse_write_condition(request.headers)
325
+ headers = _headers_to_store(request.headers)
326
+ tags = _tags_in_header(request)
327
+ public = _public_on_write(request, bucket)
328
+ key_sent = streams.customer_key(ctx, request)
329
+ blob, checksum = await streams.receive_blob(ctx, request, None, key_sent)
330
+ record = ObjectRecord(
331
+ key,
332
+ streams.client_size(blob, key_sent),
333
+ blob.md5,
334
+ meta.now_ms(),
335
+ headers,
336
+ checksum,
337
+ (blob,),
338
+ tags=tags,
339
+ public=public,
340
+ sse_check=key_sent.check if key_sent else None,
341
+ )
342
+ version_id = await _commit_new_blob(ctx, bucket, record, condition)
343
+ return Response(
344
+ headers=(
345
+ *_etag_headers(blob.md5, checksum),
346
+ *_new_version_headers(version_id),
347
+ *streams.key_headers(key_sent),
348
+ )
349
+ )
350
+
351
+
352
+ async def copy_object(
353
+ ctx: Context, request: Request, bucket: str, key: str
354
+ ) -> Response:
355
+ copied = await _load_copy_source(ctx, request)
356
+ source = copied.record
357
+ new_metadata = request.headers.get("x-amz-metadata-directive") == "REPLACE"
358
+ # Copying an older version onto its key is how one is restored.
359
+ restoring = copied.version_id is not None
360
+ onto_itself = (copied.bucket, source.key) == (bucket, key)
361
+ if onto_itself and not (new_metadata or restoring):
362
+ raise S3Error(
363
+ errors.INVALID_REQUEST,
364
+ "Copying an object onto itself must change its metadata.",
365
+ )
366
+ if source.size > streams.MAX_OBJECT_BYTES:
367
+ raise S3Error(errors.INVALID_REQUEST, "The source is too large for CopyObject.")
368
+ await meta.require_bucket(ctx.db, bucket)
369
+
370
+ condition = conditions.parse_write_condition(request.headers)
371
+ headers = _headers_to_store(request.headers) if new_metadata else source.headers
372
+ tags = _tags_of_a_copy(request, source)
373
+ public = _public_on_write(request, bucket) # As asked: a copy doesn't inherit it.
374
+ key_sent = streams.customer_key(ctx, request)
375
+ blob, _ = await streams.copy_to_new_blob(
376
+ ctx,
377
+ streams.plain_bytes(ctx, source, ByteSpan(0, source.size), copied.key),
378
+ None,
379
+ key_sent,
380
+ )
381
+
382
+ record = ObjectRecord(
383
+ key,
384
+ streams.client_size(blob, key_sent),
385
+ blob.md5,
386
+ meta.now_ms(),
387
+ headers,
388
+ None,
389
+ (blob,),
390
+ tags=tags,
391
+ public=public,
392
+ sse_check=key_sent.check if key_sent else None,
393
+ )
394
+ version_id = await _commit_new_blob(ctx, bucket, record, condition)
395
+ return _xml_response(
396
+ xml.render_copy_result(blob.md5, record.modified_at),
397
+ (
398
+ *_new_version_headers(version_id),
399
+ *_copy_source_headers(copied),
400
+ *streams.key_headers(key_sent),
401
+ ),
402
+ )
403
+
404
+
405
+ async def get_object(ctx: Context, request: Request, bucket: str, key: str) -> Response:
406
+ record = await _record_to_read(ctx, request, bucket, key)
407
+ return _object_response(ctx, request, record, include_body=True)
408
+
409
+
410
+ async def head_object(
411
+ ctx: Context, request: Request, bucket: str, key: str
412
+ ) -> Response:
413
+ record = await _record_to_read(ctx, request, bucket, key)
414
+ return _object_response(ctx, request, record, include_body=False)
415
+
416
+
417
+ async def _record_to_read(
418
+ ctx: Context, request: Request, bucket: str, key: str
419
+ ) -> ObjectRecord:
420
+ """Find what a read asks for, if whoever is asking may read it.
421
+
422
+ In one read with the object, so a private version can't slip in after the check.
423
+ Only those who may list a bucket learn that a key isn't in it.
424
+ """
425
+ version_id = request.query.get("versionId")
426
+ if request.scope is not access.ANONYMOUS:
427
+ return await meta.get_object(ctx.db, bucket, key, version_id)
428
+ if not _RESPONSE_OVERRIDES.keys().isdisjoint(request.query):
429
+ # A stranger's link shouldn't choose how a browser treats someone's object.
430
+ raise S3Error(errors.INVALID_REQUEST, "Only a signed request can do that.")
431
+ try:
432
+ record = await meta.get_object(ctx.db, bucket, key, version_id)
433
+ except S3Error as error:
434
+ if await meta.bucket_is_public(ctx.db, bucket):
435
+ raise S3Error(error.error, error.message) from None
436
+ raise S3Error(errors.ACCESS_DENIED) from None
437
+ if not record.public:
438
+ raise S3Error(errors.ACCESS_DENIED)
439
+ return record
440
+
441
+
442
+ async def post_object(ctx: Context, request: Request, bucket: str) -> Response:
443
+ """Store a browser's form. The form signs itself, so nothing is trusted before."""
444
+ boundary = post_form.parse_boundary(request.headers.get("content-type", ""))
445
+ async with aclosing(_form_events(request, boundary)) as events:
446
+ fields, filename = await _read_form_fields(events)
447
+ access_key = sigv4.post_form_access_key(fields)
448
+ secret, scope = await meta.get_access_key(ctx.db, access_key)
449
+ sigv4.verify_post_form(fields, secret)
450
+ access.require(scope, bucket, Level.WRITE)
451
+ if "key" not in fields:
452
+ raise S3Error(errors.INVALID_ARGUMENT, "A form must name a key.")
453
+ fields["key"] = fields["key"].replace("${filename}", filename)
454
+ limits = post_form.check_policy({**fields, "bucket": bucket}, time.time())
455
+ routing.refuse_unsupported_headers(fields)
456
+ key = routing.parse_decoded_key(fields["key"].encode())
457
+ public = _public_in_form(fields)
458
+ if public:
459
+ access.require(scope, bucket, Level.ADMIN)
460
+ await meta.require_bucket(ctx.db, bucket)
461
+ record = await _store_form_file(
462
+ ctx, ObjectRef(bucket, key), fields, events, limits
463
+ )
464
+ record = replace(record, public=public)
465
+ version_id = await _commit_new_blob(
466
+ ctx, bucket, record, conditions.WriteCondition()
467
+ )
468
+ return _post_response(fields, bucket, key, record.etag, version_id)
469
+
470
+
471
+ async def _form_events(
472
+ request: Request, boundary: bytes
473
+ ) -> AsyncGenerator[post_form.Event]:
474
+ parser = post_form.FormParser(boundary)
475
+ decoder = payload.new_decoder(payload.parse_spec({}), request.stream_seed)
476
+ async for chunk in streams.decoded_body(request, decoder):
477
+ for event in parser.feed(chunk):
478
+ yield event
479
+ parser.finish()
480
+
481
+
482
+ async def _read_form_fields(
483
+ events: AsyncGenerator[post_form.Event],
484
+ ) -> tuple[dict[str, str], str]:
485
+ """Read up to the file, and leave it unread. Returns the fields and its name."""
486
+ fields: dict[str, str] = {}
487
+ async for event in events:
488
+ if isinstance(event, post_form.Field):
489
+ if event.name in fields:
490
+ raise S3Error(errors.MALFORMED_POST, f"Two fields: {event.name}.")
491
+ fields[event.name] = event.value
492
+ elif isinstance(event, post_form.FileStart):
493
+ return fields, event.filename
494
+ raise S3Error(errors.INVALID_ARGUMENT, "A form must hold a file.")
495
+
496
+
497
+ def _public_in_form(fields: Mapping[str, str]) -> bool:
498
+ acl = fields.get("acl", "private")
499
+ if "x-amz-acl" in fields or acl not in {"private", "public-read"}:
500
+ raise S3Error(errors.NOT_IMPLEMENTED, f"acl: {acl} is not implemented.")
501
+ return acl == "public-read"
502
+
503
+
504
+ async def _store_form_file(
505
+ ctx: Context,
506
+ target: ObjectRef,
507
+ fields: Mapping[str, str],
508
+ events: AsyncGenerator[post_form.Event],
509
+ limits: post_form.SizeLimits,
510
+ ) -> ObjectRecord:
511
+ """Make the file a blob, once the form has been seen to end properly."""
512
+ if any(name.startswith(sse.HEADER_PREFIX) for name in fields):
513
+ raise S3Error(errors.NOT_IMPLEMENTED, "A form can't be encrypted yet.")
514
+ tags = (
515
+ xml_config.parse_tagging(fields["tagging"].encode())
516
+ if "tagging" in fields
517
+ else {}
518
+ )
519
+ headers = _headers_to_store(fields)
520
+ spec = payload.parse_spec({
521
+ name: value
522
+ for name, value in fields.items()
523
+ if name == "content-md5" or name.startswith("x-amz-checksum-")
524
+ })
525
+ largest = streams.MAX_OBJECT_BYTES if limits.largest is None else limits.largest
526
+
527
+ async def file_data() -> AsyncGenerator[bytes]:
528
+ async for event in events:
529
+ if isinstance(event, post_form.FileEnd):
530
+ return
531
+ if isinstance(event, post_form.FileData):
532
+ yield event.data
533
+
534
+ with ctx.blobs.writer(payload.new_hashers(spec)) as writer:
535
+ await blobstore.write_stream(
536
+ writer, file_data(), min(largest, streams.MAX_OBJECT_BYTES)
537
+ )
538
+ async for _ in events: # Up to the closing delimiter, or the error of its lack.
539
+ pass
540
+ if writer.size < limits.smallest:
541
+ raise S3Error(
542
+ errors.ENTITY_TOO_SMALL, "The file is below the policy's minimum."
543
+ )
544
+ checksum = payload.verify(spec, writer.digests(), writer.size, {})
545
+ blob = await asyncio.to_thread(writer.commit)
546
+ return ObjectRecord(
547
+ target.key,
548
+ blob.size,
549
+ blob.md5,
550
+ meta.now_ms(),
551
+ headers,
552
+ checksum,
553
+ (blob,),
554
+ tags=tags,
555
+ )
556
+
557
+
558
+ def _post_response(
559
+ fields: Mapping[str, str],
560
+ bucket: str,
561
+ key: str,
562
+ etag: str,
563
+ version_id: str | None,
564
+ ) -> Response:
565
+ location = f"/{bucket}/{quote(key)}"
566
+ headers = (
567
+ ("etag", f'"{etag}"'),
568
+ ("location", location),
569
+ *_new_version_headers(version_id),
570
+ )
571
+ redirect = fields.get("success_action_redirect", "")
572
+ if redirect.startswith(("http://", "https://")):
573
+ separator = "&" if "?" in redirect else "?"
574
+ query = urlencode({"bucket": bucket, "key": key, "etag": f'"{etag}"'})
575
+ target = f"{redirect}{separator}{query}"
576
+ if not _VALID_HEADER_VALUE.fullmatch(target):
577
+ raise S3Error(errors.INVALID_ARGUMENT, "That redirect is not valid.")
578
+ return Response(status=303, headers=(("location", target),))
579
+ status = fields.get("success_action_status")
580
+ if status == "201":
581
+ body = xml.render_post_result(bucket, key, etag, location)
582
+ return Response(
583
+ status=201,
584
+ headers=(("content-type", _XML_CONTENT_TYPE), *headers),
585
+ body=body,
586
+ )
587
+ return Response(status=200 if status == "200" else 204, headers=headers)
588
+
589
+
590
+ async def get_object_acl(
591
+ ctx: Context, request: Request, bucket: str, key: str
592
+ ) -> Response:
593
+ record = await meta.get_object(ctx.db, bucket, key, request.query.get("versionId"))
594
+ return _xml_response(
595
+ xml_config.render_acl(public=record.public),
596
+ _new_version_headers(record.version_id),
597
+ )
598
+
599
+
600
+ async def put_object_acl(
601
+ ctx: Context, request: Request, bucket: str, key: str
602
+ ) -> Response:
603
+ public = await _public_in_acl_request(request)
604
+ if public:
605
+ access.require(request.scope, bucket, Level.ADMIN)
606
+ version_id = await meta.set_object_public(
607
+ ctx.db, ObjectRef(bucket, key), request.query.get("versionId"), public=public
608
+ )
609
+ return Response(headers=_new_version_headers(version_id))
610
+
611
+
612
+ def _public_on_write(request: Request, bucket: str) -> bool:
613
+ """Whether a new object is to be public, which takes more than writing it."""
614
+ public = request.headers.get("x-amz-acl") == "public-read"
615
+ if public:
616
+ access.require(request.scope, bucket, Level.ADMIN)
617
+ return public
618
+
619
+
620
+ async def delete_object(
621
+ ctx: Context, request: Request, bucket: str, key: str
622
+ ) -> Response:
623
+ condition = conditions.parse_write_condition(request.headers)
624
+ outcome = await meta.delete_object(ctx.db, bucket, key, condition)
625
+ return Response(status=204, headers=_delete_headers(outcome))
626
+
627
+
628
+ async def delete_object_version(
629
+ ctx: Context, request: Request, bucket: str, key: str
630
+ ) -> Response:
631
+ outcome = await meta.delete_object_version(
632
+ ctx.db, bucket, key, request.query["versionId"]
633
+ )
634
+ return Response(status=204, headers=_delete_headers(outcome))
635
+
636
+
637
+ async def get_object_tagging(
638
+ ctx: Context, request: Request, bucket: str, key: str
639
+ ) -> Response:
640
+ record = await meta.get_object(ctx.db, bucket, key, request.query.get("versionId"))
641
+ return _xml_response(
642
+ xml_config.render_tagging(record.tags), _new_version_headers(record.version_id)
643
+ )
644
+
645
+
646
+ async def put_object_tagging(
647
+ ctx: Context, request: Request, bucket: str, key: str
648
+ ) -> Response:
649
+ tags = xml_config.parse_tagging(await _read_small_body(request))
650
+ return Response(headers=await _set_tags(ctx, request, ObjectRef(bucket, key), tags))
651
+
652
+
653
+ async def delete_object_tagging(
654
+ ctx: Context, request: Request, bucket: str, key: str
655
+ ) -> Response:
656
+ headers = await _set_tags(ctx, request, ObjectRef(bucket, key), {})
657
+ return Response(status=204, headers=headers)
658
+
659
+
660
+ async def _set_tags(
661
+ ctx: Context,
662
+ request: Request,
663
+ target: ObjectRef,
664
+ tags: Mapping[str, str],
665
+ ) -> tuple[tuple[str, str], ...]:
666
+ named = request.query.get("versionId")
667
+ if named is not None:
668
+ # A rule that filters on tags could then erase a version already kept.
669
+ access.require(request.scope, target.bucket, Level.ADMIN)
670
+ version_id = await meta.set_object_tags(
671
+ ctx.db, target.bucket, target.key, named, tags
672
+ )
673
+ return _new_version_headers(version_id)
674
+
675
+
676
+ def _tags_in_header(request: Request) -> dict[str, str]:
677
+ return tagging.parse_header(request.headers.get("x-amz-tagging", ""))
678
+
679
+
680
+ def _tags_of_a_copy(request: Request, source: ObjectRecord) -> Mapping[str, str]:
681
+ directive = request.headers.get("x-amz-tagging-directive", "COPY")
682
+ if directive == "COPY":
683
+ return source.tags
684
+ if directive == "REPLACE":
685
+ return _tags_in_header(request)
686
+ raise S3Error(errors.INVALID_ARGUMENT, f"Bad tagging directive: {directive!r}.")
687
+
688
+
689
+ #
690
+ # Multipart uploads
691
+ #
692
+ async def create_multipart_upload(
693
+ ctx: Context, request: Request, bucket: str, key: str
694
+ ) -> Response:
695
+ plan = multipart.parse_checksum_plan(request.headers)
696
+ upload = UploadRef(secrets.token_urlsafe(_UPLOAD_ID_BYTES), bucket, key)
697
+ key_sent = streams.customer_key(ctx, request)
698
+ await meta.create_upload(
699
+ ctx.db,
700
+ upload,
701
+ _headers_to_store(request.headers),
702
+ plan,
703
+ UploadExtras(
704
+ _tags_in_header(request),
705
+ _public_on_write(request, bucket),
706
+ key_sent.check if key_sent else None,
707
+ ),
708
+ )
709
+ extra = (
710
+ (
711
+ ("x-amz-checksum-algorithm", plan.algorithm.upper()),
712
+ ("x-amz-checksum-type", plan.checksum_type),
713
+ )
714
+ if plan
715
+ else ()
716
+ )
717
+ return _xml_response(
718
+ xml.render_initiate(bucket, key, upload.upload_id),
719
+ (*extra, *streams.key_headers(key_sent)),
720
+ )
721
+
722
+
723
+ async def upload_part(
724
+ ctx: Context, request: Request, bucket: str, key: str
725
+ ) -> Response:
726
+ upload = UploadRef(request.query["uploadId"], bucket, key)
727
+ number = multipart.parse_part_number(request.query.get("partNumber", ""))
728
+ started = await meta.require_upload(ctx.db, upload)
729
+ key_sent = streams.customer_key(ctx, request)
730
+ sse.check_key(started.sse_check, key_sent)
731
+
732
+ blob, checksum = await streams.receive_blob(
733
+ ctx, request, started.checksum, key_sent
734
+ )
735
+ await _commit_new_part(ctx, upload, number, blob, checksum)
736
+ return Response(
737
+ headers=(*_etag_headers(blob.md5, checksum), *streams.key_headers(key_sent))
738
+ )
739
+
740
+
741
+ async def upload_part_copy(
742
+ ctx: Context, request: Request, bucket: str, key: str
743
+ ) -> Response:
744
+ number = multipart.parse_part_number(request.query.get("partNumber", ""))
745
+ # The source first: a refusal shouldn't say whether the upload exists.
746
+ copied = await _load_copy_source(ctx, request)
747
+ upload = UploadRef(request.query["uploadId"], bucket, key)
748
+ started = await meta.require_upload(ctx.db, upload)
749
+ key_sent = streams.customer_key(ctx, request)
750
+ sse.check_key(started.sse_check, key_sent)
751
+ start, length = conditions.resolve_copy_range(
752
+ request.headers.get("x-amz-copy-source-range"), copied.record.size
753
+ )
754
+ if length > streams.MAX_OBJECT_BYTES:
755
+ raise S3Error(errors.INVALID_REQUEST, "The source is too large for one part.")
756
+
757
+ blob, checksum = await streams.copy_to_new_blob(
758
+ ctx,
759
+ streams.plain_bytes(ctx, copied.record, ByteSpan(start, length), copied.key),
760
+ started.checksum,
761
+ key_sent,
762
+ )
763
+ await _commit_new_part(ctx, upload, number, blob, checksum)
764
+ return _xml_response(
765
+ xml.render_copy_part_result(blob.md5, meta.now_ms(), checksum),
766
+ (*_copy_source_headers(copied), *streams.key_headers(key_sent)),
767
+ )
768
+
769
+
770
+ async def complete_multipart_upload(
771
+ ctx: Context, request: Request, bucket: str, key: str
772
+ ) -> Response:
773
+ body = await _read_small_body(
774
+ request, payload.without_object_checksums(request.headers)
775
+ )
776
+ completed = await meta.complete_upload(
777
+ ctx.db,
778
+ UploadRef(request.query["uploadId"], bucket, key),
779
+ multipart.parse_completion(xml.parse_complete(body), request.headers),
780
+ conditions.parse_write_condition(request.headers),
781
+ )
782
+ return _xml_response(
783
+ xml.render_complete(bucket, key, completed.etag, completed.checksum),
784
+ _new_version_headers(completed.version_id),
785
+ )
786
+
787
+
788
+ async def abort_multipart_upload(
789
+ ctx: Context, request: Request, bucket: str, key: str
790
+ ) -> Response:
791
+ await meta.abort_upload(ctx.db, UploadRef(request.query["uploadId"], bucket, key))
792
+ return Response(status=204)
793
+
794
+
795
+ async def list_parts(ctx: Context, request: Request, bucket: str, key: str) -> Response:
796
+ query = request.query
797
+ upload = UploadRef(query["uploadId"], bucket, key)
798
+ max_parts = listing.parse_max_keys(query, "max-parts")
799
+ marker_text = query.get("part-number-marker", "0")
800
+ if not marker_text.isdecimal():
801
+ raise S3Error(errors.INVALID_ARGUMENT, "part-number-marker is not valid.")
802
+ marker = int(marker_text)
803
+ parts = await meta.list_parts(ctx.db, upload, marker, max_parts + 1)
804
+ if (await meta.require_upload(ctx.db, upload)).sse_check is not None:
805
+ # A client knows a part by what it sent, not by what encrypting it takes.
806
+ parts = [
807
+ replace(part, blob=replace(part.blob, size=sse.plain_size(part.blob.size)))
808
+ for part in parts
809
+ ]
810
+ shown = tuple(parts[:max_parts])
811
+ next_marker = shown[-1].number if len(parts) > max_parts and shown else None
812
+ return _xml_response(
813
+ xml.render_parts(upload, xml.PartsPage(shown, max_parts, marker, next_marker))
814
+ )
815
+
816
+
817
+ async def list_multipart_uploads(
818
+ ctx: Context, request: Request, bucket: str
819
+ ) -> Response:
820
+ query = request.query
821
+ max_uploads = listing.parse_max_keys(query, "max-uploads")
822
+ # ponytail: no delimiter roll-up here. Add it if a client needs folders of
823
+ # in-progress uploads; the object listing's scan shows how.
824
+ uploads = await meta.list_uploads(
825
+ ctx.db,
826
+ bucket,
827
+ query.get("prefix", ""),
828
+ (query.get("key-marker", ""), query.get("upload-id-marker", "")),
829
+ max_uploads + 1,
830
+ )
831
+ page = uploads[:max_uploads]
832
+ next_position = (
833
+ (page[-1].key, page[-1].upload_id)
834
+ if len(uploads) > max_uploads and page
835
+ else None
836
+ )
837
+ return _xml_response(
838
+ xml.render_uploads(bucket, query, page, max_uploads, next_position)
839
+ )
840
+
841
+
842
+ SERVICE_HANDLERS = {Operation.LIST_BUCKETS: list_buckets}
843
+ BUCKET_HANDLERS = {
844
+ Operation.CREATE_BUCKET: create_bucket,
845
+ Operation.DELETE_BUCKET: delete_bucket,
846
+ Operation.HEAD_BUCKET: head_bucket,
847
+ Operation.GET_BUCKET_LOCATION: get_bucket_location,
848
+ Operation.GET_BUCKET_VERSIONING: get_bucket_versioning,
849
+ Operation.PUT_BUCKET_VERSIONING: put_bucket_versioning,
850
+ Operation.GET_BUCKET_LIFECYCLE: get_bucket_lifecycle,
851
+ Operation.PUT_BUCKET_LIFECYCLE: put_bucket_lifecycle,
852
+ Operation.DELETE_BUCKET_LIFECYCLE: delete_bucket_lifecycle,
853
+ Operation.GET_BUCKET_ACL: get_bucket_acl,
854
+ Operation.PUT_BUCKET_ACL: put_bucket_acl,
855
+ Operation.GET_BUCKET_CORS: get_bucket_cors,
856
+ Operation.PUT_BUCKET_CORS: put_bucket_cors,
857
+ Operation.DELETE_BUCKET_CORS: delete_bucket_cors,
858
+ Operation.LIST_OBJECTS: list_objects_v1,
859
+ Operation.LIST_OBJECTS_V2: list_objects_v2,
860
+ Operation.LIST_MULTIPART_UPLOADS: list_multipart_uploads,
861
+ Operation.LIST_OBJECT_VERSIONS: list_object_versions,
862
+ Operation.DELETE_OBJECTS: delete_objects,
863
+ Operation.POST_OBJECT: post_object,
864
+ }
865
+ OBJECT_HANDLERS = {
866
+ Operation.PUT_OBJECT: put_object,
867
+ Operation.COPY_OBJECT: copy_object,
868
+ Operation.GET_OBJECT: get_object,
869
+ Operation.HEAD_OBJECT: head_object,
870
+ Operation.DELETE_OBJECT: delete_object,
871
+ Operation.GET_OBJECT_ACL: get_object_acl,
872
+ Operation.PUT_OBJECT_ACL: put_object_acl,
873
+ Operation.GET_OBJECT_TAGGING: get_object_tagging,
874
+ Operation.PUT_OBJECT_TAGGING: put_object_tagging,
875
+ Operation.DELETE_OBJECT_TAGGING: delete_object_tagging,
876
+ Operation.DELETE_OBJECT_VERSION: delete_object_version,
877
+ Operation.CREATE_MULTIPART_UPLOAD: create_multipart_upload,
878
+ Operation.UPLOAD_PART: upload_part,
879
+ Operation.UPLOAD_PART_COPY: upload_part_copy,
880
+ Operation.COMPLETE_MULTIPART_UPLOAD: complete_multipart_upload,
881
+ Operation.ABORT_MULTIPART_UPLOAD: abort_multipart_upload,
882
+ Operation.LIST_PARTS: list_parts,
883
+ }
884
+
885
+
886
+ #
887
+ # Shared pieces
888
+ #
889
+ def _list_query(query: Mapping[str, str], after: str) -> listing.ListQuery:
890
+ return listing.ListQuery(
891
+ query.get("prefix", ""),
892
+ query.get("delimiter", ""),
893
+ after,
894
+ listing.parse_max_keys(query),
895
+ )
896
+
897
+
898
+ async def _load_copy_source(ctx: Context, request: Request) -> _CopySource:
899
+ """Find what a copy reads, if the key may read it and its conditions hold."""
900
+ bucket, key, version_id = _parse_copy_source(request.headers["x-amz-copy-source"])
901
+ access.require(request.scope, bucket, Level.READ)
902
+ record = await meta.get_object(ctx.db, bucket, key, version_id)
903
+ source_key = streams.customer_key(ctx, request, sse.COPY_SOURCE_PREFIX)
904
+ sse.check_key(record.sse_check, source_key)
905
+ if conditions.read_is_not_modified(
906
+ request.headers, record.etag, record.modified_at, prefix="x-amz-copy-source-"
907
+ ):
908
+ raise S3Error(errors.PRECONDITION_FAILED, "The source was not modified.")
909
+ return _CopySource(bucket, version_id, record, source_key)
910
+
911
+
912
+ async def _commit_new_part(
913
+ ctx: Context,
914
+ upload: UploadRef,
915
+ number: int,
916
+ blob: Blob,
917
+ checksum: Checksum | None,
918
+ ) -> None:
919
+ """Commit, and remove the blob if the commit refuses it: no one can refer to it."""
920
+ try:
921
+ await meta.commit_part(ctx.db, upload, number, blob, checksum)
922
+ except S3Error:
923
+ ctx.blobs.delete(blob.blob_id, blob.volume_ids)
924
+ raise
925
+
926
+
927
+ async def _read_small_body(
928
+ request: Request, headers: Mapping[str, str] | None = None
929
+ ) -> bytes:
930
+ """Read a bounded XML body, holding it to the same promises as any upload."""
931
+ spec = payload.parse_spec(request.headers if headers is None else headers)
932
+ decoder = payload.new_decoder(spec, request.stream_seed)
933
+ hashers = payload.new_hashers(spec)
934
+ body = bytearray()
935
+ async with aclosing(streams.decoded_body(request, decoder)) as chunks:
936
+ async for chunk in chunks:
937
+ body += chunk
938
+ if len(body) > MAX_XML_BYTES:
939
+ raise S3Error(errors.MALFORMED_XML, "The XML body is too large.")
940
+ complete_body = bytes(body)
941
+ for hasher in hashers.values():
942
+ hasher.update(complete_body)
943
+ digests = {name: hasher.digest() for name, hasher in hashers.items()}
944
+ payload.verify(spec, digests, len(complete_body), decoder.finish())
945
+ return complete_body
946
+
947
+
948
+ async def _commit_new_blob(
949
+ ctx: Context,
950
+ bucket: str,
951
+ record: ObjectRecord,
952
+ condition: conditions.WriteCondition,
953
+ ) -> str | None:
954
+ """Commit, and remove the blob if the commit refuses it: no one can refer to it."""
955
+ try:
956
+ return await meta.commit_object(ctx.db, bucket, record, condition)
957
+ except S3Error:
958
+ for blob in record.blobs:
959
+ ctx.blobs.delete(blob.blob_id, blob.volume_ids)
960
+ raise
961
+
962
+
963
+ def _refuse_unless_reachable(
964
+ ctx: Context, record: ObjectRecord, start: int, length: int
965
+ ) -> None:
966
+ """Say so while a client can still retry: after the headers, a read can only break.
967
+
968
+ A worker can be without its volumes for moments, as when it starts on a full disk.
969
+ """
970
+ blobs = (
971
+ (piece.blob for piece in conditions.slice_blobs(record.blobs, start, length))
972
+ if record.sse_check is None
973
+ else record.blobs
974
+ )
975
+ if not all(map(ctx.blobs.can_read, blobs)):
976
+ raise S3Error(errors.SERVICE_UNAVAILABLE)
977
+
978
+
979
+ def _object_response(
980
+ ctx: Context,
981
+ request: Request,
982
+ record: ObjectRecord,
983
+ *,
984
+ include_body: bool,
985
+ ) -> Response:
986
+ headers = request.headers
987
+ key_sent = streams.customer_key(ctx, request)
988
+ sse.check_key(record.sse_check, key_sent)
989
+ validators = (
990
+ ("etag", f'"{record.etag}"'),
991
+ ("last-modified", xml.http_date(record.modified_at)),
992
+ *(
993
+ ()
994
+ if record.version_id == NULL_VERSION_ID
995
+ else _version_headers(record.version_id)
996
+ ),
997
+ )
998
+ if conditions.read_is_not_modified(headers, record.etag, record.modified_at):
999
+ return Response(status=304, headers=validators)
1000
+
1001
+ byte_range = conditions.resolve_range(headers.get("range"), record.size)
1002
+ start, length = byte_range or (0, record.size)
1003
+ response_headers = [
1004
+ *validators,
1005
+ *_stored_response_headers(record, request.query),
1006
+ ("accept-ranges", "bytes"),
1007
+ ("content-length", str(length)),
1008
+ ]
1009
+ if record.tags:
1010
+ response_headers.append(("x-amz-tagging-count", str(len(record.tags))))
1011
+ response_headers.extend(streams.key_headers(key_sent))
1012
+ if byte_range is not None:
1013
+ response_headers.append((
1014
+ "content-range",
1015
+ f"bytes {start}-{start + length - 1}/{record.size}",
1016
+ ))
1017
+ # A checksum describes the whole object, so a client would reject it on a range.
1018
+ wants_checksum = headers.get("x-amz-checksum-mode", "").upper() == "ENABLED"
1019
+ if wants_checksum and record.checksum is not None and byte_range is None:
1020
+ response_headers.extend((
1021
+ _checksum_header(record.checksum),
1022
+ ("x-amz-checksum-type", record.checksum.checksum_type),
1023
+ ))
1024
+
1025
+ body: bytes | AsyncGenerator[bytes] = b""
1026
+ if include_body:
1027
+ _refuse_unless_reachable(ctx, record, start, length)
1028
+ body = streams.plain_bytes(ctx, record, ByteSpan(start, length), key_sent)
1029
+ status = 206 if byte_range is not None else 200
1030
+ return Response(status, tuple(response_headers), body)
1031
+
1032
+
1033
+ def _stored_response_headers(
1034
+ record: ObjectRecord, query: Mapping[str, str]
1035
+ ) -> list[tuple[str, str]]:
1036
+ merged = dict(record.headers)
1037
+ for parameter, header in _RESPONSE_OVERRIDES.items():
1038
+ if parameter in query:
1039
+ if not _VALID_HEADER_VALUE.fullmatch(query[parameter]):
1040
+ raise S3Error(errors.INVALID_ARGUMENT, f"{parameter} is not valid.")
1041
+ merged[header] = query[parameter]
1042
+ return list(merged.items())
1043
+
1044
+
1045
+ def _headers_to_store(headers: Mapping[str, str]) -> dict[str, str]:
1046
+ """Pick the headers that belong to the object, and enforce the metadata limit."""
1047
+ stored = {name: headers[name] for name in _STORED_HEADERS if name in headers}
1048
+ stored.setdefault("content-type", _DEFAULT_CONTENT_TYPE)
1049
+ encoding = ", ".join(
1050
+ token.strip()
1051
+ for token in headers.get("content-encoding", "").split(",")
1052
+ if token.strip() and token.strip() != "aws-chunked"
1053
+ )
1054
+ if encoding:
1055
+ stored["content-encoding"] = encoding
1056
+
1057
+ user_metadata = {
1058
+ name: value for name, value in headers.items() if name.startswith(_META_PREFIX)
1059
+ }
1060
+ metadata_size = sum(
1061
+ len(name) - len(_META_PREFIX) + len(value)
1062
+ for name, value in user_metadata.items()
1063
+ )
1064
+ if metadata_size > MAX_METADATA_BYTES:
1065
+ raise S3Error(errors.METADATA_TOO_LARGE)
1066
+ return stored | user_metadata
1067
+
1068
+
1069
+ def _parse_copy_source(header: str) -> tuple[str, str, str | None]:
1070
+ """Return the bucket, key and, if one is named, version of a copy's source."""
1071
+ path, _, raw_query = header.partition("?")
1072
+ query = routing.parse_query(raw_query.encode("latin-1"))
1073
+ if query.keys() - {"versionId"}:
1074
+ raise S3Error(errors.INVALID_ARGUMENT, "x-amz-copy-source is not valid.")
1075
+ raw_bucket, separator, raw_key = path.removeprefix("/").partition("/")
1076
+ if not separator or not raw_key:
1077
+ raise S3Error(errors.INVALID_ARGUMENT, "x-amz-copy-source is not valid.")
1078
+ return (
1079
+ routing.parse_raw_bucket(raw_bucket.encode()),
1080
+ routing.parse_key(raw_key.encode()),
1081
+ query.get("versionId"),
1082
+ )
1083
+
1084
+
1085
+ def _copy_source_headers(copied: _CopySource) -> tuple[tuple[str, str], ...]:
1086
+ version_id = copied.record.version_id
1087
+ unversioned = version_id == NULL_VERSION_ID and copied.version_id is None
1088
+ return () if unversioned else (("x-amz-copy-source-version-id", version_id),)
1089
+
1090
+
1091
+ def _version_headers(version_id: str | None) -> tuple[tuple[str, str], ...]:
1092
+ return () if version_id is None else (("x-amz-version-id", version_id),)
1093
+
1094
+
1095
+ def _new_version_headers(version_id: str | None) -> tuple[tuple[str, str], ...]:
1096
+ """What a write reports. A suspended bucket's "null" isn't worth naming."""
1097
+ return () if version_id == NULL_VERSION_ID else _version_headers(version_id)
1098
+
1099
+
1100
+ def _delete_headers(outcome: DeleteOutcome) -> tuple[tuple[str, str], ...]:
1101
+ marker = (("x-amz-delete-marker", "true"),) if outcome.is_delete_marker else ()
1102
+ return (*marker, *_version_headers(outcome.version_id))
1103
+
1104
+
1105
+ def _etag_headers(etag: str, checksum: Checksum | None) -> tuple[tuple[str, str], ...]:
1106
+ extra = (_checksum_header(checksum),) if checksum else ()
1107
+ return (("etag", f'"{etag}"'), *extra)
1108
+
1109
+
1110
+ def _checksum_header(checksum: Checksum) -> tuple[str, str]:
1111
+ return (f"x-amz-checksum-{checksum.algorithm}", checksum.value)
1112
+
1113
+
1114
+ def _xml_response(
1115
+ body: bytes, extra_headers: tuple[tuple[str, str], ...] = ()
1116
+ ) -> Response:
1117
+ return Response(
1118
+ headers=(("content-type", _XML_CONTENT_TYPE), *extra_headers), body=body
1119
+ )