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

localstack / localstack / 311e4cb7-85c6-4815-9a05-f76a2f16b041

27 Jan 2025 05:40PM UTC coverage: 86.9% (-0.004%) from 86.904%
311e4cb7-85c6-4815-9a05-f76a2f16b041

push

circleci

web-flow
Fix: validate schedule_expression for EventBridge Scheduler (#12191)

15 of 15 new or added lines in 1 file covered. (100.0%)

127 existing lines in 14 files now uncovered.

61173 of 70395 relevant lines covered (86.9%)

0.87 hits per line

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

91.61
/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 IO, 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.constants import (
1✔
58
    ALL_USERS_ACL_GRANTEE,
59
    AUTHENTICATED_USERS_ACL_GRANTEE,
60
    CHECKSUM_ALGORITHMS,
61
    LOG_DELIVERY_ACL_GRANTEE,
62
    S3_CHUNK_SIZE,
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
    checksum_crc32,
73
    checksum_crc32c,
74
    hash_sha1,
75
    hash_sha256,
76
    is_base64,
77
    to_bytes,
78
    to_str,
79
)
80
from localstack.utils.urls import localstack_host
1✔
81

82
LOG = logging.getLogger(__name__)
1✔
83

84
BUCKET_NAME_REGEX = (
1✔
85
    r"(?=^.{3,63}$)(?!^(\d+\.)+\d+$)"
86
    + 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])$)"
87
)
88

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

91

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

96
_s3_virtual_host_regex = re.compile(S3_VIRTUAL_HOSTNAME_REGEX)
1✔
97

98

99
RFC1123 = "%a, %d %b %Y %H:%M:%S GMT"
1✔
100
_gmt_zone_info = ZoneInfo("GMT")
1✔
101

102
_version_id_safe_encode_translation = bytes.maketrans(b"+/", b"._")
1✔
103

104

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

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

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

132

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

146

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

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

170
    return src_bucket, src_key, src_version_id
1✔
171

172

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

179
    def digest(self) -> bytes: ...
1✔
180

181
    def update(self, value: bytes): ...
1✔
182

183

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

193
    if len(checksum_algorithm) > 1:
1✔
194
        raise InvalidRequest(
1✔
195
            "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed."
196
        )
197

198
    return checksum_algorithm[0]
1✔
199

200

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

210
    if len(checksum_algorithm) > 1:
1✔
211
        raise InvalidRequest(
×
212
            "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed."
213
        )
214

215
    return checksum_algorithm[0]
1✔
216

217

218
def get_s3_checksum(algorithm) -> ChecksumHash:
1✔
219
    match algorithm:
1✔
220
        case ChecksumAlgorithm.CRC32:
1✔
221
            return S3CRC32Checksum()
1✔
222

223
        case ChecksumAlgorithm.CRC32C:
1✔
224
            from botocore.httpchecksum import CrtCrc32cChecksum
1✔
225

226
            return CrtCrc32cChecksum()
1✔
227

228
        case ChecksumAlgorithm.CRC64NVME:
1✔
229
            from botocore.httpchecksum import CrtCrc64NvmeChecksum
1✔
230

231
            return CrtCrc64NvmeChecksum()
1✔
232

233
        case ChecksumAlgorithm.SHA1:
1✔
234
            return hashlib.sha1(usedforsecurity=False)
1✔
235

236
        case ChecksumAlgorithm.SHA256:
1✔
237
            return hashlib.sha256(usedforsecurity=False)
1✔
238

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

243

244
class S3CRC32Checksum:
1✔
245
    """Implements a unified way of using zlib.crc32 compatible with hashlib.sha and botocore CrtCrc32cChecksum"""
246

247
    __slots__ = ["checksum"]
1✔
248

249
    def __init__(self):
1✔
250
        self.checksum = zlib.crc32(b"")
1✔
251

252
    def update(self, value: bytes):
1✔
253
        self.checksum = zlib.crc32(value, self.checksum)
1✔
254

255
    def digest(self) -> bytes:
1✔
256
        return self.checksum.to_bytes(4, "big")
1✔
257

258

259
class ObjectRange(NamedTuple):
1✔
260
    """
261
    NamedTuple representing a parsed Range header with the requested S3 object size
262
    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
263
    """
264

265
    content_range: str  # the original Range header
1✔
266
    content_length: int  # the full requested object size
1✔
267
    begin: int  # the start of range
1✔
268
    end: int  # the end of the end
1✔
269

270

271
def parse_range_header(range_header: str, object_size: int) -> ObjectRange | None:
1✔
272
    """
273
    Takes a Range header, and returns a dataclass containing the necessary information to return only a slice of an
274
    S3 object. If the range header is invalid, we return None so that the request is treated as a regular request.
275
    :param range_header: a Range header
276
    :param object_size: the requested S3 object total size
277
    :return: ObjectRange or None if the Range header is invalid
278
    """
279
    last = object_size - 1
1✔
280
    try:
1✔
281
        _, rspec = range_header.split("=")
1✔
282
    except ValueError:
1✔
283
        return None
1✔
284
    if "," in rspec:
1✔
285
        return None
1✔
286

287
    try:
1✔
288
        begin, end = [int(i) if i else None for i in rspec.split("-")]
1✔
289
    except ValueError:
1✔
290
        # if we can't parse the Range header, S3 just treat the request as a non-range request
291
        return None
1✔
292

293
    if (begin is None and end == 0) or (begin is not None and begin > last):
1✔
294
        raise InvalidRange(
1✔
295
            "The requested range is not satisfiable",
296
            ActualObjectSize=str(object_size),
297
            RangeRequested=range_header,
298
        )
299

300
    if begin is not None:  # byte range
1✔
301
        end = last if end is None else min(end, last)
1✔
302
    elif end is not None:  # suffix byte range
1✔
303
        begin = object_size - min(end, object_size)
1✔
304
        end = last
1✔
305
    else:
306
        # Treat as non-range request
307
        return None
1✔
308

309
    if begin > min(end, last):
1✔
310
        # Treat as non-range request if after the logic is applied
311
        return None
1✔
312

313
    return ObjectRange(
1✔
314
        content_range=f"bytes {begin}-{end}/{object_size}",
315
        content_length=end - begin + 1,
316
        begin=begin,
317
        end=end,
318
    )
319

320

321
def parse_copy_source_range_header(copy_source_range: str, object_size: int) -> ObjectRange:
1✔
322
    """
323
    Takes a CopySourceRange parameter, and returns a dataclass containing the necessary information to return only a slice of an
324
    S3 object. The validation is much stricter than `parse_range_header`
325
    :param copy_source_range: a CopySourceRange parameter for UploadCopyPart
326
    :param object_size: the requested S3 object total size
327
    :raises InvalidArgument if the CopySourceRanger parameter does not follow validation
328
    :return: ObjectRange
329
    """
330
    last = object_size - 1
1✔
331
    try:
1✔
332
        _, rspec = copy_source_range.split("=")
1✔
333
    except ValueError:
1✔
334
        raise InvalidArgument(
1✔
335
            "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",
336
            ArgumentName="x-amz-copy-source-range",
337
            ArgumentValue=copy_source_range,
338
        )
339
    if "," in rspec:
1✔
340
        raise InvalidArgument(
1✔
341
            "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",
342
            ArgumentName="x-amz-copy-source-range",
343
            ArgumentValue=copy_source_range,
344
        )
345

346
    try:
1✔
347
        begin, end = [int(i) if i else None for i in rspec.split("-")]
1✔
348
    except ValueError:
1✔
349
        # if we can't parse the Range header, S3 just treat the request as a non-range request
350
        raise InvalidArgument(
1✔
351
            "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",
352
            ArgumentName="x-amz-copy-source-range",
353
            ArgumentValue=copy_source_range,
354
        )
355

356
    if begin is None or end is None or begin > end:
1✔
357
        raise InvalidArgument(
1✔
358
            "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",
359
            ArgumentName="x-amz-copy-source-range",
360
            ArgumentValue=copy_source_range,
361
        )
362

363
    if begin > last:
1✔
364
        # Treat as non-range request if after the logic is applied
365
        raise InvalidRequest(
1✔
366
            "The specified copy range is invalid for the source object size",
367
        )
368
    elif end > last:
1✔
369
        raise InvalidArgument(
1✔
370
            f"Range specified is not valid for source object of size: {object_size}",
371
            ArgumentName="x-amz-copy-source-range",
372
            ArgumentValue=copy_source_range,
373
        )
374

375
    return ObjectRange(
1✔
376
        content_range=f"bytes {begin}-{end}/{object_size}",
377
        content_length=end - begin + 1,
378
        begin=begin,
379
        end=end,
380
    )
381

382

383
def get_full_default_bucket_location(bucket_name: BucketName) -> str:
1✔
384
    host_definition = localstack_host()
1✔
385
    if host_definition.host != constants.LOCALHOST_HOSTNAME:
1✔
386
        # the user has customised their LocalStack hostname, and may not support subdomains.
387
        # Return the location in path form.
388
        return f"{config.get_protocol()}://{host_definition.host_and_port()}/{bucket_name}/"
1✔
389
    else:
390
        return f"{config.get_protocol()}://{bucket_name}.s3.{host_definition.host_and_port()}/"
1✔
391

392

393
def get_object_checksum_for_algorithm(checksum_algorithm: str, data: bytes) -> str:
1✔
394
    match checksum_algorithm:
1✔
395
        case ChecksumAlgorithm.CRC32:
1✔
396
            return checksum_crc32(data)
1✔
397

398
        case ChecksumAlgorithm.CRC32C:
1✔
399
            return checksum_crc32c(data)
1✔
400

401
        case ChecksumAlgorithm.SHA1:
1✔
402
            return hash_sha1(data)
1✔
403

404
        case ChecksumAlgorithm.SHA256:
1✔
405
            return hash_sha256(data)
1✔
406

407
        case _:
1✔
408
            # TODO: check proper error? for now validated client side, need to check server response
409
            raise InvalidRequest("The value specified in the x-amz-trailer header is not supported")
1✔
410

411

412
def verify_checksum(checksum_algorithm: str, data: bytes, request: Dict):
1✔
413
    # TODO: you don't have to specify the checksum algorithm
414
    # you can use only the checksum-{algorithm-type} header
415
    # https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
416
    key = f"Checksum{checksum_algorithm.upper()}"
1✔
417
    # TODO: is there a message if the header is missing?
418
    checksum = request.get(key)
1✔
419
    calculated_checksum = get_object_checksum_for_algorithm(checksum_algorithm, data)
1✔
420

421
    if calculated_checksum != checksum:
1✔
422
        raise InvalidRequest(
1✔
423
            f"Value for x-amz-checksum-{checksum_algorithm.lower()} header is invalid."
424
        )
425

426

427
def etag_to_base_64_content_md5(etag: ETag) -> str:
1✔
428
    """
429
    Convert an ETag, representing a MD5 hexdigest (might be quoted), to its base64 encoded representation
430
    :param etag: an ETag, might be quoted
431
    :return: the base64 value
432
    """
433
    # get the bytes digest from the hexdigest
434
    byte_digest = codecs.decode(to_bytes(etag.strip('"')), "hex")
1✔
435
    return to_str(base64.b64encode(byte_digest))
1✔
436

437

438
def base_64_content_md5_to_etag(content_md5: ContentMD5) -> str | None:
1✔
439
    """
440
    Convert a ContentMD5 header, representing a base64 encoded representation of a MD5 binary digest to its ETag value,
441
    hex encoded
442
    :param content_md5: a ContentMD5 header, base64 encoded
443
    :return: the ETag value, hex coded MD5 digest, or None if the input is not valid b64 or the representation of a MD5
444
    hash
445
    """
446
    if not is_base64(content_md5):
1✔
447
        return None
1✔
448
    # get the hexdigest from the bytes digest
449
    byte_digest = base64.b64decode(content_md5)
1✔
450
    hex_digest = to_str(codecs.encode(byte_digest, "hex"))
1✔
451
    if len(hex_digest) != 32:
1✔
452
        return None
1✔
453

454
    return hex_digest
1✔
455

456

457
def decode_aws_chunked_object(
1✔
458
    stream: IO[bytes],
459
    buffer: IO[bytes],
460
    content_length: int,
461
) -> IO[bytes]:
462
    """
463
    Decode the incoming stream encoded in `aws-chunked` format into the provided buffer
464
    :param stream: the original stream to read, encoded in the `aws-chunked` format
465
    :param buffer: the buffer where we set the decoded data
466
    :param content_length: the total maximum length of the original stream, we stop decoding after that
467
    :return: the provided buffer
468
    """
469
    buffer.seek(0)
×
UNCOV
470
    buffer.truncate()
×
471
    written = 0
×
472
    while written < content_length:
×
473
        line = stream.readline()
×
474
        chunk_length = int(line.split(b";")[0], 16)
×
475

476
        while chunk_length > 0:
×
477
            amount = min(chunk_length, S3_CHUNK_SIZE)
×
478
            data = stream.read(amount)
×
UNCOV
479
            buffer.write(data)
×
480

481
            real_amount = len(data)
×
UNCOV
482
            chunk_length -= real_amount
×
483
            written += real_amount
×
484

485
        # remove trailing \r\n
UNCOV
486
        stream.read(2)
×
487

UNCOV
488
    return buffer
×
489

490

491
def is_presigned_url_request(context: RequestContext) -> bool:
1✔
492
    """
493
    Detects pre-signed URL from query string parameters
494
    Return True if any kind of presigned URL query string parameter is encountered
495
    :param context: the request context from the handler chain
496
    """
497
    # Detecting pre-sign url and checking signature
498
    query_parameters = context.request.args
1✔
499
    return any(p in query_parameters for p in SIGNATURE_V2_PARAMS) or any(
1✔
500
        p in query_parameters for p in SIGNATURE_V4_PARAMS
501
    )
502

503

504
def is_bucket_name_valid(bucket_name: str) -> bool:
1✔
505
    """
506
    ref. https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
507
    """
508
    return True if re.match(BUCKET_NAME_REGEX, bucket_name) else False
1✔
509

510

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

514

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

519

520
def is_valid_canonical_id(canonical_id: str) -> bool:
1✔
521
    """
522
    Validate that the string is a hex string with 64 char
523
    """
524
    try:
1✔
525
        return int(canonical_id, 16) and len(canonical_id) == 64
1✔
526
    except ValueError:
1✔
527
        return False
1✔
528

529

530
def uses_host_addressing(headers: Dict[str, str]) -> str | None:
1✔
531
    """
532
    Determines if the request is targeting S3 with virtual host addressing
533
    :param headers: the request headers
534
    :return: if the request targets S3 with virtual host addressing, returns the bucket name else None
535
    """
536
    host = headers.get("host", "")
