• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / c98f6ef0-0f32-4bad-99a7-eb2ba1217c55

28 Jan 2025 03:05PM UTC coverage: 86.895% (-0.006%) from 86.901%
c98f6ef0-0f32-4bad-99a7-eb2ba1217c55

push

circleci

web-flow
S3: implement new data integrity for MultipartUpload (#12183)

175 of 186 new or added lines in 4 files covered. (94.09%)

376 existing lines in 8 files now uncovered.

61354 of 70607 relevant lines covered (86.9%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.32
/localstack-core/localstack/services/s3/utils.py
1
import base64
1✔
2
import codecs
1✔
3
import datetime
1✔
4
import hashlib
1✔
5
import logging
1✔
6
import re
1✔
7
import zlib
1✔
8
from enum import StrEnum
1✔
9
from secrets import token_bytes
1✔
10
from typing import Any, Dict, Literal, NamedTuple, Optional, Protocol, Tuple, Union
1✔
11
from urllib import parse as urlparser
1✔
12
from zoneinfo import ZoneInfo
1✔
13

14
import xmltodict
1✔
15
from botocore.exceptions import ClientError
1✔
16
from botocore.utils import InvalidArnException
1✔
17

18
from localstack import config, constants
1✔
19
from localstack.aws.api import CommonServiceException, RequestContext
1✔
20
from localstack.aws.api.s3 import (
1✔
21
    AccessControlPolicy,
22
    BucketCannedACL,
23
    BucketName,
24
    ChecksumAlgorithm,
25
    ContentMD5,
26
    CopyObjectRequest,
27
    CopySource,
28
    ETag,
29
    GetObjectRequest,
30
    Grant,
31
    Grantee,
32
    HeadObjectRequest,
33
    InvalidArgument,
34
    InvalidRange,
35
    InvalidTag,
36
    LifecycleExpiration,
37
    LifecycleRule,
38
    LifecycleRules,
39
    Metadata,
40
    ObjectCannedACL,
41
    ObjectKey,
42
    ObjectSize,
43
    ObjectVersionId,
44
    Owner,
45
    Permission,
46
    PreconditionFailed,
47
    PutObjectRequest,
48
    SSEKMSKeyId,
49
    TaggingHeader,
50
    TagSet,
51
    UploadPartRequest,
52
)
53
from localstack.aws.api.s3 import Type as GranteeType
1✔
54
from localstack.aws.chain import HandlerChain
1✔
55
from localstack.aws.connect import connect_to
1✔
56
from localstack.http import Response
1✔
57
from localstack.services.s3 import checksums
1✔
58
from localstack.services.s3.constants import (
1✔
59
    ALL_USERS_ACL_GRANTEE,
60
    AUTHENTICATED_USERS_ACL_GRANTEE,
61
    CHECKSUM_ALGORITHMS,
62
    LOG_DELIVERY_ACL_GRANTEE,
63
    S3_VIRTUAL_HOST_FORWARDED_HEADER,
64
    SIGNATURE_V2_PARAMS,
65
    SIGNATURE_V4_PARAMS,
66
    SYSTEM_METADATA_SETTABLE_HEADERS,
67
)
68
from localstack.services.s3.exceptions import InvalidRequest, MalformedXML
1✔
69
from localstack.utils.aws import arns
1✔
70
from localstack.utils.aws.arns import parse_arn
1✔
71
from localstack.utils.strings import (
1✔
72
    is_base64,
73
    to_bytes,
74
    to_str,
75
)
76
from localstack.utils.urls import localstack_host
1✔
77

78
LOG = logging.getLogger(__name__)
1✔
79

80
BUCKET_NAME_REGEX = (
1✔
81
    r"(?=^.{3,63}$)(?!^(\d+\.)+\d+$)"
82
    + r"(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)"
83
)
84

85
TAG_REGEX = re.compile(r"^[\w\s.:/=+\-@]*$")
1✔
86

87

88
S3_VIRTUAL_HOSTNAME_REGEX = (
1✔
89
    r"(?P<bucket>.*).s3.(?P<region>(?:us-gov|us|ap|ca|cn|eu|sa)-[a-z]+-\d)?.*"
90
)
91

92
_s3_virtual_host_regex = re.compile(S3_VIRTUAL_HOSTNAME_REGEX)
1✔
93

94

95
RFC1123 = "%a, %d %b %Y %H:%M:%S GMT"
1✔
96
_gmt_zone_info = ZoneInfo("GMT")
1✔
97

98
_version_id_safe_encode_translation = bytes.maketrans(b"+/", b"._")
1✔
99

100

101
def s3_response_handler(chain: HandlerChain, context: RequestContext, response: Response):
1✔
102
    """
103
    This response handler is taking care of removing certain headers from S3 responses.
104
    We cannot handle this in the serializer, because the serializer handler calls `Response.update_from`, which does
105
    not allow you to remove headers, only add them.
106
    This handler can delete headers from the response.
107
    """
108
    # some requests, for example coming frome extensions, are flagged as S3 requests. This check confirms that it is
109
    # indeed truly an S3 request by checking if it parsed properly as an S3 operation
110
    if not context.service_operation:
1✔
111
        return
1✔
112

113
    # if AWS returns 204, it will not return a body, Content-Length and Content-Type
114
    # the web server is already taking care of deleting the body, but it's more explicit to remove it here
115
    if response.status_code == 204:
1✔
116
        response.data = b""
1✔
117
        response.headers.pop("Content-Type", None)
1✔
118
        response.headers.pop("Content-Length", None)
1✔
119

120
    elif (
1✔
121
        response.status_code == 200
122
        and context.request.method == "PUT"
123
        and response.headers.get("Content-Length") in (0, None)
124
    ):
125
        # AWS does not return a Content-Type if the Content-Length is 0
126
        response.headers.pop("Content-Type", None)
1✔
127

128

129
def get_owner_for_account_id(account_id: str):
1✔
130
    """
131
    This method returns the S3 Owner from the account id. for now, this is hardcoded as it was in moto, but we can then
132
    extend it to return different values depending on the account ID
133
    See https://docs.aws.amazon.com/AmazonS3/latest/API/API_Owner.html
134
    :param account_id: the owner account id
135
    :return: the Owner object containing the DisplayName and owner ID
136
    """
137
    return Owner(
1✔
138
        DisplayName="webfile",  # only in certain regions, see above
139
        ID="75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a",
140
    )
141

142

143
def extract_bucket_key_version_id_from_copy_source(
1✔
144
    copy_source: CopySource,
145
) -> tuple[BucketName, ObjectKey, Optional[ObjectVersionId]]:
146
    """
147
    Utility to parse bucket name, object key and optionally its versionId. It accepts the CopySource format:
148
    - <bucket-name/<object-key>?versionId=<version-id>, used for example in CopySource for CopyObject
149
    :param copy_source: the S3 CopySource to parse
150
    :return: parsed BucketName, ObjectKey and optionally VersionId
151
    """
152
    copy_source_parsed = urlparser.urlparse(copy_source)
1✔
153
    # we need to manually replace `+` character with a space character before URL decoding, because different languages
154
    # don't encode their URL the same way (%20 vs +), and Python doesn't unquote + into a space char
155
    parsed_path = urlparser.unquote(copy_source_parsed.path.replace("+", " ")).lstrip("/")
1✔
156

157
    if "/" not in parsed_path:
1✔
158
        raise InvalidArgument(
1✔
159
            "Invalid copy source object key",
160
            ArgumentName="x-amz-copy-source",
161
            ArgumentValue="x-amz-copy-source",
162
        )
163
    src_bucket, src_key = parsed_path.split("/", 1)
1✔
164
    src_version_id = urlparser.parse_qs(copy_source_parsed.query).get("versionId", [None])[0]
1✔
165

166
    return src_bucket, src_key, src_version_id
1✔
167

168

169
class ChecksumHash(Protocol):
1✔
170
    """
171
    This Protocol allows proper typing for different kind of hash used by S3 (hashlib.shaX, zlib.crc32 from
172
    S3CRC32Checksum, and botocore CrtCrc32cChecksum).
173
    """
174

175
    def digest(self) -> bytes: ...
1✔
176

177
    def update(self, value: bytes): ...
1✔
178

179

180
def get_s3_checksum_algorithm_from_request(
1✔
181
    request: PutObjectRequest | UploadPartRequest,
182
) -> ChecksumAlgorithm | None:
183
    checksum_algorithm: list[ChecksumAlgorithm] = [
1✔
184
        algo for algo in CHECKSUM_ALGORITHMS if request.get(f"Checksum{algo}")
185
    ]
186
    if not checksum_algorithm:
1✔
187
        return None
1✔
188

189
    if len(checksum_algorithm) > 1:
1✔
190
        raise InvalidRequest(
1✔
191
            "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed."
192
        )
193

194
    return checksum_algorithm[0]
1✔
195

196

197
def get_s3_checksum_algorithm_from_trailing_headers(
1✔
198
    trailing_headers: str,
199
) -> ChecksumAlgorithm | None:
200
    checksum_algorithm: list[ChecksumAlgorithm] = [
1✔
201
        algo for algo in CHECKSUM_ALGORITHMS if f"x-amz-checksum-{algo.lower()}" in trailing_headers
202
    ]
203
    if not checksum_algorithm:
1✔
204
        return None
1✔
205

206
    if len(checksum_algorithm) > 1:
1✔
207
        raise InvalidRequest(
×
208
            "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed."
209
        )
210

211
    return checksum_algorithm[0]
1✔
212

213

214
def get_s3_checksum(algorithm) -> ChecksumHash:
1✔
215
    match algorithm:
1✔
216
        case ChecksumAlgorithm.CRC32:
1✔
217
            return S3CRC32Checksum()
1✔
218

219
        case ChecksumAlgorithm.CRC32C:
1✔
220
            from botocore.httpchecksum import CrtCrc32cChecksum
1✔
221

222
            return CrtCrc32cChecksum()
1✔
223

224
        case ChecksumAlgorithm.CRC64NVME:
1✔
225
            from botocore.httpchecksum import CrtCrc64NvmeChecksum
1✔
226

227
            return CrtCrc64NvmeChecksum()
1✔
228

229
        case ChecksumAlgorithm.SHA1:
1✔
230
            return hashlib.sha1(usedforsecurity=False)
1✔
231

232
        case ChecksumAlgorithm.SHA256:
1✔
233
            return hashlib.sha256(usedforsecurity=False)
1✔
234

235
        case _:
×
236
            # TODO: check proper error? for now validated client side, need to check server response
237
            raise InvalidRequest("The value specified in the x-amz-trailer header is not supported")
×
238

239

240
class S3CRC32Checksum:
1✔
241
    """Implements a unified way of using zlib.crc32 compatible with hashlib.sha and botocore CrtCrc32cChecksum"""
242

243
    __slots__ = ["checksum"]
1✔
244

245
    def __init__(self):
1✔
246
        self.checksum = zlib.crc32(b"")
1✔
247

248
    def update(self, value: bytes):
1✔
249
        self.checksum = zlib.crc32(value, self.checksum)
1✔
250

251
    def digest(self) -> bytes:
1✔
252
        return self.checksum.to_bytes(4, "big")
1✔
253

254

255
class CombinedCrcHash:
1✔
256
    def __init__(self, checksum_type: ChecksumAlgorithm):
1✔
257
        match checksum_type:
1✔
258
            case ChecksumAlgorithm.CRC32:
1✔
259
                func = checksums.combine_crc32
1✔
260
            case ChecksumAlgorithm.CRC32C:
1✔
261
                func = checksums.combine_crc32c
1✔
262
            case ChecksumAlgorithm.CRC64NVME:
1✔
263
                func = checksums.combine_crc64_nvme
1✔
NEW
264
            case _:
×
NEW
265
                raise ValueError("You cannot combine SHA based checksums")
×
266

267
        self.combine_function = func
1✔
268
        self.checksum = b""
1✔
269

270
    def combine(self, value: bytes, object_len: int):
1✔
271
        if not self.checksum:
1✔
272
            self.checksum = value
1✔
273
            return
1✔
274

275
        self.checksum = self.combine_function(self.checksum, value, object_len)
1✔
276

277
    def digest(self):
1✔
278
        return self.checksum
1✔
279

280

281
class ObjectRange(NamedTuple):
1✔
282
    """
283
    NamedTuple representing a parsed Range header with the requested S3 object size
284
    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
285
    """
286

287
    content_range: str  # the original Range header
1✔
288
    content_length: int  # the full requested object size
1✔
289
    begin: int  # the start of range
1✔
290
    end: int  # the end of the end
1✔
291

292

293
def parse_range_header(range_header: str, object_size: int) -> ObjectRange | None:
1✔
294
    """
295
    Takes a Range header, and returns a dataclass containing the necessary information to return only a slice of an
296
    S3 object. If the range header is invalid, we return None so that the request is treated as a regular request.
297
    :param range_header: a Range header
298
    :param object_size: the requested S3 object total size
299
    :return: ObjectRange or None if the Range header is invalid
300
    """
301
    last = object_size - 1
1✔
302
    try:
1✔
303
        _, rspec = range_header.split("=")
1✔
304
    except ValueError:
1✔
305
        return None
1✔
306
    if "," in rspec:
1✔
307
        return None
1✔
308

309
    try:
1✔
310
        begin, end = [int(i) if i else None for i in rspec.split("-")]
1✔
311
    except ValueError:
1✔
312
        # if we can't parse the Range header, S3 just treat the request as a non-range request
313
        return None
1✔
314

315
    if (begin is None and end == 0) or (begin is not None and begin > last):
1✔
316
        raise InvalidRange(
1✔
317
            "The requested range is not satisfiable",
318
            ActualObjectSize=str(object_size),
319
            RangeRequested=range_header,
320
        )
321

322
    if begin is not None:  # byte range
1✔
323
        end = last if end is None else min(end, last)
1✔
324
    elif end is not None:  # suffix byte range
1✔
325
        begin = object_size - min(end, object_size)
1✔
326
        end = last
1✔
327
    else:
328
        # Treat as non-range request
329
        return None
1✔
330

331
    if begin > min(end, last):
1✔
332
        # Treat as non-range request if after the logic is applied
333
        return None
1✔
334

335
    return ObjectRange(
1✔
336
        content_range=f"bytes {begin}-{end}/{object_size}",
337
        content_length=end - begin + 1,
338
        begin=begin,
339
        end=end,
340
    )
341

342

343
def parse_copy_source_range_header(copy_source_range: str, object_size: int) -> ObjectRange:
1✔
344
    """
345
    Takes a CopySourceRange parameter, and returns a dataclass containing the necessary information to return only a slice of an
346
    S3 object. The validation is much stricter than `parse_range_header`
347
    :param copy_source_range: a CopySourceRange parameter for UploadCopyPart
348
    :param object_size: the requested S3 object total size
349
    :raises InvalidArgument if the CopySourceRanger parameter does not follow validation
350
    :return: ObjectRange
351
    """
352
    last = object_size - 1
1✔
353
    try:
1✔
354
        _, rspec = copy_source_range.split("=")
1✔
355
    except ValueError:
1✔
356
        raise InvalidArgument(
1✔
357
            "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy",
358
            ArgumentName="x-amz-copy-source-range",
359
            ArgumentValue=copy_source_range,
360
        )
361
    if "," in rspec:
1✔
362
        raise InvalidArgument(
1✔
363
            "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy",
364
            ArgumentName="x-amz-copy-source-range",
365
            ArgumentValue=copy_source_range,
366
        )
367

368
    try:
1✔
369
        begin, end = [int(i) if i else None for i in rspec.split("-")]
1✔
370
    except ValueError:
1✔
371
        # if we can't parse the Range header, S3 just treat the request as a non-range request
372
        raise InvalidArgument(
1✔
373
            "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy",
374
            ArgumentName="x-amz-copy-source-range",
375
            ArgumentValue=copy_source_range,
376
        )
377

378
    if begin is None or end is None or begin > end:
1✔
379
        raise InvalidArgument(
1✔
380
            "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy",
381
            ArgumentName="x-amz-copy-source-range",
382
            ArgumentValue=copy_source_range,
383
        )
384

385
    if begin > last:
1✔
386
        # Treat as non-range request if after the logic is applied
387
        raise InvalidRequest(
1✔
388
            "The specified copy range is invalid for the source object size",
389
        )
390
    elif end > last:
1✔
391
        raise InvalidArgument(
1✔
392
            f"Range specified is not valid for source object of size: {object_size}",
393
            ArgumentName="x-amz-copy-source-range",
394
            ArgumentValue=copy_source_range,
395
        )
396

397
    return ObjectRange(
1✔
398
        content_range=f"bytes {begin}-{end}/{object_size}",
399
        content_length=end - begin + 1,
400
        begin=begin,
401
        end=end,
402
    )
403

404

405
def get_full_default_bucket_location(bucket_name: BucketName) -> str:
1✔
406
    host_definition = localstack_host()
1✔
407
    if host_definition.host != constants.LOCALHOST_HOSTNAME:
1✔
408
        # the user has customised their LocalStack hostname, and may not support subdomains.
409
        # Return the location in path form.
410
        return f"{config.get_protocol()}://{host_definition.host_and_port()}/{bucket_name}/"
1✔
411
    else:
412
        return f"{config.get_protocol()}://{bucket_name}.s3.{host_definition.host_and_port()}/"
1✔
413

414

415
def etag_to_base_64_content_md5(etag: ETag) -> str:
1✔
416
    """
417
    Convert an ETag, representing a MD5 hexdigest (might be quoted), to its base64 encoded representation
418
    :param etag: an ETag, might be quoted
419
    :return: the base64 value
420
    """
421
    # get the bytes digest from the hexdigest
422
    byte_digest = codecs.decode(to_bytes(etag.strip('"')), "hex")
1✔
423
    return to_str(base64.b64encode(byte_digest))
1✔
424

425

426
def base_64_content_md5_to_etag(content_md5: ContentMD5) -> str | None:
1✔
427
    """
428
    Convert a ContentMD5 header, representing a base64 encoded representation of a MD5 binary digest to its ETag value,
429
    hex encoded
430
    :param content_md5: a ContentMD5 header, base64 encoded
431
    :return: the ETag value, hex coded MD5 digest, or None if the input is not valid b64 or the representation of a MD5
432
    hash
433
    """
434
    if not is_base64(content_md5):
1✔
435
        return None
1✔
436
    # get the hexdigest from the bytes digest
437
    byte_digest = base64.b64decode(content_md5)
1✔
438
    hex_digest = to_str(codecs.encode(byte_digest, "hex"))
1✔
439
    if len(hex_digest) != 32:
1✔
440
        return None
1✔
441

442
    return hex_digest
1✔
443

444

445
def is_presigned_url_request(context: RequestContext) -> bool:
1✔
446
    """
447
    Detects pre-signed URL from query string parameters
448
    Return True if any kind of presigned URL query string parameter is encountered
449
    :param context: the request context from the handler chain
450
    """
451
    # Detecting pre-sign url and checking signature
452
    query_parameters = context.request.args
1✔
453
    return any(p in query_parameters for p in SIGNATURE_V2_PARAMS) or any(
1✔
454
        p in query_parameters for p in SIGNATURE_V4_PARAMS
455
    )
456

457

458
def is_bucket_name_valid(bucket_name: str) -> bool:
1✔
459
    """
460
    ref. https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
461
    """
462
    return True if re.match(BUCKET_NAME_REGEX, bucket_name) else False
1✔
463

464

465
def get_permission_header_name(permission: Permission) -> str:
1✔
466
    return f"x-amz-grant-{permission.replace('_', '-').lower()}"
1✔
467

468

469
def get_permission_from_header(capitalized_field: str) -> Permission:
1✔
470
    headers_parts = [part.upper() for part in re.split(r"([A-Z][a-z]+)", capitalized_field) if part]
1✔
471
    return "_".join(headers_parts[1:])
1✔
472

473

474
def is_valid_canonical_id(canonical_id: str) -> bool:
1✔
475
    """
476
    Validate that the string is a hex string with 64 char
477
    """
478
    try:
1✔
479
        return int(canonical_id, 16) and len(canonical_id) == 64
1✔
480
    except ValueError:
1✔
481
        return False
1✔
482

483

484
def uses_host_addressing(headers: Dict[str, str]) -> str | None:
1✔
485
    """
486
    Determines if the request is targeting S3 with virtual host addressing
487
    :param headers: the request headers
488
    :return: if the request targets S3 with virtual host addressing, returns the bucket name else None
489
    """
490
    host = headers.get("host", "")
1✔
491

492
    # try to extract the bucket from the hostname (the "in" check is a minor optimization, as the regex is very greedy)
493
    if ".s3." in host and (
1✔
494
        (match := _s3_virtual_host_regex.match(host)) and (bucket_name := match.group("bucket"))
495
    ):
496
        return bucket_name
1✔
497

498

499
def get_class_attrs_from_spec_class(spec_class: type[StrEnum]) -> set[str]:
1✔
500
    return {str(spec) for spec in spec_class}
1✔
501

502

503
def get_system_metadata_from_request(request: dict) -> Metadata:
1✔
504
    metadata: Metadata = {}
1✔
505

506
    for system_metadata_field in SYSTEM_METADATA_SETTABLE_HEADERS:
1✔
507
        if field_value := request.get(system_metadata_field):
1✔
508
            metadata[system_metadata_field] = field_value
1✔
509

510
    return metadata
1✔
511

512

513
def forwarded_from_virtual_host_addressed_request(headers: dict[str, str]) -> bool:
1✔
514
    """
515
    Determines if the request was forwarded from a v-host addressing style into a path one
516
    """
517
    # we can assume that the host header we are receiving here is actually the header we originally received
518
    # from the client (because the edge service is forwarding the request in memory)
519
    return S3_VIRTUAL_HOST_FORWARDED_HEADER in headers
1✔
520

521

522
def extract_bucket_name_and_key_from_headers_and_path(
1✔
523
    headers: dict[str, str], path: str
524
) -> tuple[Optional[str], Optional[str]]:
525
    """
526
    Extract the bucket name and the object key from a request headers and path. This works with both virtual host
527
    and path style requests.
528
    :param headers: the request headers, used to get the Host
529
    :param path: the request path
530
    :return: if found, the bucket name and object key
531
    """
532
    bucket_name = None
1✔
533
    object_key = None
1✔
534
    host = headers.get("host", "")
1✔
535
    if ".s3" in host:
1✔
536
        vhost_match = _s3_virtual_host_regex.match(host)
1✔
537
        if vhost_match and vhost_match.group("bucket"):
1✔
538
            bucket_name = vhost_match.group("bucket") or None
1✔
539
            split = path.split("/", maxsplit=1)
1✔
540
            if len(split) > 1 and split[1]:
1✔
541
                object_key = split[1]
1✔
542
    else:
543
        path_without_params = path.partition("?")[0]
1✔
544
        split = path_without_params.split("/", maxsplit=2)
1✔
545
        bucket_name = split[1] or None
1✔
546
        if len(split) > 2:
1✔
547
            object_key = split[2]
1✔
548

549
    return bucket_name, object_key
1✔
550

551

552
def normalize_bucket_name(bucket_name):
1✔
553
    bucket_name = bucket_name or ""
1✔
554
    bucket_name = bucket_name.lower()
1✔
555
    return bucket_name
1✔
556

557

558
def get_bucket_and_key_from_s3_uri(s3_uri: str) -> Tuple[str, str]:
1✔
559
    """
560
    Extracts the bucket name and key from s3 uri
561
    """
562
    output_bucket, _, output_key = s3_uri.removeprefix("s3://").partition("/")
1✔
563
    return output_bucket, output_key
1✔
564

565

566
def get_bucket_and_key_from_presign_url(presign_url: str) -> Tuple[str, str]:
1✔
567
    """
568
    Extracts the bucket name and key from s3 presign url
569
    """
570
    parsed_url = urlparser.urlparse(presign_url)
1✔
571
    bucket = parsed_url.path.split("/")[1]
1✔
572
    key = "/".join(parsed_url.path.split("/")[2:]).split("?")[0]
1✔
573
    return bucket, key
1✔
574

575

576
def _create_invalid_argument_exc(
1✔
577
    message: Union[str, None], name: str, value: str, host_id: str = None
578
) -> InvalidArgument:
579
    ex = InvalidArgument(message)
1✔
580
    ex.ArgumentName = name
1✔
581
    ex.ArgumentValue = value
1✔
582
    if host_id:
1✔
583
        ex.HostId = host_id
1✔
584
    return ex
1✔
585

586

587
def capitalize_header_name_from_snake_case(header_name: str) -> str:
1✔
588
    return "-".join([part.capitalize() for part in header_name.split("-")])
1✔
589

590

591
def get_kms_key_arn(kms_key: str, account_id: str, bucket_region: str) -> Optional[str]:
1✔
592
    """
593
    In S3, the KMS key can be passed as a KeyId or a KeyArn. This method allows to always get the KeyArn from either.
594
    It can also validate if the key is in the same region, and raise an exception.
595
    :param kms_key: the KMS key id or ARN
596
    :param account_id: the bucket account id
597
    :param bucket_region: the bucket region
598
    :raise KMS.NotFoundException if the key is not in the same region
599
    :return: the key ARN if found and enabled
600
    """
601
    if not kms_key:
1✔
602
        return None
1✔
603
    try:
1✔
604
        parsed_arn = parse_arn(kms_key)
1✔
605
        key_region = parsed_arn["region"]
1✔
606
        # the KMS key should be in the same region as the bucket, we can raise an exception without calling KMS
607
        if bucket_region and key_region != bucket_region:
1✔
608
            raise CommonServiceException(
1✔
609
                code="KMS.NotFoundException", message=f"Invalid arn {key_region}"
610
            )
611

612
    except InvalidArnException:
1✔
613
        # if it fails, the passed ID is a UUID with no region data
614
        key_id = kms_key
1✔
615
        # recreate the ARN manually with the bucket region and bucket owner
616
        # if the KMS key is cross-account, user should provide an ARN and not a KeyId
617
        kms_key = arns.kms_key_arn(key_id=key_id, account_id=account_id, region_name=bucket_region)
1✔
618

619
    return kms_key
1✔
620

621

622
# TODO: replace Any by a replacement for S3Bucket, some kind of defined type?
623
def validate_kms_key_id(kms_key: str, bucket: Any) -> None:
1✔
624
    """
625
    Validate that the KMS key used to encrypt the object is valid
626
    :param kms_key: the KMS key id or ARN
627
    :param bucket: the targeted bucket
628
    :raise KMS.DisabledException if the key is disabled
629
    :raise KMS.NotFoundException if the key is not in the same region or does not exist
630
    :return: the key ARN if found and enabled
631
    """
632
    if hasattr(bucket, "region_name"):
1✔
633
        bucket_region = bucket.region_name
×
634
    else:
635
        bucket_region = bucket.bucket_region
1✔
636

637
    if hasattr(bucket, "account_id"):
1✔
638
        bucket_account_id = bucket.account_id
×
639
    else:
640
        bucket_account_id = bucket.bucket_account_id
1✔
641

642
    kms_key_arn = get_kms_key_arn(kms_key, bucket_account_id, bucket_region)
1✔
643

644
    # the KMS key should be in the same region as the bucket, create the client in the bucket region
645
    kms_client = connect_to(region_name=bucket_region).kms
1✔
646
    try:
1✔
647
        key = kms_client.describe_key(KeyId=kms_key_arn)
1✔
648
        if not key["KeyMetadata"]["Enabled"]:
1✔
649
            if key["KeyMetadata"]["KeyState"] == "PendingDeletion":
1✔
650
                raise CommonServiceException(
1✔
651
                    code="KMS.KMSInvalidStateException",
652
                    message=f"{key['KeyMetadata']['Arn']} is pending deletion.",
653
                )
654
            raise CommonServiceException(
1✔
655
                code="KMS.DisabledException", message=f"{key['KeyMetadata']['Arn']} is disabled."
656
            )
657

658
    except ClientError as e:
1✔
659
        if e.response["Error"]["Code"] == "NotFoundException":
1✔
660
            raise CommonServiceException(
1✔
661
                code="KMS.NotFoundException", message=e.response["Error"]["Message"]
662
            )
663
        raise
×
664

665

666
def create_s3_kms_managed_key_for_region(account_id: str, region_name: str) -> SSEKMSKeyId:
1✔
667
    kms_client = connect_to(aws_access_key_id=account_id, region_name=region_name).kms
1✔
668
    key = kms_client.create_key(
1✔
669
        Description="Default key that protects my S3 objects when no other key is defined"
670
    )
671

672
    return key["KeyMetadata"]["Arn"]
1✔
673

674

675
def rfc_1123_datetime(src: datetime.datetime) -> str:
1✔
676
    return src.strftime(RFC1123)
1✔
677

678

679
def str_to_rfc_1123_datetime(value: str) -> datetime.datetime:
1✔
680
    return datetime.datetime.strptime(value, RFC1123).replace(tzinfo=_gmt_zone_info)
1✔
681

682

683
def add_expiration_days_to_datetime(user_datatime: datetime.datetime, exp_days: int) -> str:
1✔
684
    """
685
    This adds expiration days to a datetime, rounding to the next day at midnight UTC.
686
    :param user_datatime: datetime object
687
    :param exp_days: provided days
688
    :return: return a datetime object, rounded to midnight, in string formatted to rfc_1123
689
    """
690
    rounded_datetime = user_datatime.replace(
1✔
691
        hour=0, minute=0, second=0, microsecond=0
692
    ) + datetime.timedelta(days=exp_days + 1)
693

694
    return rfc_1123_datetime(rounded_datetime)
1✔
695

696

697
def serialize_expiration_header(
1✔
698
    rule_id: str, lifecycle_exp: LifecycleExpiration, last_modified: datetime.datetime
699
):
700
    if exp_days := lifecycle_exp.get("Days"):
1✔
701
        # AWS round to the next day at midnight UTC
702
        exp_date = add_expiration_days_to_datetime(last_modified, exp_days)
1✔
703
    else:
704
        exp_date = rfc_1123_datetime(lifecycle_exp["Date"])
1✔
705

706
    return f'expiry-date="{exp_date}", rule-id="{rule_id}"'
1✔
707

708

709
def get_lifecycle_rule_from_object(
1✔
710
    lifecycle_conf_rules: LifecycleRules,
711
    object_key: ObjectKey,
712
    size: ObjectSize,
713
    object_tags: dict[str, str],
714
) -> LifecycleRule:
715
    for rule in lifecycle_conf_rules:
1✔
716
        if not (expiration := rule.get("Expiration")) or "ExpiredObjectDeleteMarker" in expiration:
1✔
717
            continue
1✔
718

719
        if not (rule_filter := rule.get("Filter")):
1✔
720
            return rule
1✔
721

722
        if and_rules := rule_filter.get("And"):
1✔
723
            if all(
1✔
724
                _match_lifecycle_filter(key, value, object_key, size, object_tags)
725
                for key, value in and_rules.items()
726
            ):
727
                return rule
×
728

729
        if any(
1✔
730
            _match_lifecycle_filter(key, value, object_key, size, object_tags)
731
            for key, value in rule_filter.items()
732
        ):
733
            # after validation, we can only one of `Prefix`, `Tag`, `ObjectSizeGreaterThan` or `ObjectSizeLessThan` in
734
            # the dict. Instead of manually checking, we can iterate of the only key and try to match it
735
            return rule
1✔
736

737

738
def _match_lifecycle_filter(
1✔
739
    filter_key: str,
740
    filter_value: str | int | dict[str, str],
741
    object_key: ObjectKey,
742
    size: ObjectSize,
743
    object_tags: dict[str, str],
744
):
745
    match filter_key:
1✔
746
        case "Prefix":
1✔
747
            return object_key.startswith(filter_value)
1✔
748
        case "Tag":
1✔
749
            return object_tags and object_tags.get(filter_value.get("Key")) == filter_value.get(
1✔
750
                "Value"
751
            )
752
        case "ObjectSizeGreaterThan":
1✔
753
            return size > filter_value
1✔
754
        case "ObjectSizeLessThan":
1✔
755
            return size < filter_value
1✔
756
        case "Tags":  # this is inside the `And` field
1✔
757
            return object_tags and all(
1✔
758
                object_tags.get(tag.get("Key")) == tag.get("Value") for tag in filter_value
759
            )
760

761

762
def parse_expiration_header(
1✔
763
    expiration_header: str,
764
) -> tuple[Optional[datetime.datetime], Optional[str]]:
765
    try:
1✔
766
        header_values = dict(
1✔
767
            (p.strip('"') for p in v.split("=")) for v in expiration_header.split('", ')
768
        )
769
        expiration_date = str_to_rfc_1123_datetime(header_values["expiry-date"])
1✔
770
        return expiration_date, header_values["rule-id"]
1✔
771

772
    except (IndexError, ValueError, KeyError):
1✔
773
        return None, None
1✔
774

775

776
def validate_dict_fields(data: dict, required_fields: set, optional_fields: set = None):
1✔
777
    """
778
    Validate whether the `data` dict contains at least the required fields and not more than the union of the required
779
    and optional fields
780
    TODO: we could pass the TypedDict to also use its required/optional properties, but it could be sensitive to
781
     mistake/changes in the specs and not always right
782
    :param data: the dict we want to validate
783
    :param required_fields: a set containing the required fields
784
    :param optional_fields: a set containing the optional fields
785
    :return: bool, whether the dict is valid or not
786
    """
787
    if optional_fields is None:
1✔
788
        optional_fields = set()
1✔
789
    return (set_fields := set(data)) >= required_fields and set_fields <= (
1✔
790
        required_fields | optional_fields
791
    )
792

793

794
def parse_tagging_header(tagging_header: TaggingHeader) -> dict:
1✔
795
    try:
1✔
796
        parsed_tags = urlparser.parse_qs(tagging_header, keep_blank_values=True)
1✔
797
        tags: dict[str, str] = {}
1✔
798
        for key, val in parsed_tags.items():
1✔
799
            if len(val) != 1 or not TAG_REGEX.match(key) or not TAG_REGEX.match(val[0]):
1✔
800
                raise InvalidArgument(
1✔
801
                    "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.",
802
                    ArgumentName="x-amz-tagging",
803
                    ArgumentValue=tagging_header,
804
                )
805
            elif key.startswith("aws:"):
1✔
806
                raise
×
807
            tags[key] = val[0]
1✔
808
        return tags
1✔
809

810
    except ValueError:
1✔
811
        raise InvalidArgument(
×
812
            "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.",
813
            ArgumentName="x-amz-tagging",
814
            ArgumentValue=tagging_header,
815
        )
816

817

818
def validate_tag_set(tag_set: TagSet, type_set: Literal["bucket", "object"] = "bucket"):
1✔
819
    keys = set()
1✔
820
    for tag in tag_set:
1✔
821
        if set(tag) != {"Key", "Value"}:
1✔
822
            raise MalformedXML()
×
823

824
        key = tag["Key"]
1✔
825
        if key in keys:
1✔
826
            raise InvalidTag(
1✔
827
                "Cannot provide multiple Tags with the same key",
828
                TagKey=key,
829
            )
830

831
        if key.startswith("aws:"):
1✔
832
            if type_set == "bucket":
1✔
833
                message = "System tags cannot be added/updated by requester"
1✔
834
            else:
835
                message = "Your TagKey cannot be prefixed with aws:"
1✔
836
            raise InvalidTag(
1✔
837
                message,
838
                TagKey=key,
839
            )
840

841
        if not TAG_REGEX.match(key):
1✔
842
            raise InvalidTag(
1✔
843
                "The TagKey you have provided is invalid",
844
                TagKey=key,
845
            )
846
        elif not TAG_REGEX.match(tag["Value"]):
1✔
847
            raise InvalidTag(
1✔
848
                "The TagValue you have provided is invalid", TagKey=key, TagValue=tag["Value"]
849
            )
850

851
        keys.add(key)
1✔
852

853

854
def get_unique_key_id(
1✔
855
    bucket: BucketName, object_key: ObjectKey, version_id: ObjectVersionId
856
) -> str:
857
    return f"{bucket}/{object_key}/{version_id or 'null'}"
1✔
858

859

860
def get_retention_from_now(days: int = None, years: int = None) -> datetime.datetime:
1✔
861
    """
862
    This calculates a retention date from now, adding days or years to it
863
    :param days: provided days
864
    :param years: provided years, exclusive with days
865
    :return: return a datetime object
866
    """
867
    if not days and not years:
1✔
868
        raise ValueError("Either 'days' or 'years' needs to be provided")
×
869
    now = datetime.datetime.now(tz=_gmt_zone_info)
1✔
870
    if days:
1✔
871
        retention = now + datetime.timedelta(days=days)
1✔
872
    else:
873
        retention = now.replace(year=now.year + years)
×
874

875
    return retention
1✔
876

877

878
def get_failed_precondition_copy_source(
1✔
879
    request: CopyObjectRequest, last_modified: datetime.datetime, etag: ETag
880
) -> Optional[str]:
881
    """
882
    Validate if the source object LastModified and ETag matches a precondition, and if it does, return the failed
883
    precondition
884
    # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
885
    :param request: the CopyObjectRequest
886
    :param last_modified: source object LastModified
887
    :param etag: source object ETag
888
    :return str: the failed precondition to raise
889
    """
890
    if (cs_if_match := request.get("CopySourceIfMatch")) and etag.strip('"') != cs_if_match.strip(
1✔
891
        '"'
892
    ):
893
        return "x-amz-copy-source-If-Match"
1✔
894

895
    elif (
1✔
896
        cs_if_unmodified_since := request.get("CopySourceIfUnmodifiedSince")
897
    ) and last_modified > cs_if_unmodified_since:
898
        return "x-amz-copy-source-If-Unmodified-Since"
1✔
899

900
    elif (cs_if_none_match := request.get("CopySourceIfNoneMatch")) and etag.strip(
1✔
901
        '"'
902
    ) == cs_if_none_match.strip('"'):
903
        return "x-amz-copy-source-If-None-Match"
1✔
904

905
    elif (
1✔
906
        cs_if_modified_since := request.get("CopySourceIfModifiedSince")
907
    ) and last_modified < cs_if_modified_since < datetime.datetime.now(tz=_gmt_zone_info):
908
        return "x-amz-copy-source-If-Modified-Since"
1✔
909

910

911
def validate_failed_precondition(
1✔
912
    request: GetObjectRequest | HeadObjectRequest, last_modified: datetime.datetime, etag: ETag
913
) -> None:
914
    """
915
    Validate if the object LastModified and ETag matches a precondition, and if it does, return the failed
916
    precondition
917
    :param request: the GetObjectRequest or HeadObjectRequest
918
    :param last_modified: S3 object LastModified
919
    :param etag: S3 object ETag
920
    :raises PreconditionFailed
921
    :raises NotModified, 304 with an empty body
922
    """
923
    precondition_failed = None
1✔
924
    # last_modified needs to be rounded to a second so that strict equality can be enforced from a RFC1123 header
925
    last_modified = last_modified.replace(microsecond=0)
1✔
926
    if (if_match := request.get("IfMatch")) and etag != if_match.strip('"'):
1✔
927
        precondition_failed = "If-Match"
1✔
928

929
    elif (
1✔
930
        if_unmodified_since := request.get("IfUnmodifiedSince")
931
    ) and last_modified > if_unmodified_since:
932
        precondition_failed = "If-Unmodified-Since"
1✔
933

934
    if precondition_failed:
1✔
935
        raise PreconditionFailed(
1✔
936
            "At least one of the pre-conditions you specified did not hold",
937
            Condition=precondition_failed,
938
        )
939

940
    if ((if_none_match := request.get("IfNoneMatch")) and etag == if_none_match.strip('"')) or (
1✔
941
        (if_modified_since := request.get("IfModifiedSince"))
942
        and last_modified <= if_modified_since < datetime.datetime.now(tz=_gmt_zone_info)
943
    ):
944
        raise CommonServiceException(
1✔
945
            message="Not Modified",
946
            code="NotModified",
947
            status_code=304,
948
        )
949

950

951
def get_canned_acl(
1✔
952
    canned_acl: BucketCannedACL | ObjectCannedACL, owner: Owner
953
) -> AccessControlPolicy:
954
    """
955
    Return the proper Owner and Grants from a CannedACL
956
    See https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
957
    :param canned_acl: an S3 CannedACL
958
    :param owner: the current owner of the bucket or object
959
    :return: an AccessControlPolicy containing the Grants and Owner
960
    """
961
    owner_grantee = Grantee(**owner, Type=GranteeType.CanonicalUser)
1✔
962
    grants = [Grant(Grantee=owner_grantee, Permission=Permission.FULL_CONTROL)]
1✔
963

964
    match canned_acl:
1✔
965
        case ObjectCannedACL.private:
1✔
966
            pass  # no other permissions
1✔
967
        case ObjectCannedACL.public_read:
1✔
968
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ))
1✔
969

