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/sigv4.py ADDED
@@ -0,0 +1,333 @@
1
+ """AWS Signature Version 4: parsing and verifying signed requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import TYPE_CHECKING
11
+ from urllib.parse import quote, unquote_to_bytes
12
+
13
+ from leblob.s3 import errors
14
+ from leblob.s3.errors import S3Error
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Mapping
18
+
19
+ from leblob.model import HttpRequest
20
+
21
+ ALGORITHM = "AWS4-HMAC-SHA256"
22
+ UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"
23
+ EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
24
+
25
+ MAX_CLOCK_SKEW_SECONDS = 15 * 60
26
+ MAX_PRESIGN_SECONDS = 7 * 24 * 3600
27
+
28
+ _AMZ_DATE_FORMAT = "%Y%m%dT%H%M%SZ"
29
+ _AUTHORIZATION = re.compile(
30
+ rf"{ALGORITHM} +Credential=([^,\s]+), *SignedHeaders=([^,\s]+), *"
31
+ r"Signature=([0-9a-f]{64})"
32
+ )
33
+ _SIGNATURE = re.compile(r"[0-9a-f]{64}")
34
+ _CREDENTIAL_PARTS = 5
35
+ _PRESIGNED_PARAMETERS = frozenset({
36
+ "X-Amz-Algorithm",
37
+ "X-Amz-Credential",
38
+ "X-Amz-Date",
39
+ "X-Amz-Expires",
40
+ "X-Amz-Signature",
41
+ "X-Amz-SignedHeaders",
42
+ })
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Credential:
47
+ access_key: str
48
+ date: str
49
+ region: str
50
+ service: str
51
+
52
+ @property
53
+ def scope(self) -> str:
54
+ return f"{self.date}/{self.region}/{self.service}/aws4_request"
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Claim:
59
+ """What a request says about its own signature; nothing here is verified yet."""
60
+
61
+ credential: Credential
62
+ signed_headers: tuple[str, ...]
63
+ signature: str
64
+ amz_date: str
65
+ expires: int | None
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class StreamSeed:
70
+ """A verified signature's state, from which chunk signatures are chained."""
71
+
72
+ signing_key: bytes
73
+ scope: str
74
+ amz_date: str
75
+ signature: str
76
+
77
+
78
+ def is_anonymous(request: HttpRequest) -> bool:
79
+ """Whether a request claims no signature at all. A wrong one isn't anonymous."""
80
+ return (
81
+ "X-Amz-Algorithm" not in request.query
82
+ and "authorization" not in request.headers
83
+ )
84
+
85
+
86
+ def post_form_access_key(fields: Mapping[str, str]) -> str:
87
+ """Name the key a browser form claims, if signed in a way this server reads."""
88
+ if "awsaccesskeyid" in fields:
89
+ raise S3Error(errors.NOT_IMPLEMENTED, "Forms must be signed with SigV4.")
90
+ if "policy" not in fields:
91
+ raise S3Error(errors.ACCESS_DENIED, "Anonymous uploads are not allowed.")
92
+ if "x-amz-credential" not in fields or "x-amz-signature" not in fields:
93
+ raise S3Error(errors.INVALID_ARGUMENT, "A form with a policy must be signed.")
94
+ credential = _parse_credential(fields["x-amz-credential"], errors.INVALID_ARGUMENT)
95
+ return credential.access_key
96
+
97
+
98
+ def verify_post_form(fields: Mapping[str, str], secret: str) -> None:
99
+ """Check a form's signature, which covers only its policy, as encoded."""
100
+ credential = _parse_credential(fields["x-amz-credential"], errors.INVALID_ARGUMENT)
101
+ if fields.get("x-amz-algorithm") != ALGORITHM:
102
+ raise S3Error(errors.INVALID_ARGUMENT, "Forms must be signed with SigV4.")
103
+ amz_date = fields.get("x-amz-date", "")
104
+ try:
105
+ datetime.strptime(amz_date, _AMZ_DATE_FORMAT).replace(tzinfo=UTC)
106
+ except ValueError:
107
+ raise S3Error(errors.INVALID_ARGUMENT, "x-amz-date isn't a date.") from None
108
+ if not amz_date.startswith(credential.date):
109
+ raise S3Error(errors.SIGNATURE_MISMATCH, "Credential date is not form date.")
110
+ signature = fields.get("x-amz-signature", "")
111
+ if not is_signature(signature):
112
+ raise S3Error(errors.ACCESS_DENIED, "The form isn't signed.")
113
+ key = derive_signing_key(secret, credential)
114
+ expected = _hmac(key, fields["policy"]).hex()
115
+ if not hmac.compare_digest(expected, signature):
116
+ raise S3Error(errors.SIGNATURE_MISMATCH)
117
+
118
+
119
+ def parse_claim(request: HttpRequest) -> Claim:
120
+ """Extract the claimed signature from the headers or a presigned URL."""
121
+ if "X-Amz-Algorithm" in request.query:
122
+ return _parse_presigned(request.query)
123
+ authorization = request.headers.get("authorization")
124
+ if authorization is None:
125
+ raise S3Error(errors.ACCESS_DENIED, "Anonymous access is not allowed.")
126
+ return _parse_authorization(authorization, request.headers)
127
+
128
+
129
+ def is_signature(text: str) -> bool:
130
+ """Check the form of a signature, which `compare_digest` requires to be ASCII."""
131
+ return _SIGNATURE.fullmatch(text) is not None
132
+
133
+
134
+ def verify(request: HttpRequest, claim: Claim, secret: str, now: float) -> StreamSeed:
135
+ """Check a claim against the secret key, or raise the matching S3 error."""
136
+ _check_time(claim, now)
137
+ _check_signed_headers(request.headers, claim.signed_headers)
138
+
139
+ signing_key = derive_signing_key(secret, claim.credential)
140
+ string_to_sign = "\n".join((
141
+ ALGORITHM,
142
+ claim.amz_date,
143
+ claim.credential.scope,
144
+ # Latin-1 restores the header bytes exactly as sent. Everything else in
145
+ # the canonical request is percent-encoded, so it is plain ASCII.
146
+ _sha256_hex(canonical_request(request, claim).encode("latin-1")),
147
+ ))
148
+ expected = _hmac(signing_key, string_to_sign).hex()
149
+ if not hmac.compare_digest(expected, claim.signature):
150
+ raise S3Error(errors.SIGNATURE_MISMATCH)
151
+ return StreamSeed(signing_key, claim.credential.scope, claim.amz_date, expected)
152
+
153
+
154
+ def canonical_request(request: HttpRequest, claim: Claim) -> str:
155
+ return "\n".join((
156
+ request.method,
157
+ canonical_uri(request.raw_path),
158
+ canonical_query(request.raw_query),
159
+ "".join(
160
+ f"{name}:{_normalize_value(request.headers.get(name, ''))}\n"
161
+ for name in claim.signed_headers
162
+ ),
163
+ ";".join(claim.signed_headers),
164
+ request.headers.get("x-amz-content-sha256", UNSIGNED_PAYLOAD),
165
+ ))
166
+
167
+
168
+ def canonical_uri(raw_path: bytes) -> str:
169
+ """Encode each path segment the way AWS specifies, keeping slashes literal."""
170
+ return "/".join(
171
+ quote(unquote_to_bytes(segment), safe="-_.~")
172
+ for segment in raw_path.split(b"/")
173
+ )
174
+
175
+
176
+ def canonical_query(raw_query: bytes) -> str:
177
+ """Sort and normalize the query string, without the signature itself."""
178
+ pairs = []
179
+ for item in raw_query.split(b"&"):
180
+ if not item:
181
+ continue
182
+ name, _, value = item.partition(b"=")
183
+ encoded_name = quote(unquote_to_bytes(name), safe="-_.~")
184
+ if encoded_name != "X-Amz-Signature":
185
+ pairs.append((encoded_name, quote(unquote_to_bytes(value), safe="-_.~")))
186
+ return "&".join(f"{name}={value}" for name, value in sorted(pairs))
187
+
188
+
189
+ def derive_signing_key(secret: str, credential: Credential) -> bytes:
190
+ key = _hmac(f"AWS4{secret}".encode(), credential.date)
191
+ key = _hmac(key, credential.region)
192
+ key = _hmac(key, credential.service)
193
+ return _hmac(key, "aws4_request")
194
+
195
+
196
+ def chunk_signature(seed: StreamSeed, previous: str, chunk_sha256: str) -> str:
197
+ """Signature of one aws-chunked data chunk, chained from the previous one."""
198
+ return _hmac(
199
+ seed.signing_key,
200
+ "\n".join((
201
+ f"{ALGORITHM}-PAYLOAD",
202
+ seed.amz_date,
203
+ seed.scope,
204
+ previous,
205
+ EMPTY_SHA256,
206
+ chunk_sha256,
207
+ )),
208
+ ).hex()
209
+
210
+
211
+ def trailer_signature(seed: StreamSeed, previous: str, trailer: bytes) -> str:
212
+ """Signature over the canonical trailing headers of a chunked upload."""
213
+ return _hmac(
214
+ seed.signing_key,
215
+ "\n".join((
216
+ f"{ALGORITHM}-TRAILER",
217
+ seed.amz_date,
218
+ seed.scope,
219
+ previous,
220
+ _sha256_hex(trailer),
221
+ )),
222
+ ).hex()
223
+
224
+
225
+ def _parse_authorization(authorization: str, headers: Mapping[str, str]) -> Claim:
226
+ match = _AUTHORIZATION.fullmatch(authorization.strip())
227
+ if match is None:
228
+ raise S3Error(
229
+ errors.AUTH_HEADER_MALFORMED,
230
+ f"Only {ALGORITHM} authorization is supported, in its standard form.",
231
+ )
232
+ amz_date = headers.get("x-amz-date")
233
+ if amz_date is None:
234
+ raise S3Error(errors.ACCESS_DENIED, "The x-amz-date header is required.")
235
+ credential_text, signed_headers, signature = match.groups()
236
+ credential = _parse_credential(credential_text, errors.AUTH_HEADER_MALFORMED)
237
+ if "x-amz-content-sha256" not in headers:
238
+ # Without it the signature would say nothing about the body we go on to store.
239
+ raise S3Error(
240
+ errors.INVALID_REQUEST, "The x-amz-content-sha256 header is required."
241
+ )
242
+ return Claim(
243
+ credential,
244
+ tuple(signed_headers.split(";")),
245
+ signature,
246
+ amz_date,
247
+ None,
248
+ )
249
+
250
+
251
+ def _parse_presigned(query: Mapping[str, str]) -> Claim:
252
+ if not query.keys() >= _PRESIGNED_PARAMETERS:
253
+ raise S3Error(errors.AUTH_QUERY_ERROR)
254
+ expires = query["X-Amz-Expires"]
255
+ if query["X-Amz-Algorithm"] != ALGORITHM or not is_signature(
256
+ query["X-Amz-Signature"]
257
+ ):
258
+ raise S3Error(errors.AUTH_QUERY_ERROR)
259
+ if not (expires.isdecimal() and 1 <= int(expires) <= MAX_PRESIGN_SECONDS):
260
+ raise S3Error(errors.ACCESS_DENIED, "X-Amz-Expires is out of range.")
261
+ return Claim(
262
+ _parse_credential(query["X-Amz-Credential"], errors.AUTH_QUERY_ERROR),
263
+ tuple(query["X-Amz-SignedHeaders"].split(";")),
264
+ query["X-Amz-Signature"],
265
+ query["X-Amz-Date"],
266
+ int(expires),
267
+ )
268
+
269
+
270
+ def _parse_credential(text: str, malformed: errors.ErrorCode) -> Credential:
271
+ parts = text.split("/")
272
+ if (
273
+ len(parts) != _CREDENTIAL_PARTS
274
+ or parts[3] != "s3"
275
+ or parts[4] != "aws4_request"
276
+ ):
277
+ raise S3Error(malformed, f"Malformed credential scope: {text!r}.")
278
+ return Credential(*parts[:4])
279
+
280
+
281
+ def _check_time(claim: Claim, now: float) -> None:
282
+ try:
283
+ signed_at = (
284
+ datetime
285
+ .strptime(claim.amz_date, _AMZ_DATE_FORMAT)
286
+ .replace(tzinfo=UTC)
287
+ .timestamp()
288
+ )
289
+ except ValueError:
290
+ raise S3Error(
291
+ errors.ACCESS_DENIED, f"Invalid request date: {claim.amz_date!r}."
292
+ ) from None
293
+ if not claim.amz_date.startswith(claim.credential.date):
294
+ raise S3Error(errors.SIGNATURE_MISMATCH, "Credential date is not request date.")
295
+
296
+ if signed_at - now > MAX_CLOCK_SKEW_SECONDS:
297
+ raise S3Error(errors.TIME_TOO_SKEWED)
298
+ if claim.expires is None:
299
+ if now - signed_at > MAX_CLOCK_SKEW_SECONDS:
300
+ raise S3Error(errors.TIME_TOO_SKEWED)
301
+ elif now - signed_at > claim.expires:
302
+ raise S3Error(errors.ACCESS_DENIED, "Request has expired.")
303
+
304
+
305
+ def _check_signed_headers(
306
+ headers: Mapping[str, str], signed_headers: tuple[str, ...]
307
+ ) -> None:
308
+ """Refuse requests whose security-relevant headers escape the signature.
309
+
310
+ Otherwise anyone holding a presigned PUT URL could add `x-amz-copy-source`
311
+ and turn the upload into a read of any object.
312
+ """
313
+ if "host" not in signed_headers:
314
+ raise S3Error(errors.ACCESS_DENIED, "The Host header must be signed.")
315
+ unsigned = sorted(
316
+ name
317
+ for name in headers
318
+ if name.startswith("x-amz-") and name not in signed_headers
319
+ )
320
+ if unsigned:
321
+ raise S3Error(errors.ACCESS_DENIED, f"Header {unsigned[0]} must be signed.")
322
+
323
+
324
+ def _normalize_value(value: str) -> str:
325
+ return " ".join(value.split())
326
+
327
+
328
+ def _sha256_hex(data: bytes) -> str:
329
+ return hashlib.sha256(data).hexdigest()
330
+
331
+
332
+ def _hmac(key: bytes, message: str) -> bytes:
333
+ return hmac.digest(key, message.encode(), "sha256")
leblob/s3/sse.py ADDED
@@ -0,0 +1,229 @@
1
+ """Server-side encryption with a key the client sends each time (SSE-C).
2
+
3
+ An encrypted blob is a random salt, then chunks of AES-256-GCM. Each blob has its own
4
+ key, derived from the client's and the salt, so a chunk's number can serve as its nonce.
5
+ A chunk's number and whether it is the last are authenticated, so chunks can't be moved
6
+ or dropped. Reading a range needs only the chunks it touches.
7
+
8
+ The stored bytes are just bytes to everything below this: scrub and repair check them
9
+ against the MD5 of what was stored. The client's key is never stored, only a value that
10
+ shows whether a later request has sent the same one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import hmac
18
+ import secrets
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ from cryptography.exceptions import InvalidTag
23
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24
+
25
+ from leblob.s3 import errors
26
+ from leblob.s3.errors import S3Error
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Mapping
30
+
31
+ ALGORITHM = "AES256"
32
+ HEADER_PREFIX = "x-amz-server-side-encryption-customer-"
33
+ COPY_SOURCE_PREFIX = "x-amz-copy-source-server-side-encryption-customer-"
34
+ CHUNK_BYTES = 64 * 1024
35
+ SALT_BYTES = 16
36
+ _TAG_BYTES = 16
37
+ _KEY_BYTES = 32
38
+ _NONCE_BYTES = 12
39
+ _CHECK_LABEL = b"leblob sse-c key check"
40
+ _LAST = b"\x01"
41
+ _NOT_LAST = b"\x00"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True, repr=False)
45
+ class CustomerKey:
46
+ """A key a client sent. Not printed, so it can't end up in a log by accident."""
47
+
48
+ key: bytes
49
+ key_md5: str
50
+
51
+ def __repr__(self) -> str:
52
+ return "CustomerKey(...)"
53
+
54
+ @property
55
+ def check(self) -> str:
56
+ """What is stored, to tell later whether the same key was sent."""
57
+ return hmac.new(self.key, _CHECK_LABEL, hashlib.sha256).hexdigest()
58
+
59
+ def response_headers(self) -> tuple[tuple[str, str], ...]:
60
+ return (
61
+ (f"{HEADER_PREFIX}algorithm", ALGORITHM),
62
+ (f"{HEADER_PREFIX}key-md5", self.key_md5),
63
+ )
64
+
65
+
66
+ def parse_key(
67
+ headers: Mapping[str, str], prefix: str = HEADER_PREFIX
68
+ ) -> CustomerKey | None:
69
+ """Read a key from the headers, or None if they don't try to give one."""
70
+ algorithm = headers.get(f"{prefix}algorithm")
71
+ encoded = headers.get(f"{prefix}key")
72
+ md5 = headers.get(f"{prefix}key-md5")
73
+ if algorithm is None and encoded is None and md5 is None:
74
+ return None
75
+ if algorithm != ALGORITHM:
76
+ raise S3Error(
77
+ errors.INVALID_ARGUMENT, "The encryption algorithm must be AES256."
78
+ )
79
+ if encoded is None or md5 is None:
80
+ raise S3Error(errors.INVALID_ARGUMENT, "Encryption needs a key and its MD5.")
81
+ try:
82
+ key = base64.b64decode(encoded, validate=True)
83
+ except ValueError: # `binascii.Error` is one, and so is what non-ASCII raises.
84
+ raise S3Error(
85
+ errors.INVALID_ARGUMENT, "The encryption key isn't valid."
86
+ ) from None
87
+ if len(key) != _KEY_BYTES:
88
+ raise S3Error(errors.INVALID_ARGUMENT, "The encryption key must have 256 bits.")
89
+ actual = base64.b64encode(hashlib.md5(key, usedforsecurity=False).digest()).decode()
90
+ if not hmac.compare_digest(actual.encode(), md5.encode()):
91
+ raise S3Error(
92
+ errors.INVALID_ARGUMENT, "The encryption key's MD5 doesn't match."
93
+ )
94
+ return CustomerKey(key, md5)
95
+
96
+
97
+ def check_key(stored_check: str | None, key: CustomerKey | None) -> None:
98
+ """Hold a request to what was stored: the same key, or none if none was used."""
99
+ if stored_check is None:
100
+ if key is not None:
101
+ raise S3Error(errors.INVALID_REQUEST, "This object isn't encrypted.")
102
+ return
103
+ if key is None:
104
+ raise S3Error(errors.INVALID_REQUEST, "This object needs its encryption key.")
105
+ if not hmac.compare_digest(stored_check, key.check):
106
+ raise S3Error(
107
+ errors.INVALID_REQUEST, "That isn't this object's encryption key."
108
+ )
109
+
110
+
111
+ def stored_size(plain_size: int) -> int:
112
+ chunks = max(1, (plain_size + CHUNK_BYTES - 1) // CHUNK_BYTES)
113
+ return SALT_BYTES + plain_size + chunks * _TAG_BYTES
114
+
115
+
116
+ def plain_size(stored: int) -> int:
117
+ body = stored - SALT_BYTES
118
+ full = CHUNK_BYTES + _TAG_BYTES
119
+ chunks, rest = divmod(body, full)
120
+ return chunks * CHUNK_BYTES + max(0, rest - _TAG_BYTES)
121
+
122
+
123
+ def _blob_cipher(key: CustomerKey, salt: bytes) -> AESGCM:
124
+ return AESGCM(hmac.digest(key.key, salt, "sha256"))
125
+
126
+
127
+ def _nonce(number: int) -> bytes:
128
+ return number.to_bytes(_NONCE_BYTES)
129
+
130
+
131
+ def _authenticated(number: int, *, last: bool) -> bytes:
132
+ return number.to_bytes(8) + (_LAST if last else _NOT_LAST)
133
+
134
+
135
+ class Encryptor:
136
+ """Turn a stream of plain bytes into what an encrypted blob holds."""
137
+
138
+ def __init__(self, key: CustomerKey) -> None:
139
+ salt = secrets.token_bytes(SALT_BYTES)
140
+ self._cipher = _blob_cipher(key, salt)
141
+ self._pending = bytearray(salt)
142
+ self._plain = bytearray()
143
+ self._number = 0
144
+
145
+ def _seal(self, data: bytes, *, last: bool) -> bytes:
146
+ sealed = self._cipher.encrypt(
147
+ _nonce(self._number), data, _authenticated(self._number, last=last)
148
+ )
149
+ self._number += 1
150
+ return sealed
151
+
152
+ def feed(self, data: bytes) -> bytes:
153
+ self._plain += data
154
+ out = self._pending
155
+ self._pending = bytearray()
156
+ # Keep at least one byte back: only the last chunk may be marked as last.
157
+ while len(self._plain) > CHUNK_BYTES:
158
+ out += self._seal(bytes(self._plain[:CHUNK_BYTES]), last=False)
159
+ del self._plain[:CHUNK_BYTES]
160
+ return bytes(out)
161
+
162
+ def finish(self) -> bytes:
163
+ return bytes(self._pending) + self._seal(bytes(self._plain), last=True)
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class ReadPlan:
168
+ """Which stored bytes a plain range of one blob needs, and what to trim."""
169
+
170
+ stored_offset: int
171
+ stored_length: int
172
+ first_chunk: int
173
+ skip: int
174
+ length: int
175
+
176
+
177
+ def plan_read(stored: int, start: int, length: int) -> ReadPlan:
178
+ full = CHUNK_BYTES + _TAG_BYTES
179
+ first = start // CHUNK_BYTES
180
+ last = (start + length - 1) // CHUNK_BYTES
181
+ offset = SALT_BYTES + first * full
182
+ end = min(SALT_BYTES + (last + 1) * full, stored)
183
+ return ReadPlan(offset, end - offset, first, start - first * CHUNK_BYTES, length)
184
+
185
+
186
+ class Decryptor:
187
+ """Turn the stored chunks of a range back into plain bytes, checking each."""
188
+
189
+ def __init__(
190
+ self, key: CustomerKey, salt: bytes, stored: int, plan: ReadPlan
191
+ ) -> None:
192
+ self._cipher = _blob_cipher(key, salt)
193
+ self._buffer = bytearray()
194
+ self._number = plan.first_chunk
195
+ self._skip = plan.skip
196
+ self._left = plan.length
197
+ full = CHUNK_BYTES + _TAG_BYTES
198
+ self._last_number = max(0, (stored - SALT_BYTES - 1) // full)
199
+
200
+ def _open(self, chunk: bytes) -> bytes:
201
+ try:
202
+ plain = self._cipher.decrypt(
203
+ _nonce(self._number),
204
+ chunk,
205
+ _authenticated(self._number, last=self._number == self._last_number),
206
+ )
207
+ except InvalidTag:
208
+ message = "An encrypted chunk failed its check."
209
+ raise OSError(message) from None
210
+ self._number += 1
211
+ return plain
212
+
213
+ def _trimmed(self, plain: bytes) -> bytes:
214
+ kept = plain[self._skip : self._skip + self._left]
215
+ self._skip = max(0, self._skip - len(plain))
216
+ self._left -= len(kept)
217
+ return kept
218
+
219
+ def feed(self, data: bytes) -> bytes:
220
+ self._buffer += data
221
+ full = CHUNK_BYTES + _TAG_BYTES
222
+ out = bytearray()
223
+ while len(self._buffer) >= full:
224
+ out += self._trimmed(self._open(bytes(self._buffer[:full])))
225
+ del self._buffer[:full]
226
+ return bytes(out)
227
+
228
+ def finish(self) -> bytes:
229
+ return self._trimmed(self._open(bytes(self._buffer))) if self._buffer else b""
leblob/s3/tagging.py ADDED
@@ -0,0 +1,48 @@
1
+ """Object tags: what S3 allows in them, and their form in a request header."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import parse_qsl
8
+
9
+ from leblob.s3 import errors
10
+ from leblob.s3.errors import S3Error
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterable
14
+
15
+ MAX_TAGS = 10
16
+ MAX_KEY_CHARACTERS = 128
17
+ MAX_VALUE_CHARACTERS = 256
18
+ # As AWS allows. This also keeps out what XML can't carry back to the client.
19
+ _ALLOWED = re.compile(r"[\w +\-=.:/@]*")
20
+
21
+
22
+ def build_tags(pairs: Iterable[tuple[str, str]]) -> dict[str, str]:
23
+ """Return valid tags, ordered by key, or raise InvalidTag."""
24
+ listed = list(pairs)
25
+ tags = dict(sorted(listed))
26
+ if len(tags) < len(listed):
27
+ raise S3Error(errors.INVALID_TAG, "Tag keys must be unique.")
28
+ if len(tags) > MAX_TAGS:
29
+ raise S3Error(
30
+ errors.INVALID_TAG, f"An object can have {MAX_TAGS} tags at most."
31
+ )
32
+ for key, value in tags.items():
33
+ _check_text(key, value)
34
+ return tags
35
+
36
+
37
+ def parse_header(value: str) -> dict[str, str]:
38
+ """Read the `x-amz-tagging` header, which is written like a query string."""
39
+ return build_tags(parse_qsl(value, keep_blank_values=True))
40
+
41
+
42
+ def _check_text(key: str, value: str) -> None:
43
+ if not 1 <= len(key) <= MAX_KEY_CHARACTERS:
44
+ raise S3Error(errors.INVALID_TAG, "A tag key has 1 to 128 characters.")
45
+ if len(value) > MAX_VALUE_CHARACTERS:
46
+ raise S3Error(errors.INVALID_TAG, "A tag value has 256 characters at most.")
47
+ if not (_ALLOWED.fullmatch(key) and _ALLOWED.fullmatch(value)):
48
+ raise S3Error(errors.INVALID_TAG, "A tag holds a character S3 doesn't allow.")