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/__init__.py +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
leblob/s3/routing.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Map an HTTP request to an S3 operation, and validate names once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from urllib.parse import parse_qsl, unquote_to_bytes
|
|
10
|
+
|
|
11
|
+
from leblob.s3 import errors
|
|
12
|
+
from leblob.s3.errors import S3Error
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
from leblob.model import HttpRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Operation(Enum):
|
|
21
|
+
HEALTH = auto()
|
|
22
|
+
METRICS = auto()
|
|
23
|
+
LIST_BUCKETS = auto()
|
|
24
|
+
|
|
25
|
+
CREATE_BUCKET = auto()
|
|
26
|
+
DELETE_BUCKET = auto()
|
|
27
|
+
HEAD_BUCKET = auto()
|
|
28
|
+
GET_BUCKET_LOCATION = auto()
|
|
29
|
+
GET_BUCKET_VERSIONING = auto()
|
|
30
|
+
PUT_BUCKET_VERSIONING = auto()
|
|
31
|
+
GET_BUCKET_LIFECYCLE = auto()
|
|
32
|
+
PUT_BUCKET_LIFECYCLE = auto()
|
|
33
|
+
DELETE_BUCKET_LIFECYCLE = auto()
|
|
34
|
+
GET_BUCKET_CORS = auto()
|
|
35
|
+
PUT_BUCKET_CORS = auto()
|
|
36
|
+
DELETE_BUCKET_CORS = auto()
|
|
37
|
+
CORS_PREFLIGHT = auto()
|
|
38
|
+
POST_OBJECT = auto()
|
|
39
|
+
GET_BUCKET_ACL = auto()
|
|
40
|
+
PUT_BUCKET_ACL = auto()
|
|
41
|
+
LIST_OBJECTS = auto()
|
|
42
|
+
LIST_OBJECTS_V2 = auto()
|
|
43
|
+
LIST_MULTIPART_UPLOADS = auto()
|
|
44
|
+
LIST_OBJECT_VERSIONS = auto()
|
|
45
|
+
DELETE_OBJECTS = auto()
|
|
46
|
+
|
|
47
|
+
PUT_OBJECT = auto()
|
|
48
|
+
COPY_OBJECT = auto()
|
|
49
|
+
GET_OBJECT = auto()
|
|
50
|
+
HEAD_OBJECT = auto()
|
|
51
|
+
DELETE_OBJECT = auto()
|
|
52
|
+
DELETE_OBJECT_VERSION = auto()
|
|
53
|
+
GET_OBJECT_TAGGING = auto()
|
|
54
|
+
PUT_OBJECT_TAGGING = auto()
|
|
55
|
+
DELETE_OBJECT_TAGGING = auto()
|
|
56
|
+
GET_OBJECT_ACL = auto()
|
|
57
|
+
PUT_OBJECT_ACL = auto()
|
|
58
|
+
CREATE_MULTIPART_UPLOAD = auto()
|
|
59
|
+
UPLOAD_PART = auto()
|
|
60
|
+
UPLOAD_PART_COPY = auto()
|
|
61
|
+
COMPLETE_MULTIPART_UPLOAD = auto()
|
|
62
|
+
ABORT_MULTIPART_UPLOAD = auto()
|
|
63
|
+
LIST_PARTS = auto()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ServiceRoute:
|
|
68
|
+
operation: Operation
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class BucketRoute:
|
|
73
|
+
operation: Operation
|
|
74
|
+
bucket: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ObjectRoute:
|
|
79
|
+
operation: Operation
|
|
80
|
+
bucket: str
|
|
81
|
+
key: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
type Route = ServiceRoute | BucketRoute | ObjectRoute
|
|
85
|
+
type _Rule = tuple[tuple[tuple[str, Operation], ...], Operation | None]
|
|
86
|
+
|
|
87
|
+
HEALTH_PATH = b"/-/health"
|
|
88
|
+
METRICS_PATH = b"/-/metrics"
|
|
89
|
+
MAX_KEY_BYTES = 1024
|
|
90
|
+
|
|
91
|
+
_BUCKET_NAME = re.compile(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]")
|
|
92
|
+
_BUCKET_NAME_BAD_SEQUENCE = re.compile(r"\.\.|\.-|-\.")
|
|
93
|
+
_IP_ADDRESS = re.compile(r"\d+\.\d+\.\d+\.\d+")
|
|
94
|
+
|
|
95
|
+
# Each entry: (query parameter that selects an operation, ...), default operation.
|
|
96
|
+
_BUCKET_RULES: dict[str, _Rule] = {
|
|
97
|
+
"GET": (
|
|
98
|
+
(
|
|
99
|
+
("location", Operation.GET_BUCKET_LOCATION),
|
|
100
|
+
("versioning", Operation.GET_BUCKET_VERSIONING),
|
|
101
|
+
("lifecycle", Operation.GET_BUCKET_LIFECYCLE),
|
|
102
|
+
("cors", Operation.GET_BUCKET_CORS),
|
|
103
|
+
("acl", Operation.GET_BUCKET_ACL),
|
|
104
|
+
("uploads", Operation.LIST_MULTIPART_UPLOADS),
|
|
105
|
+
("versions", Operation.LIST_OBJECT_VERSIONS),
|
|
106
|
+
("list-type", Operation.LIST_OBJECTS_V2),
|
|
107
|
+
),
|
|
108
|
+
Operation.LIST_OBJECTS,
|
|
109
|
+
),
|
|
110
|
+
"HEAD": ((), Operation.HEAD_BUCKET),
|
|
111
|
+
"PUT": (
|
|
112
|
+
(
|
|
113
|
+
("versioning", Operation.PUT_BUCKET_VERSIONING),
|
|
114
|
+
("lifecycle", Operation.PUT_BUCKET_LIFECYCLE),
|
|
115
|
+
("cors", Operation.PUT_BUCKET_CORS),
|
|
116
|
+
("acl", Operation.PUT_BUCKET_ACL),
|
|
117
|
+
),
|
|
118
|
+
Operation.CREATE_BUCKET,
|
|
119
|
+
),
|
|
120
|
+
"DELETE": (
|
|
121
|
+
(
|
|
122
|
+
("lifecycle", Operation.DELETE_BUCKET_LIFECYCLE),
|
|
123
|
+
("cors", Operation.DELETE_BUCKET_CORS),
|
|
124
|
+
),
|
|
125
|
+
Operation.DELETE_BUCKET,
|
|
126
|
+
),
|
|
127
|
+
"POST": ((("delete", Operation.DELETE_OBJECTS),), None),
|
|
128
|
+
}
|
|
129
|
+
_OBJECT_RULES: dict[str, _Rule] = {
|
|
130
|
+
"GET": (
|
|
131
|
+
(
|
|
132
|
+
("tagging", Operation.GET_OBJECT_TAGGING),
|
|
133
|
+
("acl", Operation.GET_OBJECT_ACL),
|
|
134
|
+
("uploadId", Operation.LIST_PARTS),
|
|
135
|
+
),
|
|
136
|
+
Operation.GET_OBJECT,
|
|
137
|
+
),
|
|
138
|
+
"HEAD": ((), Operation.HEAD_OBJECT),
|
|
139
|
+
"PUT": (
|
|
140
|
+
(
|
|
141
|
+
("tagging", Operation.PUT_OBJECT_TAGGING),
|
|
142
|
+
("acl", Operation.PUT_OBJECT_ACL),
|
|
143
|
+
("uploadId", Operation.UPLOAD_PART),
|
|
144
|
+
),
|
|
145
|
+
Operation.PUT_OBJECT,
|
|
146
|
+
),
|
|
147
|
+
"DELETE": (
|
|
148
|
+
(
|
|
149
|
+
# First: `?versionId=x&tagging` must not delete the version.
|
|
150
|
+
("tagging", Operation.DELETE_OBJECT_TAGGING),
|
|
151
|
+
("uploadId", Operation.ABORT_MULTIPART_UPLOAD),
|
|
152
|
+
("versionId", Operation.DELETE_OBJECT_VERSION),
|
|
153
|
+
),
|
|
154
|
+
Operation.DELETE_OBJECT,
|
|
155
|
+
),
|
|
156
|
+
"POST": (
|
|
157
|
+
(
|
|
158
|
+
("uploads", Operation.CREATE_MULTIPART_UPLOAD),
|
|
159
|
+
("uploadId", Operation.COMPLETE_MULTIPART_UPLOAD),
|
|
160
|
+
),
|
|
161
|
+
None,
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Query parameters that change what a request means. Serving a plain object or
|
|
166
|
+
# listing in answer to one of these would be a silent lie, so they are refused.
|
|
167
|
+
_UNSUPPORTED_PARAMETERS = frozenset({
|
|
168
|
+
"accelerate",
|
|
169
|
+
"acl",
|
|
170
|
+
"analytics",
|
|
171
|
+
"attributes",
|
|
172
|
+
"cors",
|
|
173
|
+
"encryption",
|
|
174
|
+
"intelligent-tiering",
|
|
175
|
+
"inventory",
|
|
176
|
+
"legal-hold",
|
|
177
|
+
"lifecycle",
|
|
178
|
+
"logging",
|
|
179
|
+
"metrics",
|
|
180
|
+
"notification",
|
|
181
|
+
"object-lock",
|
|
182
|
+
"ownershipControls",
|
|
183
|
+
"partNumber",
|
|
184
|
+
"policy",
|
|
185
|
+
"policyStatus",
|
|
186
|
+
"publicAccessBlock",
|
|
187
|
+
"replication",
|
|
188
|
+
"requestPayment",
|
|
189
|
+
"restore",
|
|
190
|
+
"retention",
|
|
191
|
+
"select",
|
|
192
|
+
"tagging",
|
|
193
|
+
"torrent",
|
|
194
|
+
"versionId",
|
|
195
|
+
"versioning",
|
|
196
|
+
"website",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_VERSIONED_READS = frozenset({"versionId"})
|
|
201
|
+
_COPY_SOURCE_HEADER = "x-amz-copy-source"
|
|
202
|
+
_COPY_RANGE_HEADER = "x-amz-copy-source-range"
|
|
203
|
+
_COPYING_FORM = {
|
|
204
|
+
Operation.PUT_OBJECT: Operation.COPY_OBJECT,
|
|
205
|
+
Operation.UPLOAD_PART: Operation.UPLOAD_PART_COPY,
|
|
206
|
+
}
|
|
207
|
+
_CUSTOMER_KEY_PREFIX = "x-amz-server-side-encryption-customer-"
|
|
208
|
+
_COPY_SOURCE_KEY_PREFIX = "x-amz-copy-source-server-side-encryption-customer-"
|
|
209
|
+
_REQUESTS_WITH_A_KEY = frozenset({
|
|
210
|
+
Operation.PUT_OBJECT,
|
|
211
|
+
Operation.GET_OBJECT,
|
|
212
|
+
Operation.HEAD_OBJECT,
|
|
213
|
+
Operation.COPY_OBJECT,
|
|
214
|
+
Operation.CREATE_MULTIPART_UPLOAD,
|
|
215
|
+
Operation.UPLOAD_PART,
|
|
216
|
+
Operation.UPLOAD_PART_COPY,
|
|
217
|
+
})
|
|
218
|
+
_REQUESTS_WITH_A_SOURCE_KEY = frozenset({
|
|
219
|
+
Operation.COPY_OBJECT,
|
|
220
|
+
Operation.UPLOAD_PART_COPY,
|
|
221
|
+
})
|
|
222
|
+
_ACL_HEADER = "x-amz-acl"
|
|
223
|
+
_CANNED_ACLS = frozenset({"private", "public-read"})
|
|
224
|
+
_WRITES_WITH_ACL = frozenset({
|
|
225
|
+
Operation.CREATE_BUCKET,
|
|
226
|
+
Operation.PUT_BUCKET_ACL,
|
|
227
|
+
Operation.PUT_OBJECT_ACL,
|
|
228
|
+
Operation.PUT_OBJECT,
|
|
229
|
+
Operation.COPY_OBJECT,
|
|
230
|
+
Operation.CREATE_MULTIPART_UPLOAD,
|
|
231
|
+
})
|
|
232
|
+
_TAGS_HEADER = "x-amz-tagging"
|
|
233
|
+
_WRITES_WITH_TAGS = frozenset({
|
|
234
|
+
Operation.PUT_OBJECT,
|
|
235
|
+
Operation.COPY_OBJECT,
|
|
236
|
+
Operation.CREATE_MULTIPART_UPLOAD,
|
|
237
|
+
})
|
|
238
|
+
_PARTS_OF_AN_UPLOAD = frozenset({"partNumber"})
|
|
239
|
+
|
|
240
|
+
_UNSUPPORTED_HEADER_PREFIXES = (
|
|
241
|
+
"x-amz-grant-",
|
|
242
|
+
"x-amz-if-match-",
|
|
243
|
+
"x-amz-object-lock-",
|
|
244
|
+
"x-amz-server-side-encryption",
|
|
245
|
+
"x-amz-website-redirect-location",
|
|
246
|
+
)
|
|
247
|
+
# Asking for what we do anyway is fine: rclone sends the object-lock header on
|
|
248
|
+
# every CreateBucket, with the value "false".
|
|
249
|
+
_ACCEPTED_HEADER_VALUES = {
|
|
250
|
+
"x-amz-bucket-object-lock-enabled": "false",
|
|
251
|
+
"x-amz-storage-class": "STANDARD",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def route(request: HttpRequest, base_domain: str | None) -> Route:
|
|
256
|
+
"""Decide which S3 operation a request asks for."""
|
|
257
|
+
if request.raw_path == HEALTH_PATH:
|
|
258
|
+
return ServiceRoute(Operation.HEALTH)
|
|
259
|
+
if request.raw_path == METRICS_PATH:
|
|
260
|
+
return ServiceRoute(Operation.METRICS)
|
|
261
|
+
refuse_unsupported_headers(request.headers)
|
|
262
|
+
|
|
263
|
+
host = request.headers.get("host", "")
|
|
264
|
+
bucket, raw_key = _split_target(host, request.raw_path, base_domain)
|
|
265
|
+
if bucket is None:
|
|
266
|
+
if request.method != "GET":
|
|
267
|
+
raise S3Error(errors.METHOD_NOT_ALLOWED)
|
|
268
|
+
return ServiceRoute(Operation.LIST_BUCKETS)
|
|
269
|
+
|
|
270
|
+
if not raw_key:
|
|
271
|
+
return _route_bucket(request, bucket)
|
|
272
|
+
if request.method == "OPTIONS":
|
|
273
|
+
return ObjectRoute(Operation.CORS_PREFLIGHT, bucket, parse_key(raw_key))
|
|
274
|
+
|
|
275
|
+
operation = _select(
|
|
276
|
+
_OBJECT_RULES, request.method, request.query, _tolerated(request)
|
|
277
|
+
)
|
|
278
|
+
if _COPY_SOURCE_HEADER in request.headers:
|
|
279
|
+
operation = _COPYING_FORM.get(operation, operation)
|
|
280
|
+
_refuse_stray_headers(request.headers, operation)
|
|
281
|
+
return ObjectRoute(operation, bucket, parse_key(raw_key))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _route_bucket(request: HttpRequest, bucket: str) -> BucketRoute:
|
|
285
|
+
# Whatever the query asks for: it describes the request a browser means to send.
|
|
286
|
+
if request.method == "OPTIONS":
|
|
287
|
+
return BucketRoute(Operation.CORS_PREFLIGHT, bucket)
|
|
288
|
+
selected = (
|
|
289
|
+
Operation.POST_OBJECT
|
|
290
|
+
if _is_form_upload(request)
|
|
291
|
+
else _select(_BUCKET_RULES, request.method, request.query)
|
|
292
|
+
)
|
|
293
|
+
_refuse_stray_headers(request.headers, selected)
|
|
294
|
+
return BucketRoute(selected, bucket)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_query(raw_query: bytes) -> dict[str, str]:
|
|
298
|
+
return dict(parse_qsl(raw_query.decode("latin-1"), keep_blank_values=True))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def parse_bucket_name(name: str) -> str:
|
|
302
|
+
"""Return a valid bucket name, or raise InvalidBucketName."""
|
|
303
|
+
valid = (
|
|
304
|
+
_BUCKET_NAME.fullmatch(name)
|
|
305
|
+
and not _BUCKET_NAME_BAD_SEQUENCE.search(name)
|
|
306
|
+
and not _IP_ADDRESS.fullmatch(name)
|
|
307
|
+
)
|
|
308
|
+
if not valid:
|
|
309
|
+
raise S3Error(errors.INVALID_BUCKET_NAME, f"Invalid bucket name: {name!r}.")
|
|
310
|
+
return name
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_raw_bucket(raw_bucket: bytes) -> str:
|
|
314
|
+
"""Return a valid bucket name from its percent-encoded wire form."""
|
|
315
|
+
return parse_bucket_name(unquote_to_bytes(raw_bucket).decode(errors="replace"))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_key(raw_key: bytes) -> str:
|
|
319
|
+
"""Return a valid object key from its percent-encoded wire form."""
|
|
320
|
+
return parse_decoded_key(unquote_to_bytes(raw_key))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_decoded_key(decoded: bytes) -> str:
|
|
324
|
+
"""Return a valid object key from its raw bytes."""
|
|
325
|
+
if len(decoded) > MAX_KEY_BYTES:
|
|
326
|
+
raise S3Error(errors.KEY_TOO_LONG)
|
|
327
|
+
if b"\x00" in decoded:
|
|
328
|
+
raise S3Error(errors.INVALID_ARGUMENT, "Object keys cannot contain NUL.")
|
|
329
|
+
try:
|
|
330
|
+
return decoded.decode("utf-8")
|
|
331
|
+
except UnicodeDecodeError:
|
|
332
|
+
raise S3Error(
|
|
333
|
+
errors.INVALID_ARGUMENT, "Object keys must be valid UTF-8."
|
|
334
|
+
) from None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def refuse_unsupported_headers(headers: Mapping[str, str]) -> None:
|
|
338
|
+
"""Refuse promises we can't keep, above all encryption.
|
|
339
|
+
|
|
340
|
+
Ignoring them would store plaintext while the client believes it is encrypted.
|
|
341
|
+
"""
|
|
342
|
+
for name, value in headers.items():
|
|
343
|
+
if name == _ACL_HEADER and value not in _CANNED_ACLS:
|
|
344
|
+
raise S3Error(
|
|
345
|
+
errors.NOT_IMPLEMENTED, f"{name}: {value} is not implemented."
|
|
346
|
+
)
|
|
347
|
+
if name.startswith(_UNSUPPORTED_HEADER_PREFIXES) and not name.startswith(
|
|
348
|
+
_CUSTOMER_KEY_PREFIX
|
|
349
|
+
):
|
|
350
|
+
raise S3Error(errors.NOT_IMPLEMENTED, f"Header {name} is not implemented.")
|
|
351
|
+
if _ACCEPTED_HEADER_VALUES.get(name, value) != value:
|
|
352
|
+
raise S3Error(
|
|
353
|
+
errors.NOT_IMPLEMENTED, f"{name}: {value} is not implemented."
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _refuse_stray_headers(headers: Mapping[str, str], operation: Operation) -> None:
|
|
358
|
+
"""Refuse what a request asks for and its operation wouldn't do, not to lose it."""
|
|
359
|
+
if _COPY_SOURCE_HEADER in headers and operation not in _REQUESTS_WITH_A_SOURCE_KEY:
|
|
360
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "This request can't copy.")
|
|
361
|
+
# Copying a whole object instead would be more than was asked for.
|
|
362
|
+
if _COPY_RANGE_HEADER in headers and operation is not Operation.UPLOAD_PART_COPY:
|
|
363
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "This request can't copy a range.")
|
|
364
|
+
if _TAGS_HEADER in headers and operation not in _WRITES_WITH_TAGS:
|
|
365
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "This request can't set tags.")
|
|
366
|
+
if headers.get(_ACL_HEADER) == "public-read" and operation not in _WRITES_WITH_ACL:
|
|
367
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "This request can't set an ACL.")
|
|
368
|
+
# A key ignored means a client believes it encrypted what it didn't.
|
|
369
|
+
for name in headers:
|
|
370
|
+
if (
|
|
371
|
+
name.startswith(_CUSTOMER_KEY_PREFIX)
|
|
372
|
+
and operation not in _REQUESTS_WITH_A_KEY
|
|
373
|
+
) or (
|
|
374
|
+
name.startswith(_COPY_SOURCE_KEY_PREFIX)
|
|
375
|
+
and operation not in _REQUESTS_WITH_A_SOURCE_KEY
|
|
376
|
+
):
|
|
377
|
+
raise S3Error(errors.NOT_IMPLEMENTED, f"This request can't take {name}.")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _is_form_upload(request: HttpRequest) -> bool:
|
|
381
|
+
return (
|
|
382
|
+
request.method == "POST"
|
|
383
|
+
and not request.query
|
|
384
|
+
and request.headers
|
|
385
|
+
.get("content-type", "")
|
|
386
|
+
.lower()
|
|
387
|
+
.startswith("multipart/form-data")
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _split_target(
|
|
392
|
+
host: str, raw_path: bytes, base_domain: str | None
|
|
393
|
+
) -> tuple[str | None, bytes]:
|
|
394
|
+
"""Separate the bucket from the still-encoded key, for both addressing styles."""
|
|
395
|
+
path = raw_path.removeprefix(b"/")
|
|
396
|
+
hostname = host.rsplit(":", 1)[0].lower()
|
|
397
|
+
if base_domain and hostname.endswith(f".{base_domain}"):
|
|
398
|
+
return parse_bucket_name(hostname.removesuffix(f".{base_domain}")), path
|
|
399
|
+
|
|
400
|
+
raw_bucket, _, raw_key = path.partition(b"/")
|
|
401
|
+
if not raw_bucket:
|
|
402
|
+
return None, b""
|
|
403
|
+
return parse_raw_bucket(raw_bucket), raw_key
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _tolerated(request: HttpRequest) -> frozenset[str]:
|
|
407
|
+
"""Parameters that are part of an object operation, and don't change its meaning."""
|
|
408
|
+
if "uploadId" in request.query:
|
|
409
|
+
return _PARTS_OF_AN_UPLOAD
|
|
410
|
+
versioned = (
|
|
411
|
+
request.method in {"GET", "HEAD"}
|
|
412
|
+
or "tagging" in request.query
|
|
413
|
+
or (request.method == "PUT" and "acl" in request.query)
|
|
414
|
+
)
|
|
415
|
+
return _VERSIONED_READS if versioned else frozenset()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _select(
|
|
419
|
+
rules: dict[str, _Rule],
|
|
420
|
+
method: str,
|
|
421
|
+
query: Mapping[str, str],
|
|
422
|
+
tolerated: frozenset[str] = frozenset(),
|
|
423
|
+
) -> Operation:
|
|
424
|
+
if method not in rules:
|
|
425
|
+
raise S3Error(errors.METHOD_NOT_ALLOWED)
|
|
426
|
+
selectors, default = rules[method]
|
|
427
|
+
# Before the selectors: `?versionId=x&tagging` must not delete a version.
|
|
428
|
+
selecting = {parameter for parameter, _ in selectors}
|
|
429
|
+
unsupported = sorted(
|
|
430
|
+
(query.keys() & _UNSUPPORTED_PARAMETERS) - selecting - tolerated
|
|
431
|
+
)
|
|
432
|
+
if unsupported:
|
|
433
|
+
raise S3Error(
|
|
434
|
+
errors.NOT_IMPLEMENTED,
|
|
435
|
+
f"{method} with ?{unsupported[0]} is not implemented.",
|
|
436
|
+
)
|
|
437
|
+
for parameter, operation in selectors:
|
|
438
|
+
if parameter in query:
|
|
439
|
+
return operation
|
|
440
|
+
|
|
441
|
+
if default is None:
|
|
442
|
+
raise S3Error(errors.NOT_IMPLEMENTED, f"This {method} form is not implemented.")
|
|
443
|
+
return default
|