970
        case ObjectCannedACL.public_read_write:
1✔
971
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ))
1✔
972
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.WRITE))
1✔
973
        case ObjectCannedACL.authenticated_read:
×
974
            grants.append(
×
975
                Grant(Grantee=AUTHENTICATED_USERS_ACL_GRANTEE, Permission=Permission.READ)
976
            )
977
        case ObjectCannedACL.bucket_owner_read:
×
978
            pass  # TODO: bucket owner ACL
×
979
        case ObjectCannedACL.bucket_owner_full_control:
×
980
            pass  # TODO: bucket owner ACL
×
981
        case ObjectCannedACL.aws_exec_read:
×
982
            pass  # TODO: bucket owner, EC2 Read
×
983
        case BucketCannedACL.log_delivery_write:
×
984
            grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.READ_ACP))
×
985
            grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.WRITE))
×
986

987
    return AccessControlPolicy(Owner=owner, Grants=grants)
1✔
988

989

990
def create_redirect_for_post_request(
1✔
991
    base_redirect: str, bucket: BucketName, object_key: ObjectKey, etag: ETag
992
):
993
    """
994
    POST requests can redirect if successful. It will take the URL provided and append query string parameters
995
    (key, bucket and ETag). It needs to be a full URL.
996
    :param base_redirect: the URL provided for redirection
997
    :param bucket: bucket name
998
    :param object_key: object key
999
    :param etag: key ETag
1000
    :return: the URL provided with the new appended query string parameters
1001
    """