1✔
537

538
    # try to extract the bucket from the hostname (the "in" check is a minor optimization, as the regex is very greedy)
539
    if ".s3." in host and (
1✔
540
        (match := _s3_virtual_host_regex.match(host)) and (bucket_name := match.group("bucket"))
541
    ):
542
        return bucket_name
1✔
543

544

545
def get_class_attrs_from_spec_class(spec_class: type[StrEnum]) -> set[str]:
1✔
546
    return {str(spec) for spec in spec_class}
1✔
547

548

549
def get_system_metadata_from_request(request: dict) -> Metadata:
1✔
550
    metadata: Metadata = {}
1✔
551

552
    for system_metadata_field in SYSTEM_METADATA_SETTABLE_HEADERS:
1✔
553
        if field_value := request.get(system_metadata_field):
1✔
554
            metadata[system_metadata_field] = field_value
1✔
555

556
    return metadata
1✔
557

558

559
def forwarded_from_virtual_host_addressed_request(headers: dict[str, str]) -> bool:
1✔
560
    """
561
    Determines if the request was forwarded from a v-host addressing style into a path one
562
    """
563
    # we can assume that the host header we are receiving here is actually the header we originally received
564
    # from the client (because the edge service is forwarding the request in memory)
565
    return S3_VIRTUAL_HOST_FORWARDED_HEADER in headers
1✔
566

567

568
def extract_bucket_name_and_key_from_headers_and_path(
1✔
569
    headers: dict[str, str], path: str
570
) -> tuple[Optional[str], Optional[str]]:
571
    """
572
    Extract the bucket name and the object key from a request headers and path. This works with both virtual host
573
    and path style requests.
574
    :param headers: the request headers, used to get the Host
575
    :param path: the request path
576
    :return: if found, the bucket name and object key
577
    """
578
    bucket_name = None
1✔
579
    object_key = None
1✔
580
    host = headers.get("host", "")
1✔
581
    if ".s3" in host:
1✔
582
        vhost_match = _s3_virtual_host_regex.match(host)
1✔
583
        if vhost_match and vhost_match.group("bucket"):
1✔
584
            bucket_name = vhost_match.group("bucket") or None
1✔
585
            split = path.split("/", maxsplit=1)
1✔
586
            if len(split) > 1 and split[1]:
1✔
587
                object_key = split[1]
1✔
588
    else:
589
        path_without_params = path.partition("?")[0]
1✔
590
        split = path_without_params.split("/", maxsplit=2)
1✔
591
        bucket_name = split[1] or None
1✔
592
        if len(split) > 2:
1✔
593
            object_key = split[2]
1✔
594

595
    return bucket_name, object_key
1✔
596

597

598
def normalize_bucket_name(bucket_name):
1✔
599
    bucket_name = bucket_name or ""
1✔
600
    bucket_name = bucket_name.lower()
1✔
601
    return bucket_name
1✔
602

603

604
def get_bucket_and_key_from_s3_uri(s3_uri: str) -> Tuple[str, str]:
1✔
605
    """
606
    Extracts the bucket name and key from s3 uri
607
    """
608
    output_bucket, _, output_key = s3_uri.removeprefix("s3://").partition("/")
1✔
609
    return output_bucket, output_key
1✔
610

611

612
def get_bucket_and_key_from_presign_url(presign_url: str) -> Tuple[str, str]:
1✔
613
    """
614
    Extracts the bucket name and key from s3 presign url
615
    """
616
    parsed_url = urlparser.urlparse(presign_url)
1✔
617
    bucket = parsed_url.path.split("/")[1]
1✔
618
    key = "/".join(parsed_url.path.split("/")[2:]).split("?")[0]
1✔
619
    return bucket, key
1✔
620

621

622
def _create_invalid_argument_exc(
1✔
623
    message: Union[str, None], name: str, value: str, host_id: str = None
624
) -> InvalidArgument:
625
    ex = InvalidArgument(message)
1✔
626
    ex.ArgumentName = name
1✔
627
    ex.ArgumentValue = value
1✔
628
    if host_id:
1✔
629
        ex.HostId = host_id
1✔
630
    return ex
1✔
631

632

633
def capitalize_header_name_from_snake_case(header_name: str) -> str:
1✔
634
    return "-".join([part.capitalize() for part in header_name.split("-")])
1✔
635

636

637
def get_kms_key_arn(kms_key: str, account_id: str, bucket_region: str) -> Optional[str]:
1✔
638
    """
639
    In S3, the KMS key can be passed as a KeyId or a KeyArn. This method allows to always get the KeyArn from either.
640
    It can also validate if the key is in the same region, and raise an exception.
641
    :param kms_key: the KMS key id or ARN
642
    :param account_id: the bucket account id
643
    :param bucket_region: the bucket region
644
    :raise KMS.NotFoundException if the key is not in the same region
645
    :return: the key ARN if found and enabled
646
    """
647
    if not kms_key:
1✔
648
        return None
1✔
649
    try:
1✔
650
        parsed_arn = parse_arn(kms_key)
