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