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/post_form.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Browser form uploads: reading `multipart/form-data`, and judging its policy.
|
|
2
|
+
|
|
3
|
+
The form can hold gigabytes, so `FormParser` takes it a chunk at a time, and is pure.
|
|
4
|
+
A body that ends before its closing delimiter never reports the end of its file, which
|
|
5
|
+
is what stops a cut-off upload from becoming an object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import Enum, auto
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from leblob.s3 import errors
|
|
20
|
+
from leblob.s3.errors import S3Error
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterator, Mapping
|
|
24
|
+
|
|
25
|
+
MAX_FIELDS_BYTES = 20 * 1024 # As AWS limits what comes before the file.
|
|
26
|
+
_MAX_BOUNDARY_BYTES = 200
|
|
27
|
+
_HEADERS_END = b"\r\n\r\n"
|
|
28
|
+
_CLOSING = b"--"
|
|
29
|
+
_LINE_END = b"\r\n"
|
|
30
|
+
_BOUNDARY = re.compile(r'boundary=(?:"([^"]+)"|([^\s;]+))', re.IGNORECASE)
|
|
31
|
+
_DISPOSITION_NAME = re.compile(r'[;\s]name="((?:[^"\\]|\\.)*)"', re.IGNORECASE)
|
|
32
|
+
_DISPOSITION_FILENAME = re.compile(r'[;\s]filename="((?:[^"\\]|\\.)*)"', re.IGNORECASE)
|
|
33
|
+
_ESCAPED = re.compile(r"\\(.)")
|
|
34
|
+
_EXPIRATION = re.compile(
|
|
35
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z"
|
|
36
|
+
)
|
|
37
|
+
FILE_FIELD = "file"
|
|
38
|
+
_NOT_FOR_THE_POLICY = frozenset({"file", "policy", "x-amz-signature"})
|
|
39
|
+
# A checksum can only make a form stricter, so it needs no one's leave.
|
|
40
|
+
_EXEMPT_PREFIXES = ("x-ignore-", "x-amz-checksum-")
|
|
41
|
+
_LENGTH_CONDITION = "content-length-range"
|
|
42
|
+
_EQUALS_ARGUMENTS = 3
|
|
43
|
+
_REFUSAL = "Invalid according to Policy: "
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Field:
|
|
48
|
+
name: str
|
|
49
|
+
value: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class FileStart:
|
|
54
|
+
filename: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class FileData:
|
|
59
|
+
data: bytes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class FileEnd:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
type Event = Field | FileStart | FileData | FileEnd
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class SizeLimits:
|
|
72
|
+
smallest: int
|
|
73
|
+
largest: int | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _State(Enum):
|
|
77
|
+
BEFORE_FIRST = auto()
|
|
78
|
+
AFTER_DELIMITER = auto()
|
|
79
|
+
HEADERS = auto()
|
|
80
|
+
VALUE = auto()
|
|
81
|
+
FILE = auto()
|
|
82
|
+
SKIPPING = auto()
|
|
83
|
+
DONE = auto()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_boundary(content_type: str) -> bytes:
|
|
87
|
+
found = _BOUNDARY.search(content_type)
|
|
88
|
+
boundary = (found[1] or found[2]) if found else ""
|
|
89
|
+
if not boundary or len(boundary) > _MAX_BOUNDARY_BYTES:
|
|
90
|
+
raise S3Error(errors.MALFORMED_POST, "The form has no usable boundary.")
|
|
91
|
+
return boundary.encode("latin-1")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FormParser:
|
|
95
|
+
"""Turn the chunks of a form into events. Only the first file is reported.
|
|
96
|
+
|
|
97
|
+
S3 ignores anything after the file, so such parts are skipped without keeping them.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, boundary: bytes) -> None:
|
|
101
|
+
self._delimiter = b"\r\n--" + boundary
|
|
102
|
+
# So that the first delimiter, which has no line end of its own, looks the same.
|
|
103
|
+
self._buffer = bytearray(_LINE_END)
|
|
104
|
+
self._state = _State.BEFORE_FIRST
|
|
105
|
+
self._seen_file = False
|
|
106
|
+
self._field_name = ""
|
|
107
|
+
self._field_bytes = 0
|
|
108
|
+
|
|
109
|
+
def feed(self, chunk: bytes) -> Iterator[Event]:
|
|
110
|
+
self._buffer += chunk
|
|
111
|
+
progress = True
|
|
112
|
+
while progress:
|
|
113
|
+
progress = False
|
|
114
|
+
for event in self._step():
|
|
115
|
+
progress = True
|
|
116
|
+
if event is not None:
|
|
117
|
+
yield event
|
|
118
|
+
self._check_size()
|
|
119
|
+
|
|
120
|
+
def finish(self) -> None:
|
|
121
|
+
if self._state is not _State.DONE:
|
|
122
|
+
raise S3Error(errors.MALFORMED_POST, "The form ends before it should.")
|
|
123
|
+
|
|
124
|
+
def _check_size(self) -> None:
|
|
125
|
+
if not self._seen_file and self._field_bytes + len(self._buffer) > (
|
|
126
|
+
MAX_FIELDS_BYTES
|
|
127
|
+
):
|
|
128
|
+
raise S3Error(errors.MAX_POST_PRE_DATA)
|
|
129
|
+
|
|
130
|
+
def _step(self) -> Iterator[Event | None]:
|
|
131
|
+
"""Do what the buffer allows of one state. Yields once for each advance."""
|
|
132
|
+
if self._state is _State.BEFORE_FIRST:
|
|
133
|
+
yield from self._skip_to_delimiter()
|
|
134
|
+
elif self._state is _State.AFTER_DELIMITER:
|
|
135
|
+
yield from self._read_after_delimiter()
|
|
136
|
+
elif self._state is _State.HEADERS:
|
|
137
|
+
yield from self._read_headers()
|
|
138
|
+
elif self._state is _State.VALUE:
|
|
139
|
+
yield from self._read_value()
|
|
140
|
+
elif self._state is _State.FILE:
|
|
141
|
+
yield from self._read_file()
|
|
142
|
+
elif self._state is _State.SKIPPING:
|
|
143
|
+
yield from self._skip_to_delimiter()
|
|
144
|
+
else:
|
|
145
|
+
self._buffer.clear()
|
|
146
|
+
|
|
147
|
+
def _take(self, length: int, then_skip: int = 0) -> bytes:
|
|
148
|
+
taken = bytes(self._buffer[:length])
|
|
149
|
+
del self._buffer[: length + then_skip]
|
|
150
|
+
return taken
|
|
151
|
+
|
|
152
|
+
def _skip_to_delimiter(self) -> Iterator[None]:
|
|
153
|
+
at = self._buffer.find(self._delimiter)
|
|
154
|
+
if at >= 0:
|
|
155
|
+
self._take(at, len(self._delimiter))
|
|
156
|
+
self._state = _State.AFTER_DELIMITER
|
|
157
|
+
yield None
|
|
158
|
+
elif self._seen_file:
|
|
159
|
+
# Keep only what could be the start of a delimiter.
|
|
160
|
+
keep = len(self._delimiter) - 1
|
|
161
|
+
if len(self._buffer) > keep:
|
|
162
|
+
self._take(len(self._buffer) - keep)
|
|
163
|
+
|
|
164
|
+
def _read_after_delimiter(self) -> Iterator[None]:
|
|
165
|
+
if len(self._buffer) < len(_CLOSING):
|
|
166
|
+
return
|
|
167
|
+
ending = self._take(len(_CLOSING))
|
|
168
|
+
if ending == _CLOSING:
|
|
169
|
+
self._state = _State.DONE
|
|
170
|
+
elif ending == _LINE_END:
|
|
171
|
+
self._state = _State.HEADERS
|
|
172
|
+
else:
|
|
173
|
+
raise S3Error(errors.MALFORMED_POST, "Bad text after a form boundary.")
|
|
174
|
+
yield None
|
|
175
|
+
|
|
176
|
+
def _read_headers(self) -> Iterator[Event | None]:
|
|
177
|
+
at = self._buffer.find(_HEADERS_END)
|
|
178
|
+
if at < 0:
|
|
179
|
+
if self._seen_file and len(self._buffer) > MAX_FIELDS_BYTES:
|
|
180
|
+
raise S3Error(
|
|
181
|
+
errors.MALFORMED_POST, "A form part's headers are too long."
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
headers = self._decode(self._take(at, len(_HEADERS_END)))
|
|
185
|
+
self._field_bytes += at + len(_HEADERS_END)
|
|
186
|
+
disposition = next(
|
|
187
|
+
(
|
|
188
|
+
line
|
|
189
|
+
for line in headers.split("\r\n")
|
|
190
|
+
if line.lower().startswith("content-disposition:")
|
|
191
|
+
),
|
|
192
|
+
"",
|
|
193
|
+
)
|
|
194
|
+
name = _DISPOSITION_NAME.search(disposition)
|
|
195
|
+
if name is None:
|
|
196
|
+
raise S3Error(errors.MALFORMED_POST, "A form part has no name.")
|
|
197
|
+
self._field_name = _unescape(name[1]).lower()
|
|
198
|
+
if self._seen_file:
|
|
199
|
+
self._state = _State.SKIPPING
|
|
200
|
+
yield None
|
|
201
|
+
elif self._field_name == FILE_FIELD:
|
|
202
|
+
filename = _DISPOSITION_FILENAME.search(disposition)
|
|
203
|
+
self._seen_file = True
|
|
204
|
+
self._state = _State.FILE
|
|
205
|
+
yield FileStart(_unescape(filename[1]) if filename else "")
|
|
206
|
+
else:
|
|
207
|
+
self._state = _State.VALUE
|
|
208
|
+
yield None
|
|
209
|
+
|
|
210
|
+
def _read_value(self) -> Iterator[Event]:
|
|
211
|
+
at = self._buffer.find(self._delimiter)
|
|
212
|
+
if at < 0:
|
|
213
|
+
return
|
|
214
|
+
self._field_bytes += at + len(self._delimiter)
|
|
215
|
+
value = self._decode(self._take(at, len(self._delimiter)))
|
|
216
|
+
self._state = _State.AFTER_DELIMITER
|
|
217
|
+
yield Field(self._field_name, value)
|
|
218
|
+
|
|
219
|
+
def _read_file(self) -> Iterator[Event]:
|
|
220
|
+
at = self._buffer.find(self._delimiter)
|
|
221
|
+
if at >= 0:
|
|
222
|
+
data = self._take(at, len(self._delimiter))
|
|
223
|
+
if data:
|
|
224
|
+
yield FileData(data)
|
|
225
|
+
self._state = _State.AFTER_DELIMITER
|
|
226
|
+
yield FileEnd()
|
|
227
|
+
return
|
|
228
|
+
safe = len(self._buffer) - (len(self._delimiter) - 1)
|
|
229
|
+
if safe > 0:
|
|
230
|
+
yield FileData(self._take(safe))
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _decode(data: bytes) -> str:
|
|
234
|
+
try:
|
|
235
|
+
return data.decode()
|
|
236
|
+
except UnicodeDecodeError:
|
|
237
|
+
raise S3Error(errors.MALFORMED_POST, "A form field isn't UTF-8.") from None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _unescape(text: str) -> str:
|
|
241
|
+
return _ESCAPED.sub(r"\1", text)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def check_policy(fields: Mapping[str, str], now: float) -> SizeLimits:
|
|
245
|
+
"""Hold a form to its policy: nothing it doesn't allow, nor less than it asks."""
|
|
246
|
+
document = _load_policy(fields["policy"])
|
|
247
|
+
conditions = document.get("conditions")
|
|
248
|
+
if not isinstance(conditions, list):
|
|
249
|
+
raise S3Error(errors.INVALID_POLICY, "The policy has no conditions.")
|
|
250
|
+
_check_expiration(document.get("expiration"), now)
|
|
251
|
+
|
|
252
|
+
covered: set[str] = set()
|
|
253
|
+
limits = SizeLimits(0, None)
|
|
254
|
+
for condition in conditions:
|
|
255
|
+
if _is_length_condition(condition):
|
|
256
|
+
limits = _narrowed(limits, condition)
|
|
257
|
+
else:
|
|
258
|
+
covered.add(_check_condition(condition, fields))
|
|
259
|
+
for name in fields:
|
|
260
|
+
exempt = name in _NOT_FOR_THE_POLICY or name.startswith(_EXEMPT_PREFIXES)
|
|
261
|
+
if not exempt and name not in covered:
|
|
262
|
+
raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Extra field: {name}.")
|
|
263
|
+
return limits
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _load_policy(encoded: str) -> dict[str, object]:
|
|
267
|
+
try:
|
|
268
|
+
document = json.loads(base64.b64decode(encoded, validate=True))
|
|
269
|
+
except (binascii.Error, ValueError, RecursionError):
|
|
270
|
+
raise S3Error(errors.INVALID_POLICY, "The policy can't be read.") from None
|
|
271
|
+
if not isinstance(document, dict):
|
|
272
|
+
raise S3Error(errors.INVALID_POLICY, "The policy can't be read.")
|
|
273
|
+
return document
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _check_expiration(expiration: object, now: float) -> None:
|
|
277
|
+
if not isinstance(expiration, str):
|
|
278
|
+
raise S3Error(errors.INVALID_POLICY, "The policy has no expiration.")
|
|
279
|
+
if _EXPIRATION.fullmatch(expiration) is None:
|
|
280
|
+
raise S3Error(errors.INVALID_POLICY, "Bad expiration in the policy.")
|
|
281
|
+
try:
|
|
282
|
+
moment = datetime.fromisoformat(expiration)
|
|
283
|
+
except ValueError:
|
|
284
|
+
raise S3Error(errors.INVALID_POLICY, "Bad expiration in the policy.") from None
|
|
285
|
+
if moment.timestamp() < now:
|
|
286
|
+
raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Policy expired.")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _is_length_condition(condition: object) -> bool:
|
|
290
|
+
return (
|
|
291
|
+
isinstance(condition, list)
|
|
292
|
+
and bool(condition)
|
|
293
|
+
and isinstance(condition[0], str)
|
|
294
|
+
and condition[0].lower() == _LENGTH_CONDITION
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _narrowed(limits: SizeLimits, condition: list[object]) -> SizeLimits:
|
|
299
|
+
try:
|
|
300
|
+
_, smallest, largest = condition
|
|
301
|
+
low, high = int(str(smallest)), int(str(largest))
|
|
302
|
+
except ValueError:
|
|
303
|
+
raise S3Error(errors.INVALID_POLICY, "Bad content-length-range.") from None
|
|
304
|
+
if low < 0 or high < low:
|
|
305
|
+
raise S3Error(errors.INVALID_POLICY, "Bad content-length-range.")
|
|
306
|
+
return SizeLimits(
|
|
307
|
+
max(limits.smallest, low),
|
|
308
|
+
high if limits.largest is None else min(limits.largest, high),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _check_condition(condition: object, fields: Mapping[str, str]) -> str:
|
|
313
|
+
"""Check a condition holds, and return the field it covers."""
|
|
314
|
+
if isinstance(condition, dict) and len(condition) == 1:
|
|
315
|
+
[(name, wanted)] = condition.items()
|
|
316
|
+
return _check_field("eq", f"${name}", wanted, fields)
|
|
317
|
+
if isinstance(condition, list) and len(condition) == _EQUALS_ARGUMENTS:
|
|
318
|
+
operator, name, wanted = condition
|
|
319
|
+
return _check_field(operator, name, wanted, fields)
|
|
320
|
+
raise S3Error(errors.INVALID_POLICY, "A policy condition can't be read.")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _check_field(
|
|
324
|
+
operator: object, name: object, wanted: object, fields: Mapping[str, str]
|
|
325
|
+
) -> str:
|
|
326
|
+
if not (
|
|
327
|
+
isinstance(operator, str)
|
|
328
|
+
and isinstance(name, str)
|
|
329
|
+
and name.startswith("$")
|
|
330
|
+
and isinstance(wanted, str)
|
|
331
|
+
):
|
|
332
|
+
raise S3Error(errors.INVALID_POLICY, "A policy condition can't be read.")
|
|
333
|
+
field = name[1:].lower()
|
|
334
|
+
value = fields.get(field)
|
|
335
|
+
verb = operator.lower()
|
|
336
|
+
if verb == "eq":
|
|
337
|
+
holds = value == wanted
|
|
338
|
+
elif verb == "starts-with":
|
|
339
|
+
holds = value is not None and value.startswith(wanted)
|
|
340
|
+
else:
|
|
341
|
+
raise S3Error(errors.INVALID_POLICY, f"Unknown condition: {operator!r}.")
|
|
342
|
+
if not holds:
|
|
343
|
+
raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Condition failed: {field}.")
|
|
344
|
+
return field
|