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/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