1002
    parts = urlparser.urlparse(base_redirect)
1✔
1003
    if not parts.netloc:
1✔
1004
        raise ValueError("The provided URL is not valid")
1✔
1005
    queryargs = urlparser.parse_qs(parts.query)
1✔
1006
    queryargs["key"] = [object_key]
1✔
1007
    queryargs["bucket"] = [bucket]
1✔
1008
    queryargs["etag"] = [etag]
1✔
1009
    redirect_queryargs = urlparser.urlencode(queryargs, doseq=True)
1✔
1010
    newparts = (
1✔
1011
        parts.scheme,
1012
        parts.netloc,
1013
        parts.path,
1014
        parts.params,
1015
        redirect_queryargs,
1016
        parts.fragment,
1017
    )
1018
    return urlparser.urlunparse(newparts)
1✔
1019

1020

1021
def parse_post_object_tagging_xml(tagging: str) -> Optional[dict]:
1✔
1022
    try:
1✔
1023
        tag_set = {}
1✔
1024
        tags = xmltodict.parse(tagging)
1✔
1025
        xml_tags = tags.get("Tagging", {}).get("TagSet", {}).get("Tag", [])
1✔
1026
        if not xml_tags:
1✔
1027
            # if the Tagging does not respect the schema, just return
1028
            return
1✔
1029
        if not isinstance(xml_tags, list):
1✔
1030
            xml_tags = [xml_tags]
1✔
1031
        for tag in xml_tags:
1✔
1032
            tag_set[tag["Key"]] = tag["Value"]
1✔
1033

1034
        return tag_set
1✔
1035

1036
    except Exception:
1✔
1037
        raise MalformedXML()
1✔
1038

1039

1040
def generate_safe_version_id() -> str:
1✔
1041
    # the safe b64 encoding is inspired by the stdlib base64.urlsafe_b64encode
1042
    # and also using stdlib secrets.token_urlsafe, but with a different alphabet adapted for S3
1043
    # VersionId cannot have `-` in it, as it fails in XML
1044
    tok = token_bytes(24)
1✔
1045
    return (
1✔
1046
        base64.b64encode(tok)
1047
        .translate(_version_id_safe_encode_translation)
1048
        .rstrip(b"=")
1049
        .decode("ascii")
1050
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc