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/xml.py ADDED
@@ -0,0 +1,436 @@
1
+ """S3 XML documents: rendering responses and parsing request bodies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from email.utils import formatdate
8
+ from typing import TYPE_CHECKING
9
+ from urllib.parse import quote
10
+
11
+ from leblob.model import (
12
+ Checksum,
13
+ DeleteTarget,
14
+ RequestedPart,
15
+ Versioning,
16
+ )
17
+ from leblob.s3 import errors, xml_base as base
18
+ from leblob.s3.errors import S3Error
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Iterable, Mapping
22
+ from xml.etree.ElementTree import Element
23
+
24
+ from leblob.model import (
25
+ BucketRecord,
26
+ DeleteOutcome,
27
+ ObjectSummary,
28
+ Part,
29
+ Upload,
30
+ UploadRef,
31
+ VersionSummary,
32
+ )
33
+ from leblob.s3.listing import ListPage
34
+
35
+ type Encoder = Callable[[str], str]
36
+
37
+ MAX_DELETE_KEYS = 1000
38
+ _CHECKSUM_ELEMENT_PREFIX = "Checksum"
39
+ _CHECKSUM_TYPE_ELEMENT = "ChecksumType"
40
+
41
+
42
+ def http_date(milliseconds: int) -> str:
43
+ return formatdate(milliseconds / 1000, usegmt=True)
44
+
45
+
46
+ def iso_date(milliseconds: int) -> str:
47
+ moment = datetime.fromtimestamp(milliseconds / 1000, UTC)
48
+ return f"{moment:%Y-%m-%dT%H:%M:%S}.{milliseconds % 1000:03d}Z"
49
+
50
+
51
+ def render_error(code: str, message: str, resource: str, request_id: str) -> bytes:
52
+ return base.document(
53
+ "Error",
54
+ base.element("Code", code),
55
+ base.element("Message", message),
56
+ base.element("Resource", resource),
57
+ base.element("RequestId", request_id),
58
+ namespace="",
59
+ )
60
+
61
+
62
+ def render_buckets(buckets: Iterable[BucketRecord]) -> bytes:
63
+ entries = "".join(
64
+ "<Bucket>"
65
+ f"{base.element('Name', bucket.name)}"
66
+ f"{base.element('CreationDate', iso_date(bucket.created_at))}"
67
+ "</Bucket>"
68
+ for bucket in buckets
69
+ )
70
+ return base.document(
71
+ "ListAllMyBucketsResult", base.OWNER, f"<Buckets>{entries}</Buckets>"
72
+ )
73
+
74
+
75
+ def render_location() -> bytes:
76
+ return base.document("LocationConstraint")
77
+
78
+
79
+ def render_versioning(state: Versioning | None) -> bytes:
80
+ status = base.element("Status", state.value) if state else ""
81
+ return base.document("VersioningConfiguration", status)
82
+
83
+
84
+ def parse_versioning(body: bytes) -> Versioning | None:
85
+ """Return the state asked for, or None if the request leaves it as it is."""
86
+ root = base.parse_document(body)
87
+ if base.optional_child_text(root, "MfaDelete") == "Enabled":
88
+ raise S3Error(errors.NOT_IMPLEMENTED, "MFA delete is not implemented.")
89
+ status = base.optional_child_text(root, "Status")
90
+ if status is None:
91
+ return None
92
+ try:
93
+ return Versioning(status)
94
+ except ValueError:
95
+ raise S3Error(errors.MALFORMED_XML, f"Bad status: {status!r}.") from None
96
+
97
+
98
+ def render_post_result(bucket: str, key: str, etag: str, location: str) -> bytes:
99
+ return base.document(
100
+ "PostResponse",
101
+ base.element("Location", location),
102
+ base.element("Bucket", bucket),
103
+ base.element("Key", key),
104
+ base.element("ETag", f'"{etag}"'),
105
+ )
106
+
107
+
108
+ def render_list_v1(
109
+ bucket: str,
110
+ query: Mapping[str, str],
111
+ page: ListPage,
112
+ max_keys: int,
113
+ ) -> bytes:
114
+ encode = _encoder(query)
115
+ marker = (
116
+ base.element("NextMarker", encode(page.next_marker))
117
+ if page.next_marker is not None and query.get("delimiter")
118
+ else ""
119
+ )
120
+ return base.document(
121
+ "ListBucketResult",
122
+ base.element("Name", bucket),
123
+ # Not encoded: botocore doesn't decode this one element in version 1.
124
+ base.element("Prefix", query.get("prefix", "")),
125
+ base.element("Marker", encode(query.get("marker", ""))),
126
+ marker,
127
+ base.element("MaxKeys", max_keys),
128
+ _optional_delimiter(query, encode),
129
+ _optional_encoding_type(query),
130
+ base.element("IsTruncated", _boolean(page.is_truncated)),
131
+ _render_page(page, encode, owner=True),
132
+ )
133
+
134
+
135
+ def render_list_v2(
136
+ bucket: str,
137
+ query: Mapping[str, str],
138
+ page: ListPage,
139
+ max_keys: int,
140
+ next_token: str | None,
141
+ ) -> bytes:
142
+ encode = _encoder(query)
143
+ return base.document(
144
+ "ListBucketResult",
145
+ base.element("Name", bucket),
146
+ base.element("Prefix", encode(query.get("prefix", ""))),
147
+ base.element("KeyCount", len(page.objects) + len(page.common_prefixes)),
148
+ base.element("MaxKeys", max_keys),
149
+ _optional_delimiter(query, encode),
150
+ _optional_encoding_type(query),
151
+ base.element("IsTruncated", _boolean(page.is_truncated)),
152
+ base.optional("ContinuationToken", query.get("continuation-token")),
153
+ base.optional("NextContinuationToken", next_token),
154
+ base.optional(
155
+ "StartAfter",
156
+ encode(query["start-after"]) if "start-after" in query else None,
157
+ ),
158
+ _render_page(page, encode, owner=query.get("fetch-owner") == "true"),
159
+ )
160
+
161
+
162
+ def render_versions(
163
+ bucket: str,
164
+ query: Mapping[str, str],
165
+ page: ListPage[VersionSummary],
166
+ max_keys: int,
167
+ ) -> bytes:
168
+ encode = _encoder(query)
169
+ next_marker = ""
170
+ if page.next_marker is not None:
171
+ last = page.objects[-1] if page.objects else None
172
+ next_version = (
173
+ last.version_id if last is not None and last.key == page.next_marker else ""
174
+ )
175
+ next_marker = base.element(
176
+ "NextKeyMarker", encode(page.next_marker)
177
+ ) + base.element("NextVersionIdMarker", next_version)
178
+ prefixes = "".join(
179
+ f"<CommonPrefixes>{base.element('Prefix', encode(common))}</CommonPrefixes>"
180
+ for common in page.common_prefixes
181
+ )
182
+ return base.document(
183
+ "ListVersionsResult",
184
+ base.element("Name", bucket),
185
+ base.element("Prefix", encode(query.get("prefix", ""))),
186
+ base.element("KeyMarker", encode(query.get("key-marker", ""))),
187
+ base.element("VersionIdMarker", query.get("version-id-marker", "")),
188
+ next_marker,
189
+ base.element("MaxKeys", max_keys),
190
+ _optional_delimiter(query, encode),
191
+ _optional_encoding_type(query),
192
+ base.element("IsTruncated", _boolean(page.is_truncated)),
193
+ "".join(_render_version(item, encode) for item in page.objects),
194
+ prefixes,
195
+ )
196
+
197
+
198
+ def _render_version(item: VersionSummary, encode: Encoder) -> str:
199
+ common = (
200
+ f"{base.element('Key', encode(item.key))}"
201
+ f"{base.element('VersionId', item.version_id)}"
202
+ f"{base.element('IsLatest', _boolean(item.is_latest))}"
203
+ f"{base.element('LastModified', iso_date(item.modified_at))}"
204
+ )
205
+ if item.is_delete_marker:
206
+ return f"<DeleteMarker>{common}{base.OWNER}</DeleteMarker>"
207
+ return (
208
+ f"<Version>{common}"
209
+ f"{base.element('ETag', f'"{item.etag}"')}"
210
+ f"{base.element('Size', item.size)}"
211
+ f"{base.element('StorageClass', 'STANDARD')}"
212
+ f"{base.OWNER}</Version>"
213
+ )
214
+
215
+
216
+ def render_copy_result(etag: str, modified_at: int) -> bytes:
217
+ return base.document(
218
+ "CopyObjectResult",
219
+ base.element("ETag", f'"{etag}"'),
220
+ base.element("LastModified", iso_date(modified_at)),
221
+ )
222
+
223
+
224
+ def render_copy_part_result(
225
+ etag: str, modified_at: int, checksum: Checksum | None
226
+ ) -> bytes:
227
+ return base.document(
228
+ "CopyPartResult",
229
+ base.element("ETag", f'"{etag}"'),
230
+ base.element("LastModified", iso_date(modified_at)),
231
+ _render_checksum(checksum),
232
+ )
233
+
234
+
235
+ def render_initiate(bucket: str, key: str, upload_id: str) -> bytes:
236
+ return base.document(
237
+ "InitiateMultipartUploadResult",
238
+ base.element("Bucket", bucket),
239
+ base.element("Key", key),
240
+ base.element("UploadId", upload_id),
241
+ )
242
+
243
+
244
+ def render_complete(
245
+ bucket: str, key: str, etag: str, checksum: Checksum | None
246
+ ) -> bytes:
247
+ checksum_type = (
248
+ base.element("ChecksumType", checksum.checksum_type) if checksum else ""
249
+ )
250
+ return base.document(
251
+ "CompleteMultipartUploadResult",
252
+ base.element("Location", f"/{bucket}/{quote(key)}"),
253
+ base.element("Bucket", bucket),
254
+ base.element("Key", key),
255
+ base.element("ETag", f'"{etag}"'),
256
+ _render_checksum(checksum),
257
+ checksum_type,
258
+ )
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class PartsPage:
263
+ parts: tuple[Part, ...]
264
+ max_parts: int
265
+ marker: int
266
+ next_marker: int | None
267
+
268
+
269
+ def render_parts(upload: UploadRef, page: PartsPage) -> bytes:
270
+ entries = "".join(
271
+ "<Part>"
272
+ f"{base.element('PartNumber', part.number)}"
273
+ f"{base.element('LastModified', iso_date(part.modified_at))}"
274
+ f"{base.element('ETag', f'"{part.blob.md5}"')}"
275
+ f"{base.element('Size', part.blob.size)}"
276
+ f"{_render_checksum(part.checksum)}"
277
+ "</Part>"
278
+ for part in page.parts
279
+ )
280
+ return base.document(
281
+ "ListPartsResult",
282
+ base.element("Bucket", upload.bucket),
283
+ base.element("Key", upload.key),
284
+ base.element("UploadId", upload.upload_id),
285
+ base.element("PartNumberMarker", page.marker),
286
+ base.optional("NextPartNumberMarker", page.next_marker),
287
+ base.element("MaxParts", page.max_parts),
288
+ base.element("IsTruncated", _boolean(page.next_marker is not None)),
289
+ entries,
290
+ )
291
+
292
+
293
+ def render_uploads(
294
+ bucket: str,
295
+ query: Mapping[str, str],
296
+ uploads: Iterable[Upload],
297
+ max_uploads: int,
298
+ next_position: tuple[str, str] | None,
299
+ ) -> bytes:
300
+ encode = _encoder(query)
301
+ entries = "".join(
302
+ "<Upload>"
303
+ f"{base.element('Key', encode(upload.key))}"
304
+ f"{base.element('UploadId', upload.upload_id)}"
305
+ f"{base.OWNER}"
306
+ f"{base.element('Initiated', iso_date(upload.created_at))}"
307
+ "</Upload>"
308
+ for upload in uploads
309
+ )
310
+ next_key, next_upload_id = next_position or (None, None)
311
+ return base.document(
312
+ "ListMultipartUploadsResult",
313
+ base.element("Bucket", bucket),
314
+ base.element("KeyMarker", encode(query.get("key-marker", ""))),
315
+ base.element("UploadIdMarker", query.get("upload-id-marker", "")),
316
+ base.optional("NextKeyMarker", encode(next_key) if next_key else None),
317
+ base.optional("NextUploadIdMarker", next_upload_id),
318
+ base.element("Prefix", encode(query.get("prefix", ""))),
319
+ base.element("MaxUploads", max_uploads),
320
+ _optional_encoding_type(query),
321
+ base.element("IsTruncated", _boolean(next_position is not None)),
322
+ entries,
323
+ )
324
+
325
+
326
+ def render_deleted(outcomes: Iterable[DeleteOutcome], *, quiet: bool) -> bytes:
327
+ if quiet:
328
+ return base.document("DeleteResult")
329
+ return base.document("DeleteResult", "".join(map(_render_outcome, outcomes)))
330
+
331
+
332
+ def _render_outcome(outcome: DeleteOutcome) -> str:
333
+ version = base.optional("VersionId", outcome.version_id)
334
+ marker = ""
335
+ if outcome.is_delete_marker:
336
+ marker = base.element("DeleteMarker", "true") + base.optional(
337
+ "DeleteMarkerVersionId", outcome.version_id
338
+ )
339
+ version = ""
340
+ return f"<Deleted>{base.element('Key', outcome.key)}{version}{marker}</Deleted>"
341
+
342
+
343
+ def parse_delete(body: bytes) -> tuple[list[DeleteTarget], bool]:
344
+ """Return what to delete and whether the response should be quiet."""
345
+ root = base.parse_document(body)
346
+ keys = [
347
+ DeleteTarget(
348
+ base.child_text(item, "Key"), base.optional_child_text(item, "VersionId")
349
+ )
350
+ for item in root
351
+ if base.local_name(item.tag) == "Object"
352
+ ]
353
+ quiet = any(
354
+ base.local_name(item.tag) == "Quiet"
355
+ and (item.text or "").strip().lower() == "true"
356
+ for item in root
357
+ )
358
+ if len(keys) > MAX_DELETE_KEYS:
359
+ raise S3Error(errors.MALFORMED_XML, f"At most {MAX_DELETE_KEYS} keys at once.")
360
+ return keys, quiet
361
+
362
+
363
+ def parse_complete(body: bytes) -> tuple[RequestedPart, ...]:
364
+ """Return the parts a client wants to assemble."""
365
+ parts = []
366
+ for item in base.parse_document(body):
367
+ if base.local_name(item.tag) != "Part":
368
+ continue
369
+ number = base.child_text(item, "PartNumber")
370
+ if not number.isdecimal():
371
+ raise S3Error(errors.MALFORMED_XML, f"Bad part number: {number!r}.")
372
+ etag = base.child_text(item, "ETag").strip().strip('"')
373
+ parts.append(RequestedPart(int(number), etag, _part_checksums(item)))
374
+ return tuple(parts)
375
+
376
+
377
+ def _part_checksums(part: Element) -> tuple[Checksum, ...]:
378
+ return tuple(
379
+ Checksum(
380
+ base.local_name(child.tag).removeprefix(_CHECKSUM_ELEMENT_PREFIX).lower(),
381
+ child.text or "",
382
+ )
383
+ for child in part
384
+ if base.local_name(child.tag).startswith(_CHECKSUM_ELEMENT_PREFIX)
385
+ and base.local_name(child.tag) != _CHECKSUM_TYPE_ELEMENT
386
+ )
387
+
388
+
389
+ def _encoder(query: Mapping[str, str]) -> Encoder:
390
+ """Honour encoding-type=url, which is what lets clients receive any key."""
391
+ if query.get("encoding-type") == "url":
392
+ return lambda value: quote(value, safe="/")
393
+ return lambda value: value
394
+
395
+
396
+ def _render_page(page: ListPage, encode: Encoder, *, owner: bool) -> str:
397
+ return "".join((
398
+ *(_render_summary(item, encode, owner=owner) for item in page.objects),
399
+ *(
400
+ f"<CommonPrefixes>{base.element('Prefix', encode(common))}</CommonPrefixes>"
401
+ for common in page.common_prefixes
402
+ ),
403
+ ))
404
+
405
+
406
+ def _render_summary(item: ObjectSummary, encode: Encoder, *, owner: bool) -> str:
407
+ return (
408
+ "<Contents>"
409
+ f"{base.element('Key', encode(item.key))}"
410
+ f"{base.element('LastModified', iso_date(item.modified_at))}"
411
+ f"{base.element('ETag', f'"{item.etag}"')}"
412
+ f"{base.element('Size', item.size)}"
413
+ f"{base.element('StorageClass', 'STANDARD')}"
414
+ f"{base.OWNER if owner else ''}"
415
+ "</Contents>"
416
+ )
417
+
418
+
419
+ def _render_checksum(checksum: Checksum | None) -> str:
420
+ if checksum is None:
421
+ return ""
422
+ return base.element(f"Checksum{checksum.algorithm.upper()}", checksum.value)
423
+
424
+
425
+ def _optional_delimiter(query: Mapping[str, str], encode: Encoder) -> str:
426
+ return base.optional(
427
+ "Delimiter", encode(query["delimiter"]) if query.get("delimiter") else None
428
+ )
429
+
430
+
431
+ def _optional_encoding_type(query: Mapping[str, str]) -> str:
432
+ return base.optional("EncodingType", query.get("encoding-type"))
433
+
434
+
435
+ def _boolean(value: bool) -> str:
436
+ return "true" if value else "false"
leblob/s3/xml_base.py ADDED
@@ -0,0 +1,64 @@
1
+ """What every S3 XML document here is built and read with. Pure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+ from xml.sax.saxutils import escape
7
+
8
+ from defusedxml import DefusedXmlException, ElementTree
9
+
10
+ from leblob.s3 import errors
11
+ from leblob.s3.errors import S3Error
12
+
13
+ if TYPE_CHECKING:
14
+ from xml.etree.ElementTree import Element
15
+
16
+ NAMESPACE = "http://s3.amazonaws.com/doc/2006-03-01/"
17
+
18
+
19
+ OWNER_ID = "leblob"
20
+
21
+
22
+ OWNER = f"<Owner><ID>{OWNER_ID}</ID><DisplayName>{OWNER_ID}</DisplayName></Owner>"
23
+
24
+
25
+ def parse_document(body: bytes) -> Element:
26
+ """Parse untrusted XML, refusing DTDs and entities in any encoding."""
27
+ try:
28
+ return ElementTree.fromstring(body, forbid_dtd=True)
29
+ except (ElementTree.ParseError, DefusedXmlException):
30
+ raise S3Error(errors.MALFORMED_XML) from None
31
+
32
+
33
+ def child_text(parent: Element, name: str) -> str:
34
+ text = optional_child_text(parent, name)
35
+ if text is None:
36
+ raise S3Error(errors.MALFORMED_XML, f"Missing element: {name}.")
37
+ return text
38
+
39
+
40
+ def optional_child_text(parent: Element, name: str) -> str | None:
41
+ for child in parent:
42
+ if local_name(child.tag) == name:
43
+ return child.text
44
+ return None
45
+
46
+
47
+ def local_name(tag: str) -> str:
48
+ return tag.rpartition("}")[2]
49
+
50
+
51
+ def optional(tag: str, value: object | None) -> str:
52
+ return "" if value is None else element(tag, value)
53
+
54
+
55
+ def element(tag: str, value: object) -> str:
56
+ return f"<{tag}>{escape(str(value))}</{tag}>"
57
+
58
+
59
+ def document(root: str, *children: str, namespace: str = NAMESPACE) -> bytes:
60
+ attribute = f' xmlns="{namespace}"' if namespace else ""
61
+ body = "".join(children)
62
+ return (
63
+ f'<?xml version="1.0" encoding="UTF-8"?>\n<{root}{attribute}>{body}</{root}>'
64
+ ).encode()