1✔
651
        key_region = parsed_arn["region"]
1✔
652
        # the KMS key should be in the same region as the bucket, we can raise an exception without calling KMS
653
        if bucket_region and key_region != bucket_region:
1✔
654
            raise CommonServiceException(
1✔
655
                code="KMS.NotFoundException", message=f"Invalid arn {key_region}"
656
            )
657

658
    except InvalidArnException:
1✔
659
        # if it fails, the passed ID is a UUID with no region data
660
        key_id = kms_key
1✔
661
        # recreate the ARN manually with the bucket region and bucket owner
662
        # if the KMS key is cross-account, user should provide an ARN and not a KeyId
663
        kms_key = arns.kms_key_arn(key_id=key_id, account_id=account_id, region_name=bucket_region)
1✔
664

665
    return kms_key
1✔
666

667

668
# TODO: replace Any by a replacement for S3Bucket, some kind of defined type?
669
def validate_kms_key_id(kms_key: str, bucket: Any) -> None:
1✔
670
    """
671
    Validate that the KMS key used to encrypt the object is valid
672
    :param kms_key: the KMS key id or ARN
673
    :param bucket: the targeted bucket
674
    :raise KMS.DisabledException if the key is disabled
675
    :raise KMS.NotFoundException if the key is not in the same region or does not exist
676
    :return: the key ARN if found and enabled
677
    """
678
    if hasattr(bucket, "region_name"):
1✔
679
        bucket_region = bucket.region_name
×
680
    else:
681
        bucket_region = bucket.bucket_region
1✔
682

683
    if hasattr(bucket, "account_id"):
1✔
UNCOV
684
        bucket_account_id = bucket.account_id
×
685
    else:
686
        bucket_account_id = bucket.bucket_account_id
1✔
687

688
    kms_key_arn = get_kms_key_arn(kms_key, bucket_account_id, bucket_region)
1✔
689

690
    # the KMS key should be in the same region as the bucket, create the client in the bucket region
691
    kms_client = connect_to(region_name=bucket_region).kms
1✔
692
    try:
1✔
693
        key = kms_client.describe_key(KeyId=kms_key_arn)
1✔
694
        if not key["KeyMetadata"]["Enabled"]:
1✔
695
            if key["KeyMetadata"]["KeyState"] == "PendingDeletion":
1✔
696
                raise CommonServiceException(
1✔
697
                    code="KMS.KMSInvalidStateException",
698
                    message=f"{key['KeyMetadata']['Arn']} is pending deletion.",
699
                )
700
            raise CommonServiceException(
1✔
701
                code="KMS.DisabledException", message=f"{key['KeyMetadata']['Arn']} is disabled."
702
            )
703

704
    except ClientError as e:
1✔
705
        if e.response["Error"]["Code"] == "NotFoundException":
1✔
706
            raise CommonServiceException(
1✔
707
                code="KMS.NotFoundException", message=e.response["Error"]["Message"]
708
            )
UNCOV
709
        raise
×
710

711

712
def create_s3_kms_managed_key_for_region(account_id: str, region_name: str) -> SSEKMSKeyId:
1✔
713
    kms_client = connect_to(aws_access_key_id=account_id, region_name=region_name).kms
1✔
714
    key = kms_client.create_key(
1✔
715
        Description="Default key that protects my S3 objects when no other key is defined"
716
    )
717

718
    return key["KeyMetadata"]["Arn"]
1✔
719

720

721
def rfc_1123_datetime(src: datetime.datetime) -> str:
1✔
722
    return src.strftime(RFC1123)
1✔
723

724

725
def str_to_rfc_1123_datetime(value: str) -> datetime.datetime:
1✔
726
    return datetime.datetime.strptime(value, RFC1123).replace(tzinfo=_gmt_zone_info)
1✔
727

728

729
def add_expiration_days_to_datetime(user_datatime: datetime.datetime, exp_days: int) -> str:
1✔
730
    """
731
    This adds expiration days to a datetime, rounding to the next day at midnight UTC.
732
    :param user_datatime: datetime object
733
    :param exp_days: provided days
734
    :return: return a datetime object, rounded to midnight, in string formatted to rfc_1123
735
    """
736
    rounded_datetime = user_datatime.replace(
1✔
737
        hour=0, minute=0, second=0, microsecond=0
738
    ) + datetime.timedelta(days=exp_days + 1)
739

740
    return rfc_1123_datetime(rounded_datetime)
1✔
741

742

743
def serialize_expiration_header(
1✔
744
    rule_id: str, lifecycle_exp: LifecycleExpiration, last_modified: datetime.datetime
745
):
746
    if exp_days := lifecycle_exp.get("Days"):
1✔
747
        # AWS round to the next day at midnight UTC
748
        exp_date = add_expiration_days_to_datetime(last_modified, exp_days)
1✔
749
    else:
750
        exp_date = rfc_1123_datetime(lifecycle_exp["Date"])
1✔
751

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

754

