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/listing.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Listing logic: prefixes, delimiters and stateless pagination.
|
|
2
|
+
|
|
3
|
+
The scan takes its rows from a `fetch` callable, so all of the logic here runs
|
|
4
|
+
without a database. Python compares strings by code point, which is the same
|
|
5
|
+
order as comparing their UTF-8 bytes, the order S3 specifies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from itertools import islice, takewhile
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol
|
|
15
|
+
|
|
16
|
+
from leblob.s3 import errors
|
|
17
|
+
from leblob.s3.errors import S3Error
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
21
|
+
|
|
22
|
+
from leblob.model import VersionSummary
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Keyed(Protocol):
|
|
26
|
+
@property
|
|
27
|
+
def key(self) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MAX_KEYS = 1000
|
|
31
|
+
|
|
32
|
+
_MAX_CODE_POINT = 0x10FFFF
|
|
33
|
+
_SURROGATES_START = 0xD800
|
|
34
|
+
_AFTER_SURROGATES = 0xE000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ListQuery:
|
|
39
|
+
prefix: str
|
|
40
|
+
delimiter: str
|
|
41
|
+
after: str
|
|
42
|
+
max_keys: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ListPage[T]:
|
|
47
|
+
objects: tuple[T, ...]
|
|
48
|
+
common_prefixes: tuple[str, ...]
|
|
49
|
+
next_marker: str | None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_truncated(self) -> bool:
|
|
53
|
+
return self.next_marker is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scan[T: _Keyed](
|
|
57
|
+
fetch: Callable[[str, str | None, int], Iterator[T]],
|
|
58
|
+
query: ListQuery,
|
|
59
|
+
*,
|
|
60
|
+
resume_inside_key: bool = False,
|
|
61
|
+
) -> ListPage[T]:
|
|
62
|
+
"""Produce one page, skipping over each common prefix rather than walking it.
|
|
63
|
+
|
|
64
|
+
`fetch(lower, upper, limit)` yields up to `limit` rows in key order, with
|
|
65
|
+
`lower <= key`, and `key < upper` unless `upper` is None. It must be lazy: each
|
|
66
|
+
common prefix uses one row of a fetch and abandons the others.
|
|
67
|
+
"""
|
|
68
|
+
prefix, delimiter, max_keys = query.prefix, query.delimiter, query.max_keys
|
|
69
|
+
if max_keys == 0:
|
|
70
|
+
return ListPage((), (), None)
|
|
71
|
+
|
|
72
|
+
lower = (
|
|
73
|
+
max(prefix, query.after)
|
|
74
|
+
if resume_inside_key
|
|
75
|
+
else _first_key_after(query.after, prefix, delimiter)
|
|
76
|
+
)
|
|
77
|
+
upper = key_after_prefix(prefix) if prefix else None
|
|
78
|
+
objects: list[T] = []
|
|
79
|
+
prefixes: list[str] = []
|
|
80
|
+
last_entry = ""
|
|
81
|
+
|
|
82
|
+
while lower is not None:
|
|
83
|
+
remaining = max_keys - len(objects) - len(prefixes)
|
|
84
|
+
rows = fetch(lower, upper, remaining + 1)
|
|
85
|
+
lower = None
|
|
86
|
+
for row in rows:
|
|
87
|
+
if len(objects) + len(prefixes) == max_keys:
|
|
88
|
+
return ListPage(tuple(objects), tuple(prefixes), last_entry)
|
|
89
|
+
common = common_prefix(row.key, prefix, delimiter)
|
|
90
|
+
if common is None:
|
|
91
|
+
objects.append(row)
|
|
92
|
+
last_entry = row.key
|
|
93
|
+
continue
|
|
94
|
+
prefixes.append(common)
|
|
95
|
+
last_entry = common
|
|
96
|
+
lower = key_after_prefix(common)
|
|
97
|
+
break
|
|
98
|
+
return ListPage(tuple(objects), tuple(prefixes), None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scan_versions(
|
|
102
|
+
fetch: Callable[[str], Iterator[VersionSummary]],
|
|
103
|
+
query: ListQuery,
|
|
104
|
+
version_marker: str,
|
|
105
|
+
) -> ListPage[VersionSummary]:
|
|
106
|
+
"""Produce one page of versions, newest first within each key.
|
|
107
|
+
|
|
108
|
+
`fetch(lower)` yields every version from that key on, in order. A page can end
|
|
109
|
+
within a key, so `version_marker` resumes after that version of `query.after`.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def windowed(lower: str, upper: str | None, limit: int) -> Iterator[VersionSummary]:
|
|
113
|
+
rows: Iterable[VersionSummary] = fetch(lower)
|
|
114
|
+
if upper is not None:
|
|
115
|
+
rows = takewhile(lambda row: row.key < upper, rows)
|
|
116
|
+
return islice(
|
|
117
|
+
_after_version(_mark_latest(rows), query.after, version_marker), limit
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return scan(windowed, query, resume_inside_key=bool(version_marker))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _mark_latest(rows: Iterable[VersionSummary]) -> Iterator[VersionSummary]:
|
|
124
|
+
"""The first version of each key is its latest, as rows arrive newest first."""
|
|
125
|
+
previous = None
|
|
126
|
+
for row in rows:
|
|
127
|
+
yield replace(row, is_latest=row.key != previous)
|
|
128
|
+
previous = row.key
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _after_version(
|
|
132
|
+
rows: Iterable[VersionSummary], key: str, version_id: str
|
|
133
|
+
) -> Iterator[VersionSummary]:
|
|
134
|
+
passed = not version_id
|
|
135
|
+
for row in rows:
|
|
136
|
+
if row.key == key and not passed:
|
|
137
|
+
passed = row.version_id == version_id
|
|
138
|
+
continue
|
|
139
|
+
yield row
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def common_prefix(key: str, prefix: str, delimiter: str) -> str | None:
|
|
143
|
+
"""Return the prefix this key rolls up into, if it does."""
|
|
144
|
+
if not delimiter:
|
|
145
|
+
return None
|
|
146
|
+
position = key.find(delimiter, len(prefix))
|
|
147
|
+
if position < 0:
|
|
148
|
+
return None
|
|
149
|
+
return key[: position + len(delimiter)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def key_after_prefix(prefix: str) -> str | None:
|
|
153
|
+
"""Return the smallest string above all those starting with `prefix`."""
|
|
154
|
+
for index in reversed(range(len(prefix))):
|
|
155
|
+
code = ord(prefix[index])
|
|
156
|
+
if code == _MAX_CODE_POINT:
|
|
157
|
+
continue
|
|
158
|
+
code += 1
|
|
159
|
+
if _SURROGATES_START <= code < _AFTER_SURROGATES:
|
|
160
|
+
code = _AFTER_SURROGATES
|
|
161
|
+
return prefix[:index] + chr(code)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_max_keys(query: Mapping[str, str], name: str = "max-keys") -> int:
|
|
166
|
+
value = query.get(name, str(MAX_KEYS))
|
|
167
|
+
if not value.isdecimal():
|
|
168
|
+
raise S3Error(
|
|
169
|
+
errors.INVALID_ARGUMENT, f"{name} must be a non-negative integer."
|
|
170
|
+
)
|
|
171
|
+
return min(int(value), MAX_KEYS)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def encode_token(marker: str) -> str:
|
|
175
|
+
"""Encode a position; the token holds the whole state, so any worker can resume."""
|
|
176
|
+
return base64.urlsafe_b64encode(marker.encode()).decode()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def decode_token(token: str) -> str:
|
|
180
|
+
try:
|
|
181
|
+
# Strict: lenient decoding would turn a corrupt token into "start over".
|
|
182
|
+
return base64.b64decode(token.encode(), altchars=b"-_", validate=True).decode()
|
|
183
|
+
except (binascii.Error, UnicodeDecodeError):
|
|
184
|
+
raise S3Error(
|
|
185
|
+
errors.INVALID_ARGUMENT, "The continuation token is not valid."
|
|
186
|
+
) from None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _first_key_after(after: str, prefix: str, delimiter: str) -> str | None:
|
|
190
|
+
"""Return the scan's lower bound. Keys cannot contain NUL, so `x\\0` follows `x`."""
|
|
191
|
+
if not after:
|
|
192
|
+
return prefix
|
|
193
|
+
if common_prefix(after, prefix, delimiter) == after:
|
|
194
|
+
lower = key_after_prefix(after)
|
|
195
|
+
return None if lower is None else max(prefix, lower)
|
|
196
|
+
return max(prefix, f"{after}\0")
|
leblob/s3/multipart.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Rules for turning uploaded parts into a finished object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from leblob.model import ChecksumPlan, ChecksumType, CompletionRequest
|
|
10
|
+
from leblob.s3 import errors, payload
|
|
11
|
+
from leblob.s3.errors import S3Error
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from leblob.model import Blob, Checksum, Part, RequestedPart
|
|
17
|
+
|
|
18
|
+
MIN_PART_NUMBER = 1
|
|
19
|
+
MAX_PART_NUMBER = 10_000
|
|
20
|
+
MIN_PART_BYTES = 5 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
# Only a CRC can be combined into a whole object's. The first type is the default.
|
|
23
|
+
_CHECKSUM_TYPES = {
|
|
24
|
+
"crc32": (ChecksumType.COMPOSITE, ChecksumType.FULL_OBJECT),
|
|
25
|
+
"crc32c": (ChecksumType.COMPOSITE, ChecksumType.FULL_OBJECT),
|
|
26
|
+
"crc64nvme": (ChecksumType.FULL_OBJECT,),
|
|
27
|
+
"sha1": (ChecksumType.COMPOSITE,),
|
|
28
|
+
"sha256": (ChecksumType.COMPOSITE,),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Assembly:
|
|
34
|
+
"""The object a completed upload produces."""
|
|
35
|
+
|
|
36
|
+
parts: tuple[Part, ...]
|
|
37
|
+
size: int
|
|
38
|
+
etag: str
|
|
39
|
+
unused_blob_ids: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def blobs(self) -> tuple[Blob, ...]:
|
|
43
|
+
return tuple(part.blob for part in self.parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_part_number(value: str) -> int:
|
|
47
|
+
number = int(value) if value.isdecimal() else 0
|
|
48
|
+
if not MIN_PART_NUMBER <= number <= MAX_PART_NUMBER:
|
|
49
|
+
raise S3Error(
|
|
50
|
+
errors.INVALID_ARGUMENT,
|
|
51
|
+
f"Part numbers must be between {MIN_PART_NUMBER} and {MAX_PART_NUMBER}.",
|
|
52
|
+
)
|
|
53
|
+
return number
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_checksum_plan(headers: Mapping[str, str]) -> ChecksumPlan | None:
|
|
57
|
+
"""Read and check the checksum a new upload declares, if any."""
|
|
58
|
+
name = headers.get("x-amz-checksum-algorithm")
|
|
59
|
+
requested_type = headers.get("x-amz-checksum-type")
|
|
60
|
+
if name is None:
|
|
61
|
+
if requested_type is not None:
|
|
62
|
+
raise S3Error(
|
|
63
|
+
errors.INVALID_REQUEST, "A checksum type needs a checksum algorithm."
|
|
64
|
+
)
|
|
65
|
+
return None
|
|
66
|
+
algorithm = payload.check_algorithm_supported(name)
|
|
67
|
+
allowed = _CHECKSUM_TYPES[algorithm]
|
|
68
|
+
if requested_type is None:
|
|
69
|
+
return ChecksumPlan(algorithm, allowed[0])
|
|
70
|
+
if requested_type not in allowed:
|
|
71
|
+
raise S3Error(
|
|
72
|
+
errors.INVALID_REQUEST,
|
|
73
|
+
f"{name} does not support the checksum type {requested_type}.",
|
|
74
|
+
)
|
|
75
|
+
return ChecksumPlan(algorithm, ChecksumType(requested_type))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_completion(
|
|
79
|
+
parts: tuple[RequestedPart, ...], headers: Mapping[str, str]
|
|
80
|
+
) -> CompletionRequest:
|
|
81
|
+
return CompletionRequest(
|
|
82
|
+
parts,
|
|
83
|
+
payload.parse_checksum_header(headers),
|
|
84
|
+
headers.get("x-amz-checksum-type"),
|
|
85
|
+
payload.parse_length(headers.get("x-amz-mp-object-size")),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def assemble(
|
|
90
|
+
requested: Iterable[RequestedPart],
|
|
91
|
+
stored: Mapping[int, Part],
|
|
92
|
+
client_size: Callable[[int], int] | None = None,
|
|
93
|
+
) -> Assembly:
|
|
94
|
+
"""Check the client's list of parts against what was uploaded.
|
|
95
|
+
|
|
96
|
+
Sizes are as the client knows them: `client_size` turns a size on disk into that,
|
|
97
|
+
if storing the parts took more room than the bytes they hold.
|
|
98
|
+
"""
|
|
99
|
+
size_of = client_size or int
|
|
100
|
+
listed = list(requested)
|
|
101
|
+
if not listed:
|
|
102
|
+
raise S3Error(errors.MALFORMED_XML, "The list of parts is empty.")
|
|
103
|
+
|
|
104
|
+
numbers = [item.number for item in listed]
|
|
105
|
+
if numbers != sorted(set(numbers)):
|
|
106
|
+
raise S3Error(errors.INVALID_PART_ORDER)
|
|
107
|
+
for item in listed:
|
|
108
|
+
part = stored.get(item.number)
|
|
109
|
+
if (
|
|
110
|
+
part is None
|
|
111
|
+
or part.blob.md5 != item.etag
|
|
112
|
+
or any(claim != part.checksum for claim in item.checksums)
|
|
113
|
+
):
|
|
114
|
+
raise S3Error(errors.INVALID_PART, f"Part {item.number} does not match.")
|
|
115
|
+
|
|
116
|
+
parts = tuple(stored[number] for number in numbers)
|
|
117
|
+
for part in parts[:-1]:
|
|
118
|
+
if size_of(part.blob.size) < MIN_PART_BYTES:
|
|
119
|
+
raise S3Error(
|
|
120
|
+
errors.ENTITY_TOO_SMALL,
|
|
121
|
+
f"Part {part.number} is below the 5 MiB minimum.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
md5_of_md5s = hashlib.md5(usedforsecurity=False)
|
|
125
|
+
for part in parts:
|
|
126
|
+
md5_of_md5s.update(bytes.fromhex(part.blob.md5))
|
|
127
|
+
return Assembly(
|
|
128
|
+
parts,
|
|
129
|
+
sum(size_of(part.blob.size) for part in parts),
|
|
130
|
+
f"{md5_of_md5s.hexdigest()}-{len(parts)}",
|
|
131
|
+
tuple(
|
|
132
|
+
part.blob.blob_id
|
|
133
|
+
for number, part in stored.items()
|
|
134
|
+
if number not in numbers
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def object_checksum(
|
|
140
|
+
plan: ChecksumPlan | None, parts: Sequence[Part]
|
|
141
|
+
) -> Checksum | None:
|
|
142
|
+
"""Work out the checksum of an object from its parts', without reading any data."""
|
|
143
|
+
if plan is None:
|
|
144
|
+
return None
|
|
145
|
+
checksums = [_planned_checksum(plan, part) for part in parts]
|
|
146
|
+
if plan.checksum_type is ChecksumType.COMPOSITE:
|
|
147
|
+
return payload.composite_checksum(plan.algorithm, checksums)
|
|
148
|
+
sizes = [part.blob.size for part in parts]
|
|
149
|
+
return payload.full_object_checksum(
|
|
150
|
+
plan.algorithm, zip(checksums, sizes, strict=True)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_checksum_claims(request: CompletionRequest, actual: Checksum | None) -> None:
|
|
155
|
+
"""Hold a client to what it says about the object's checksum."""
|
|
156
|
+
if request.checksum is None and request.checksum_type is None:
|
|
157
|
+
return
|
|
158
|
+
if actual is None:
|
|
159
|
+
raise S3Error(
|
|
160
|
+
errors.INVALID_REQUEST, "This upload did not declare a checksum algorithm."
|
|
161
|
+
)
|
|
162
|
+
if request.checksum_type not in {None, actual.checksum_type}:
|
|
163
|
+
raise S3Error(
|
|
164
|
+
errors.INVALID_REQUEST,
|
|
165
|
+
f"This upload has the checksum type {actual.checksum_type}.",
|
|
166
|
+
)
|
|
167
|
+
if request.checksum not in {None, actual}:
|
|
168
|
+
raise S3Error(errors.BAD_DIGEST, "The object's checksum did not match.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_size_claim(request: CompletionRequest, size: int) -> None:
|
|
172
|
+
if request.object_size not in {None, size}:
|
|
173
|
+
raise S3Error(
|
|
174
|
+
errors.INVALID_REQUEST,
|
|
175
|
+
f"The parts hold {size} bytes, not {request.object_size}.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _planned_checksum(plan: ChecksumPlan, part: Part) -> Checksum:
|
|
180
|
+
if part.checksum is None or part.checksum.algorithm != plan.algorithm:
|
|
181
|
+
raise S3Error(
|
|
182
|
+
errors.INVALID_PART, f"Part {part.number} has no {plan.algorithm}."
|
|
183
|
+
)
|
|
184
|
+
return part.checksum
|
leblob/s3/payload.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""What a client declares about an upload's body, and checking that it held."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import re
|
|
10
|
+
import zlib
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from enum import Enum, auto
|
|
13
|
+
from functools import partial
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol
|
|
15
|
+
|
|
16
|
+
from awscrt import checksums
|
|
17
|
+
|
|
18
|
+
from leblob.model import Checksum
|
|
19
|
+
from leblob.s3 import errors, sigv4
|
|
20
|
+
from leblob.s3.chunked import ChunkedDecoder, PlainDecoder
|
|
21
|
+
from leblob.s3.errors import S3Error
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
25
|
+
|
|
26
|
+
from leblob.s3.sigv4 import StreamSeed
|
|
27
|
+
|
|
28
|
+
_CHECKSUM_PREFIX = "x-amz-checksum-"
|
|
29
|
+
_MD5 = "md5"
|
|
30
|
+
_SHA256 = "sha256"
|
|
31
|
+
_MODE_HEADERS = frozenset({
|
|
32
|
+
"x-amz-checksum-algorithm",
|
|
33
|
+
"x-amz-checksum-mode",
|
|
34
|
+
"x-amz-checksum-type",
|
|
35
|
+
})
|
|
36
|
+
_HEX_SHA256 = re.compile(r"[0-9a-fA-F]{64}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Hasher(Protocol):
|
|
40
|
+
def update(self, data: bytes, /) -> None: ...
|
|
41
|
+
|
|
42
|
+
def digest(self) -> bytes: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Crc:
|
|
46
|
+
"""Give a CRC function the same shape as a hashlib object."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, function: Callable[[bytes, int], int], size: int) -> None:
|
|
49
|
+
self._function = function
|
|
50
|
+
self._size = size
|
|
51
|
+
self._value = 0
|
|
52
|
+
|
|
53
|
+
def update(self, data: bytes, /) -> None:
|
|
54
|
+
self._value = self._function(data, self._value)
|
|
55
|
+
|
|
56
|
+
def digest(self) -> bytes:
|
|
57
|
+
return self._value.to_bytes(self._size, "big")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sha1() -> Hasher:
|
|
61
|
+
return hashlib.sha1(usedforsecurity=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_CHECKSUM_FACTORIES: dict[str, Callable[[], Hasher]] = {
|
|
65
|
+
"crc32": partial(_Crc, zlib.crc32, 4),
|
|
66
|
+
"crc32c": partial(_Crc, checksums.crc32c, 4),
|
|
67
|
+
"crc64nvme": partial(_Crc, checksums.crc64nvme, 8),
|
|
68
|
+
"sha1": _sha1,
|
|
69
|
+
_SHA256: hashlib.sha256,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_CRC_COMBINERS: dict[str, tuple[Callable[[int, int, int], int], int]] = {
|
|
74
|
+
"crc32": (checksums.combine_crc32, 4),
|
|
75
|
+
"crc32c": (checksums.combine_crc32c, 4),
|
|
76
|
+
"crc64nvme": (checksums.combine_crc64nvme, 8),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Framing(Enum):
|
|
81
|
+
PLAIN = auto()
|
|
82
|
+
SIGNED_CHUNKS = auto()
|
|
83
|
+
SIGNED_CHUNKS_WITH_TRAILER = auto()
|
|
84
|
+
UNSIGNED_CHUNKS = auto()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_FRAMING_BY_CONTENT_SHA256 = {
|
|
88
|
+
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD": Framing.SIGNED_CHUNKS,
|
|
89
|
+
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER": Framing.SIGNED_CHUNKS_WITH_TRAILER,
|
|
90
|
+
"STREAMING-UNSIGNED-PAYLOAD-TRAILER": Framing.UNSIGNED_CHUNKS,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class PayloadSpec:
|
|
96
|
+
"""Everything the request headers promise about the body."""
|
|
97
|
+
|
|
98
|
+
framing: Framing
|
|
99
|
+
sha256_hex: str | None
|
|
100
|
+
md5_base64: str | None
|
|
101
|
+
checksum: Checksum | None
|
|
102
|
+
trailer_algorithm: str | None
|
|
103
|
+
declared_length: int | None
|
|
104
|
+
algorithms: frozenset[str]
|
|
105
|
+
required_algorithm: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_spec(headers: Mapping[str, str]) -> PayloadSpec:
|
|
109
|
+
"""Read the body's promised framing, length and checksums from the headers."""
|
|
110
|
+
content_sha256 = headers.get("x-amz-content-sha256", sigv4.UNSIGNED_PAYLOAD)
|
|
111
|
+
framing = _FRAMING_BY_CONTENT_SHA256.get(content_sha256, Framing.PLAIN)
|
|
112
|
+
sha256_hex = _parse_content_sha256(content_sha256, framing)
|
|
113
|
+
checksum = parse_checksum_header(headers)
|
|
114
|
+
trailer_algorithm = _parse_trailer_algorithm(headers)
|
|
115
|
+
|
|
116
|
+
algorithms = {_MD5}
|
|
117
|
+
if sha256_hex:
|
|
118
|
+
algorithms.add(_SHA256)
|
|
119
|
+
if checksum:
|
|
120
|
+
algorithms.add(checksum.algorithm)
|
|
121
|
+
if trailer_algorithm:
|
|
122
|
+
algorithms.add(trailer_algorithm)
|
|
123
|
+
|
|
124
|
+
length_header = (
|
|
125
|
+
"content-length" if framing is Framing.PLAIN else "x-amz-decoded-content-length"
|
|
126
|
+
)
|
|
127
|
+
return PayloadSpec(
|
|
128
|
+
framing,
|
|
129
|
+
sha256_hex,
|
|
130
|
+
headers.get("content-md5"),
|
|
131
|
+
checksum,
|
|
132
|
+
trailer_algorithm,
|
|
133
|
+
parse_length(headers.get(length_header)),
|
|
134
|
+
frozenset(algorithms),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def requiring(spec: PayloadSpec, algorithm: str) -> PayloadSpec:
|
|
139
|
+
"""Have a body end up with this checksum, as its multipart upload declared."""
|
|
140
|
+
sent = spec.trailer_algorithm or (spec.checksum and spec.checksum.algorithm)
|
|
141
|
+
if sent and sent != algorithm:
|
|
142
|
+
raise S3Error(
|
|
143
|
+
errors.INVALID_REQUEST,
|
|
144
|
+
f"This upload declared {algorithm}, but the part has {sent}.",
|
|
145
|
+
)
|
|
146
|
+
return replace(
|
|
147
|
+
spec, algorithms=spec.algorithms | {algorithm}, required_algorithm=algorithm
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_algorithm_supported(name: str) -> str:
|
|
152
|
+
"""Return a lower-case checksum algorithm name, or refuse it."""
|
|
153
|
+
algorithm = name.lower()
|
|
154
|
+
if algorithm not in _CHECKSUM_FACTORIES:
|
|
155
|
+
raise S3Error(
|
|
156
|
+
errors.NOT_IMPLEMENTED, f"Checksum algorithm {name} is not implemented."
|
|
157
|
+
)
|
|
158
|
+
return algorithm
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def without_object_checksums(headers: Mapping[str, str]) -> dict[str, str]:
|
|
162
|
+
"""Drop checksum headers that describe an object rather than this request's body.
|
|
163
|
+
|
|
164
|
+
CompleteMultipartUpload carries them, next to an XML body they don't apply to.
|
|
165
|
+
"""
|
|
166
|
+
return {
|
|
167
|
+
name: value
|
|
168
|
+
for name, value in headers.items()
|
|
169
|
+
if not name.startswith(_CHECKSUM_PREFIX)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def new_decoder(
|
|
174
|
+
spec: PayloadSpec, seed: StreamSeed | None
|
|
175
|
+
) -> PlainDecoder | ChunkedDecoder:
|
|
176
|
+
"""Choose how to read a body. Chunks signed with no signature to chain from fail."""
|
|
177
|
+
if seed is None and spec.framing in _SIGNED_FRAMINGS:
|
|
178
|
+
raise S3Error(errors.ACCESS_DENIED, "Signed chunks need a signed request.")
|
|
179
|
+
match spec.framing:
|
|
180
|
+
case Framing.PLAIN:
|
|
181
|
+
return PlainDecoder()
|
|
182
|
+
case Framing.UNSIGNED_CHUNKS:
|
|
183
|
+
return ChunkedDecoder(None)
|
|
184
|
+
case Framing.SIGNED_CHUNKS:
|
|
185
|
+
return ChunkedDecoder(seed)
|
|
186
|
+
case Framing.SIGNED_CHUNKS_WITH_TRAILER:
|
|
187
|
+
return ChunkedDecoder(seed, signed_trailer=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_SIGNED_FRAMINGS = frozenset({
|
|
191
|
+
Framing.SIGNED_CHUNKS,
|
|
192
|
+
Framing.SIGNED_CHUNKS_WITH_TRAILER,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def new_hashers(spec: PayloadSpec) -> dict[str, Hasher]:
|
|
197
|
+
return {name: _new_hasher(name) for name in spec.algorithms}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def verify(
|
|
201
|
+
spec: PayloadSpec,
|
|
202
|
+
digests: Mapping[str, bytes],
|
|
203
|
+
size: int,
|
|
204
|
+
trailers: Mapping[str, str],
|
|
205
|
+
) -> Checksum | None:
|
|
206
|
+
"""Compare the received body with every promise; return its checksum to store."""
|
|
207
|
+
if spec.declared_length is not None and size != spec.declared_length:
|
|
208
|
+
raise S3Error(
|
|
209
|
+
errors.INCOMPLETE_BODY,
|
|
210
|
+
f"Received {size} bytes, but {spec.declared_length} were declared.",
|
|
211
|
+
)
|
|
212
|
+
if spec.sha256_hex is not None and not hmac.compare_digest(
|
|
213
|
+
digests[_SHA256].hex(), spec.sha256_hex
|
|
214
|
+
):
|
|
215
|
+
raise S3Error(errors.CONTENT_SHA256_MISMATCH)
|
|
216
|
+
if spec.md5_base64 is not None:
|
|
217
|
+
_check_content_md5(digests[_MD5], spec.md5_base64)
|
|
218
|
+
|
|
219
|
+
checksum = spec.checksum
|
|
220
|
+
if spec.trailer_algorithm is not None:
|
|
221
|
+
checksum = _read_trailer_checksum(spec.trailer_algorithm, trailers)
|
|
222
|
+
if checksum is not None:
|
|
223
|
+
_check_checksum(digests[checksum.algorithm], checksum)
|
|
224
|
+
return checksum
|
|
225
|
+
if spec.required_algorithm is None:
|
|
226
|
+
return None
|
|
227
|
+
return Checksum(
|
|
228
|
+
spec.required_algorithm, _encode_base64(digests[spec.required_algorithm])
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def composite_checksum(algorithm: str, parts: Sequence[Checksum]) -> Checksum:
|
|
233
|
+
"""Hash the parts' checksums, in order, and say how many there were."""
|
|
234
|
+
hasher = _new_hasher(algorithm)
|
|
235
|
+
for part in parts:
|
|
236
|
+
hasher.update(base64.b64decode(part.value))
|
|
237
|
+
return Checksum(algorithm, f"{_encode_base64(hasher.digest())}-{len(parts)}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def full_object_checksum(
|
|
241
|
+
algorithm: str, parts: Iterable[tuple[Checksum, int]]
|
|
242
|
+
) -> Checksum:
|
|
243
|
+
"""Combine the parts' CRCs into the one the whole object has, reading no data."""
|
|
244
|
+
combine, size = _CRC_COMBINERS[algorithm]
|
|
245
|
+
crc = 0
|
|
246
|
+
for part, length in parts:
|
|
247
|
+
part_crc = int.from_bytes(base64.b64decode(part.value), "big")
|
|
248
|
+
crc = combine(crc, part_crc, length)
|
|
249
|
+
return Checksum(algorithm, _encode_base64(crc.to_bytes(size, "big")))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _new_hasher(name: str) -> Hasher:
|
|
253
|
+
if name == _MD5:
|
|
254
|
+
return hashlib.md5(usedforsecurity=False)
|
|
255
|
+
return _CHECKSUM_FACTORIES[name]()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_content_sha256(value: str, framing: Framing) -> str | None:
|
|
259
|
+
if framing is not Framing.PLAIN or value == sigv4.UNSIGNED_PAYLOAD:
|
|
260
|
+
return None
|
|
261
|
+
if value.startswith("STREAMING-"):
|
|
262
|
+
raise S3Error(errors.NOT_IMPLEMENTED, f"Payload mode {value} is not supported.")
|
|
263
|
+
if not _HEX_SHA256.fullmatch(value):
|
|
264
|
+
raise S3Error(errors.INVALID_ARGUMENT, "x-amz-content-sha256 is not valid.")
|
|
265
|
+
return value.lower()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_checksum_header(headers: Mapping[str, str]) -> Checksum | None:
|
|
269
|
+
names = [
|
|
270
|
+
name
|
|
271
|
+
for name in headers
|
|
272
|
+
if name.startswith(_CHECKSUM_PREFIX) and name not in _MODE_HEADERS
|
|
273
|
+
]
|
|
274
|
+
if not names:
|
|
275
|
+
return None
|
|
276
|
+
if len(names) > 1:
|
|
277
|
+
raise S3Error(errors.INVALID_REQUEST, "Send at most one checksum header.")
|
|
278
|
+
algorithm = check_algorithm_supported(names[0].removeprefix(_CHECKSUM_PREFIX))
|
|
279
|
+
return Checksum(algorithm, headers[names[0]])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parse_trailer_algorithm(headers: Mapping[str, str]) -> str | None:
|
|
283
|
+
trailer = headers.get("x-amz-trailer")
|
|
284
|
+
if trailer is None:
|
|
285
|
+
return None
|
|
286
|
+
if not trailer.startswith(_CHECKSUM_PREFIX):
|
|
287
|
+
raise S3Error(errors.INVALID_REQUEST, f"Unsupported trailer: {trailer}.")
|
|
288
|
+
return check_algorithm_supported(trailer.removeprefix(_CHECKSUM_PREFIX))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_length(value: str | None) -> int | None:
|
|
292
|
+
if value is None:
|
|
293
|
+
return None
|
|
294
|
+
if not value.isdecimal():
|
|
295
|
+
raise S3Error(errors.INVALID_ARGUMENT, f"Invalid body length: {value!r}.")
|
|
296
|
+
return int(value)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _read_trailer_checksum(algorithm: str, trailers: Mapping[str, str]) -> Checksum:
|
|
300
|
+
value = trailers.get(f"{_CHECKSUM_PREFIX}{algorithm}")
|
|
301
|
+
if value is None:
|
|
302
|
+
raise S3Error(errors.MALFORMED_TRAILER)
|
|
303
|
+
return Checksum(algorithm, value)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _check_content_md5(digest: bytes, claimed: str) -> None:
|
|
307
|
+
decoded = _decode_base64(claimed)
|
|
308
|
+
if decoded is None:
|
|
309
|
+
raise S3Error(errors.INVALID_DIGEST, "Content-MD5 is not base64.")
|
|
310
|
+
if not hmac.compare_digest(digest, decoded):
|
|
311
|
+
raise S3Error(errors.BAD_DIGEST, "The Content-MD5 did not match.")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _check_checksum(digest: bytes, checksum: Checksum) -> None:
|
|
315
|
+
decoded = _decode_base64(checksum.value)
|
|
316
|
+
if decoded is None or not hmac.compare_digest(digest, decoded):
|
|
317
|
+
raise S3Error(errors.BAD_DIGEST, f"The {checksum.algorithm} did not match.")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _encode_base64(digest: bytes) -> str:
|
|
321
|
+
return base64.b64encode(digest).decode()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _decode_base64(value: str) -> bytes | None:
|
|
325
|
+
try:
|
|
326
|
+
return base64.b64decode(value, validate=True)
|
|
327
|
+
except binascii.Error:
|
|
328
|
+
return None
|