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