755
def get_lifecycle_rule_from_object(
1✔
756
    lifecycle_conf_rules: LifecycleRules,
757
    object_key: ObjectKey,
758
    size: ObjectSize,
759
    object_tags: dict[str, str],
760
) -> LifecycleRule:
761
    for rule in lifecycle_conf_rules:
1✔
762
        if not (expiration := rule.get("Expiration")) or "ExpiredObjectDeleteMarker" in expiration:
1✔
763
            continue
1✔
764

765
        if not (rule_filter := rule.get("Filter")):
1✔
766
            return rule
1✔
767

768
        if and_rules := rule_filter.get("And"):
1✔
769
            if all(
1✔
770
                _match_lifecycle_filter(key, value, object_key, size, object_tags)
771
                for key, value in and_rules.items()
772
            ):
UNCOV
773
                return rule
×
774

775
        if any(
1✔
776
            _match_lifecycle_filter(key, value, object_key, size, object_tags)
777
            for key, value in rule_filter.items()
778
        ):
779
            # after validation, we can only one of `Prefix`, `Tag`, `ObjectSizeGreaterThan` or `ObjectSizeLessThan` in
780
            # the dict. Instead of manually checking, we can iterate of the only key and try to match it
781
            return rule
1✔
782

783

784
def _match_lifecycle_filter(
1✔
785
    filter_key: str,
786
    filter_value: str | int | dict[str, str],
787
    object_key: ObjectKey,
788
    size: ObjectSize,
789
    object_tags: dict[str, str],
790
):
791
    match filter_key:
1✔
792
        case "Prefix":
1✔
793
            return object_key.startswith(filter_value)
1✔
794
        case "Tag":
1✔
795
            return object_tags and object_tags.get(filter_value.get("Key")) == filter_value.get(
1✔
796
                "Value"
797
            )
798
        case "ObjectSizeGreaterThan":
1✔
799
            return size > filter_value
1✔
800
        case "ObjectSizeLessThan":
1✔
801
            return size < filter_value
1✔
802
        case "Tags":  # this is inside the `And` field
1✔
803
            return object_tags and all(
1✔
804
                object_tags.get(tag.get("Key")) == tag.get("Value") for tag in filter_value
805
            )
806

807

808
def parse_expiration_header(
1✔
809
    expiration_header: str,
810
) -> tuple[Optional[datetime.datetime], Optional[str]]:
811
    try:
1✔
812
        header_values = dict(
1✔
813
            (p.strip('"') for p in v.split("=")) for v in expiration_header.split('", ')
814
        )
815
        expiration_date = str_to_rfc_1123_datetime(header_values["expiry-date"])
1✔
816
        return expiration_date, header_values["rule-id"]
1✔
817

818
    except (IndexError, ValueError, KeyError):
1✔
819
        return None, None
1✔
820

821

822
def validate_dict_fields(data: dict, required_fields: set, optional_fields: set = None):
1✔
823
    """
824
    Validate whether the `data` dict contains at least the required fields and not more than the union of the required
825
    and optional fields
826
    TODO: we could pass the TypedDict to also use its required/optional properties, but it could be sensitive to
827
     mistake/changes in the specs and not always right
828
    :param data: the dict we want to validate
829
    :param required_fields: a set containing the required fields
830
    :param optional_fields: a set containing the optional fields
831
    :return: bool, whether the dict is valid or not
832
    """
833
    if optional_fields is None:
1✔
834
        optional_fields = set()
1✔
835
    return (set_fields := set(data)) >= required_fields and set_fields <= (
1✔
836
        required_fields | optional_fields
837
    )
838

839

840
def parse_tagging_header(tagging_header: TaggingHeader) -> dict:
1✔
841
    try:
1✔
842
        parsed_tags = urlparser.parse_qs(tagging_header, keep_blank_values=True)
1✔
843
        tags: dict[str, str] = {}
1✔
844
        for key, val in parsed_tags.items():
1✔
845
            if len(val) != 1 or not TAG_REGEX.match(key) or not TAG_REGEX.match(val[0]):
1✔
846
                raise InvalidArgument(
1✔
847
                    "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.",
848
                    ArgumentName="x-amz-tagging",
849
                    ArgumentValue=tagging_header,
850
                )
851
            elif key.startswith("aws:"):
1✔
852
                raise
×
853
            tags[key] = val[0]
1✔
854
        return tags
1✔
855

856
    except ValueError:
1✔
UNCOV
857
        raise InvalidArgument(
×
858
            "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.",
859
            ArgumentName="x-amz-tagging",
860
            ArgumentValue=tagging_header,
861
        )
862

863

864
def validate_tag_set(tag_set: TagSet, type_set: Literal["bucket", "object"] = "bucket"):
1✔
865
    keys = set()
1✔
866
    for tag in tag_set:
1✔
867
        if set(tag) != {"Key", "Value"}:
1✔
UNCOV
868
            raise MalformedXML()
×
869

870
        key = tag["Key"]
1✔
871
        if key in keys:
1✔
872
            raise InvalidTag(
1✔
873
                "Cannot provide multiple Tags with the same key",
874
                TagKey=key,
875
            )
876

877
        if key.startswith("aws:"):
1✔
878
            if type_set == "bucket":
1✔
879
                message = "System tags cannot be added/updated by requester"
1✔
880
            else:
881
                message = "Your TagKey cannot be prefixed with aws:"
1✔
882
            raise InvalidTag(
1✔
883
                message,
884
                TagKey=key,
885
            )
886

887
        if not TAG_REGEX.match(key):
1✔
888
            raise InvalidTag(
1✔
889
                "The TagKey you have provided is invalid",
890
                TagKey=key,
891
            )
892
        elif not TAG_REGEX.match(tag["Value"]):
1✔
893
            raise InvalidTag(
1✔
894
                "The TagValue you have provided is invalid", TagKey=key, TagValue=tag["Value"]
895
            )
896

897
        keys.add(key)
1✔
898

899

900
def get_unique_key_id(
1✔
901
    bucket: BucketName, object_key: ObjectKey, version_id: ObjectVersionId
902
) -> str:
903
    return f"{bucket}/{object_key}/{version_id or 'null'}"
1✔
904

905

906
def get_retention_from_now(days: int = None, years: int = None) -> datetime.datetime:
1✔
907
    """
908
    This calculates a retention date from now, adding days or years to it
909
    :param days: provided days
910
    :param years: provided years, exclusive with days
911
    :return: return a datetime object
912
    """
913
    if not days and not years:
1✔
914
        raise ValueError("Either 'days' or 'years' needs to be provided")
×
915
    now = datetime.datetime.now(tz=_gmt_zone_info)
1✔
916
    if days:
1✔
917
        retention = now + datetime.timedelta(days=days)
1✔
918
    else:
UNCOV
919
        retention = now.replace(year=now.year + years)
×
920

921
    return retention
1✔
922

923

924
def get_failed_precondition_copy_source(
1✔
925
    request: CopyObjectRequest, last_modified: datetime.datetime, etag: ETag
926
) -> Optional[str]:
927
    """
928
    Validate if the source object LastModified and ETag matches a precondition, and if it does, return the failed
929
    precondition
930
    # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
931
    :param request: the CopyObjectRequest
932
    :param last_modified: source object LastModified
933
    :param etag: source object ETag
934
    :return str: the failed precondition to raise
935
    """
936
    if (cs_if_match := request.get("CopySourceIfMatch")) and etag.strip('"') != cs_if_match.strip(
1✔
937
        '"'
938
    ):
939
        return "x-amz-copy-source-If-Match"
1✔
940

941
    elif (
1✔
942
        cs_if_unmodified_since := request.get("CopySourceIfUnmodifiedSince")
943
    ) and last_modified > cs_if_unmodified_since:
944
        return "x-amz-copy-source-If-Unmodified-Since"
1✔
945

946
    elif (cs_if_none_match := request.get("CopySourceIfNoneMatch")) and etag.strip(
1✔
947
        '"'
948
    ) == cs_if_none_match.strip('"'):
949
        return "x-amz-copy-source-If-None-Match"
1✔
950

951
    elif (
1✔
952
        cs_if_modified_since := request.get("CopySourceIfModifiedSince")
953
    ) and last_modified < cs_if_modified_since < datetime.datetime.now(tz=_gmt_zone_info):
954
        return "x-amz-copy-source-If-Modified-Since"
1✔
955

956

957
def validate_failed_precondition(
1✔
958
    request: GetObjectRequest | HeadObjectRequest, last_modified: datetime.datetime, etag: ETag
959
) -> None:
960
    """
961
    Validate if the object LastModified and ETag matches a precondition, and if it does, return the failed
962
    precondition
963
    :param request: the GetObjectRequest or HeadObjectRequest
964
    :param last_modified: S3 object LastModified
965
    :param etag: S3 object ETag
966
    :raises PreconditionFailed
967
    :raises NotModified, 304 with an empty body
968
    """
969
    precondition_failed = None
1✔
970
    # last_modified needs to be rounded to a second so that strict equality can be enforced from a RFC1123 header
971
    last_modified = last_modified.replace(microsecond=0)
1✔
972
    if (if_match := request.get("IfMatch")) and etag != if_match.strip('"'):
1✔
973
        precondition_failed = "If-Match"
1✔
974

975
    elif (
1✔
976
        if_unmodified_since := request.get("IfUnmodifiedSince")
977
    ) and last_modified > if_unmodified_since:
978
        precondition_failed = "If-Unmodified-Since"
1✔
979

980
    if precondition_failed:
1✔
981
        raise PreconditionFailed(
1✔
982
            "At least one of the pre-conditions you specified did not hold",
983
            Condition=precondition_failed,
984
        )
985

986
    if ((if_none_match := request.get("IfNoneMatch")) and etag == if_none_match.strip('"')) or (
1✔
987
        (if_modified_since := request.get("IfModifiedSince"))
988
        and last_modified <= if_modified_since < datetime.datetime.now(tz=_gmt_zone_info)
989
    ):
990
        raise CommonServiceException(
1✔
991
            message="Not Modified",
992
            code="NotModified",
993
            status_code=304,
994
        )
995

996

997
def get_canned_acl(
1✔
998
    canned_acl: BucketCannedACL | ObjectCannedACL, owner: Owner
999
) -> AccessControlPolicy:
1000
    """
1001
    Return the proper Owner and Grants from a CannedACL
1002
    See https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
1003
    :param canned_acl: an S3 CannedACL
1004
    :param owner: the current owner of the bucket or object
1005
    :return: an AccessControlPolicy containing the Grants and Owner
1006
    """
1007
    owner_grantee = Grantee(**owner, Type=GranteeType.CanonicalUser)
1✔
1008
    grants = [Grant(Grantee=owner_grantee, Permission=Permission.FULL_CONTROL)]
1✔
1009

1010
    match canned_acl:
1✔
1011
        case ObjectCannedACL.private:
1✔
1012
            pass  # no other permissions
1✔
1013
        case ObjectCannedACL.public_read:
1✔
1014
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ))
1✔
1015

1016
        case ObjectCannedACL.public_read_write:
1✔
1017
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ))
1✔
1018
            grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.WRITE))
1✔
1019
        case ObjectCannedACL.authenticated_read:
×
1020
            grants.append(
×
1021
                Grant(Grantee=AUTHENTICATED_USERS_ACL_GRANTEE, Permission=Permission.READ)
1022
            )
1023
        case ObjectCannedACL.bucket_owner_read:
×
1024
            pass  # TODO: bucket owner ACL
×
1025
        case ObjectCannedACL.bucket_owner_full_control:
×
1026
            pass  # TODO: bucket owner ACL
×
UNCOV
1027
        case ObjectCannedACL.aws_exec_read:
×
UNCOV
1028
            pass  # TODO: bucket owner, EC2 Read
×
UNCOV
1029
        case BucketCannedACL.log_delivery_write:
×
UNCOV
1030
            grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.READ_ACP))
×
UNCOV
1031
            grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.WRITE))
×
1032

1033
    return AccessControlPolicy(Owner=owner, Grants=grants)
1✔
1034

1035

1036
def create_redirect_for_post_request(
1✔
1037
    base_redirect: str, bucket: BucketName, object_key: ObjectKey, etag: ETag
1038
):
1039
    """
1040
    POST requests can redirect if successful. It will take the URL provided and append query string parameters
1041
    (key, bucket and ETag). It needs to be a full URL.
1042
    :param base_redirect: the URL provided for redirection
1043
    :param bucket: bucket name
1044
    :param object_key: object key
1045
    :param etag: key ETag
1046
    :return: the URL provided with the new appended query string parameters
1047
    """
1048
    parts = urlparser.urlparse(base_redirect)
1✔
1049
    if not parts.netloc:
1✔
1050
        raise ValueError("The provided URL is not valid")
1✔
1051
    queryargs = urlparser.parse_qs(parts.query)
1✔
1052
    queryargs["key"] = [object_key]
1✔
1053
    queryargs["bucket"] = [bucket]
1✔
1054
    queryargs["etag"] = [etag]
1✔
1055
    redirect_queryargs = urlparser.urlencode(queryargs, doseq=True)
1✔
1056
    newparts = (
1✔
1057
        parts.scheme,
1058
        parts.netloc,
1059
        parts.path,
1060
        parts.params,
1061
        redirect_queryargs,
1062
        parts.fragment,
1063
    )
1064
    return urlparser.urlunparse(newparts)
1✔
1065

1066

1067
def parse_post_object_tagging_xml(tagging: str) -> Optional[dict]:
1✔
1068
    try:
1✔
1069
        tag_set = {}
1✔
1070
        tags = xmltodict.parse(tagging)
1✔
1071
        xml_tags = tags.get("Tagging", {}).get("TagSet", {}).get("Tag", [])
1✔
1072
        if not xml_tags:
1✔
1073
            # if the Tagging does not respect the schema, just return
1074
            return
1✔
1075
        if not isinstance(xml_tags, list):
1✔
1076
            xml_tags = [xml_tags]
1✔
1077
        for tag in xml_tags:
1✔
1078
            tag_set[tag["Key"]] = tag["Value"]
1✔
1079

1080
        return tag_set
1✔
1081

1082
    except Exception:
1✔
1083
        raise MalformedXML()
1✔
1084

1085

1086
def generate_safe_version_id() -> str:
1✔
1087
    # the safe b64 encoding is inspired by the stdlib base64.urlsafe_b64encode
1088
    # and also using stdlib secrets.token_urlsafe, but with a different alphabet adapted for S3
1089
    # VersionId cannot have `-` in it, as it fails in XML
1090
    tok = token_bytes(24)
1✔
1091
    return (
1✔
1092
        base64.b64encode(tok)
1093
        .translate(_version_id_safe_encode_translation)
1094
        .rstrip(b"=")
1095
        .decode("ascii")
1096
    )
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