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

localstack / localstack / 37ec8a09-4c4f-4634-84b6-f51ce52791ce

03 Feb 2025 03:05PM UTC coverage: 86.93% (+0.02%) from 86.914%
37ec8a09-4c4f-4634-84b6-f51ce52791ce

push

circleci

web-flow
restructure unit tests (#12221)

61428 of 70664 relevant lines covered (86.93%)

0.87 hits per line

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

98.97
/localstack-core/localstack/services/s3/models.py
1
import base64
1✔
2
import hashlib
1✔
3
import logging
1✔
4
from collections import defaultdict
1✔
5
from datetime import datetime
1✔
6
from secrets import token_urlsafe
1✔
7
from typing import Literal, NamedTuple, Optional, Union
1✔
8
from zoneinfo import ZoneInfo
1✔
9

10
from localstack.aws.api import CommonServiceException
1✔
11
from localstack.aws.api.s3 import (
1✔
12
    AccessControlPolicy,
13
    AccountId,
14
    AnalyticsConfiguration,
15
    AnalyticsId,
16
    BadDigest,
17
    BucketAccelerateStatus,
18
    BucketKeyEnabled,
19
    BucketName,
20
    BucketRegion,
21
    BucketVersioningStatus,
22
    ChecksumAlgorithm,
23
    ChecksumType,
24
    CompletedPartList,
25
    CORSConfiguration,
26
    DefaultRetention,
27
    EntityTooSmall,
28
    ETag,
29
    Expiration,
30
    IntelligentTieringConfiguration,
31
    IntelligentTieringId,
32
    InvalidArgument,
33
    InvalidPart,
34
    InventoryConfiguration,
35
    InventoryId,
36
    LifecycleRules,
37
    LoggingEnabled,
38
    Metadata,
39
    MethodNotAllowed,
40
    MultipartUploadId,
41
    NoSuchKey,
42
    NoSuchVersion,
43
    NotificationConfiguration,
44
    ObjectKey,
45
    ObjectLockLegalHoldStatus,
46
    ObjectLockMode,
47
    ObjectLockRetainUntilDate,
48
    ObjectLockRetentionMode,
49
    ObjectOwnership,
50
    ObjectStorageClass,
51
    ObjectVersionId,
52
    Owner,
53
    PartNumber,
54
    Payer,
55
    Policy,
56
    PublicAccessBlockConfiguration,
57
    ReplicationConfiguration,
58
    Restore,
59
    ServerSideEncryption,
60
    ServerSideEncryptionRule,
61
    Size,
62
    SSECustomerKeyMD5,
63
    SSEKMSKeyId,
64
    StorageClass,
65
    TransitionDefaultMinimumObjectSize,
66
    WebsiteConfiguration,
67
    WebsiteRedirectLocation,
68
)
69
from localstack.constants import AWS_REGION_US_EAST_1
1✔
70
from localstack.services.s3.constants import (
1✔
71
    DEFAULT_BUCKET_ENCRYPTION,
72
    DEFAULT_PUBLIC_BLOCK_ACCESS,
73
    S3_UPLOAD_PART_MIN_SIZE,
74
)
75
from localstack.services.s3.exceptions import InvalidRequest
1✔
76
from localstack.services.s3.utils import CombinedCrcHash, get_s3_checksum, rfc_1123_datetime
1✔
77
from localstack.services.stores import (
1✔
78
    AccountRegionBundle,
79
    BaseStore,
80
    CrossAccountAttribute,
81
    CrossRegionAttribute,
82
    LocalAttribute,
83
)
84
from localstack.utils.aws import arns
1✔
85
from localstack.utils.tagging import TaggingService
1✔
86

87
LOG = logging.getLogger(__name__)
1✔
88

89
_gmt_zone_info = ZoneInfo("GMT")
1✔
90

91

92
# note: not really a need to use a dataclass here, as it has a lot of fields, but only a few are set at creation
93
class S3Bucket:
1✔
94
    name: BucketName
1✔
95
    bucket_account_id: AccountId
1✔
96
    bucket_region: BucketRegion
1✔
97
    creation_date: datetime
1✔
98
    multiparts: dict[MultipartUploadId, "S3Multipart"]
1✔
99
    objects: Union["KeyStore", "VersionedKeyStore"]
1✔
100
    versioning_status: BucketVersioningStatus | None
1✔
101
    lifecycle_rules: Optional[LifecycleRules]
1✔
102
    transition_default_minimum_object_size: Optional[TransitionDefaultMinimumObjectSize]
1✔
103
    policy: Optional[Policy]
1✔
104
    website_configuration: Optional[WebsiteConfiguration]
1✔
105
    acl: AccessControlPolicy
1✔
106
    cors_rules: Optional[CORSConfiguration]
1✔
107
    logging: LoggingEnabled
1✔
108
    notification_configuration: NotificationConfiguration
1✔
109
    payer: Payer
1✔
110
    encryption_rule: Optional[ServerSideEncryptionRule]
1✔
111
    public_access_block: Optional[PublicAccessBlockConfiguration]
1✔
112
    accelerate_status: Optional[BucketAccelerateStatus]
1✔
113
    object_lock_enabled: bool
1✔
114
    object_ownership: ObjectOwnership
1✔
115
    intelligent_tiering_configurations: dict[IntelligentTieringId, IntelligentTieringConfiguration]
1✔
116
    analytics_configurations: dict[AnalyticsId, AnalyticsConfiguration]
1✔
117
    inventory_configurations: dict[InventoryId, InventoryConfiguration]
1✔
118
    object_lock_default_retention: Optional[DefaultRetention]
1✔
119
    replication: ReplicationConfiguration
1✔
120
    owner: Owner
1✔
121

122
    # set all buckets parameters here
123
    def __init__(
1✔
124
        self,
125
        name: BucketName,
126
        account_id: AccountId,
127
        bucket_region: BucketRegion,
128
        owner: Owner,
129
        acl: AccessControlPolicy = None,
130
        object_ownership: ObjectOwnership = None,
131
        object_lock_enabled_for_bucket: bool = None,
132
    ):
133
        self.name = name
1✔
134
        self.bucket_account_id = account_id
1✔
135
        self.bucket_region = bucket_region
1✔
136
        # If ObjectLock is enabled, it forces the bucket to be versioned as well
137
        self.versioning_status = None if not object_lock_enabled_for_bucket else "Enabled"
1✔
138
        self.objects = KeyStore() if not object_lock_enabled_for_bucket else VersionedKeyStore()
1✔
139
        self.object_ownership = object_ownership or ObjectOwnership.BucketOwnerEnforced
1✔
140
        self.object_lock_enabled = object_lock_enabled_for_bucket
1✔
141
        self.encryption_rule = DEFAULT_BUCKET_ENCRYPTION
1✔
142
        self.creation_date = datetime.now(tz=_gmt_zone_info)
1✔
143
        self.payer = Payer.BucketOwner
1✔
144
        self.public_access_block = DEFAULT_PUBLIC_BLOCK_ACCESS
1✔
145
        self.multiparts = {}
1✔
146
        self.notification_configuration = {}
1✔
147
        self.logging = {}
1✔
148
        self.cors_rules = None
1✔
149
        self.lifecycle_rules = None
1✔
150
        self.transition_default_minimum_object_size = None
1✔
151
        self.website_configuration = None
1✔
152
        self.policy = None
1✔
153
        self.accelerate_status = None
1✔
154
        self.intelligent_tiering_configurations = {}
1✔
155
        self.analytics_configurations = {}
1✔
156
        self.inventory_configurations = {}
1✔
157
        self.object_lock_default_retention = {}
1✔
158
        self.replication = None
1✔
159
        self.acl = acl
1✔
160
        # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_Owner.html
161
        self.owner = owner
1✔
162
        self.bucket_arn = arns.s3_bucket_arn(self.name, region=bucket_region)
1✔
163

164
    def get_object(
1✔
165
        self,
166
        key: ObjectKey,
167
        version_id: ObjectVersionId = None,
168
        http_method: Literal["GET", "PUT", "HEAD", "DELETE"] = "GET",
169
    ) -> "S3Object":
170
        """
171
        :param key: the Object Key
172
        :param version_id: optional, the versionId of the object
173
        :param http_method: the HTTP method of the original call. This is necessary for the exception if the bucket is
174
        versioned or suspended
175
        see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html
176
        :return: the S3Object from the bucket
177
        :raises NoSuchKey if the object key does not exist at all, or if the object is a DeleteMarker
178
        :raises MethodNotAllowed if the object is a DeleteMarker and the operation is not allowed against it
179
        """
180

181
        if self.versioning_status is None:
1✔
182
            if version_id and version_id != "null":
1✔
183
                raise InvalidArgument(
1✔
184
                    "Invalid version id specified",
185
                    ArgumentName="versionId",
186
                    ArgumentValue=version_id,
187
                )
188

189
            s3_object = self.objects.get(key)
1✔
190

191
            if not s3_object:
1✔
192
                raise NoSuchKey("The specified key does not exist.", Key=key)
1✔
193

194
        else:
195
            self.objects: VersionedKeyStore
1✔
196
            if version_id:
1✔
197
                s3_object_version = self.objects.get(key, version_id)
1✔
198
                if not s3_object_version:
1✔
199
                    raise NoSuchVersion(
1✔
200
                        "The specified version does not exist.",
201
                        Key=key,
202
                        VersionId=version_id,
203
                    )
204
                elif isinstance(s3_object_version, S3DeleteMarker):
1✔
205
                    if http_method == "HEAD":
1✔
206
                        raise CommonServiceException(
1✔
207
                            code="405",
208
                            message="Method Not Allowed",
209
                            status_code=405,
210
                        )
211

212
                    raise MethodNotAllowed(
1✔
213
                        "The specified method is not allowed against this resource.",
214
                        Method=http_method,
215
                        ResourceType="DeleteMarker",
216
                        DeleteMarker=True,
217
                        Allow="DELETE",
218
                        VersionId=s3_object_version.version_id,
219
                    )
220
                return s3_object_version
1✔
221

222
            s3_object = self.objects.get(key)
1✔
223

224
            if not s3_object:
1✔
225
                raise NoSuchKey("The specified key does not exist.", Key=key)
1✔
226

227
            elif isinstance(s3_object, S3DeleteMarker):
1✔
228
                if http_method not in ("HEAD", "GET"):
1✔
229
                    raise MethodNotAllowed(
1✔
230
                        "The specified method is not allowed against this resource.",
231
                        Method=http_method,
232
                        ResourceType="DeleteMarker",
233
                        DeleteMarker=True,
234
                        Allow="DELETE",
235
                        VersionId=s3_object.version_id,
236
                    )
237

238
                raise NoSuchKey(
1✔
239
                    "The specified key does not exist.",
240
                    Key=key,
241
                    DeleteMarker=True,
242
                    VersionId=s3_object.version_id,
243
                )
244

245
        return s3_object
1✔
246

247

248
class S3Object:
1✔
249
    key: ObjectKey
1✔
250
    version_id: Optional[ObjectVersionId]
1✔
251
    bucket: BucketName
1✔
252
    owner: Optional[Owner]
1✔
253
    size: Optional[Size]
1✔
254
    etag: Optional[ETag]
1✔
255
    user_metadata: Metadata
1✔
256
    system_metadata: Metadata
1✔
257
    last_modified: datetime
1✔
258
    expires: Optional[datetime]
1✔
259
    expiration: Optional[Expiration]  # right now, this is stored in the provider cache
1✔
260
    storage_class: StorageClass | ObjectStorageClass
1✔
261
    encryption: Optional[ServerSideEncryption]  # inherit bucket
1✔
262
    kms_key_id: Optional[SSEKMSKeyId]  # inherit bucket
1✔
263
    bucket_key_enabled: Optional[bool]  # inherit bucket
1✔
264
    sse_key_hash: Optional[SSECustomerKeyMD5]
1✔
265
    checksum_algorithm: ChecksumAlgorithm
1✔
266
    checksum_value: str
1✔
267
    checksum_type: ChecksumType
1✔
268
    lock_mode: Optional[ObjectLockMode | ObjectLockRetentionMode]
1✔
269
    lock_legal_status: Optional[ObjectLockLegalHoldStatus]
1✔
270
    lock_until: Optional[datetime]
1✔
271
    website_redirect_location: Optional[WebsiteRedirectLocation]
1✔
272
    acl: Optional[AccessControlPolicy]
1✔
273
    is_current: bool
1✔
274
    parts: Optional[dict[int, tuple[int, int]]]
1✔
275
    restore: Optional[Restore]
1✔
276
    internal_last_modified: int
1✔
277

278
    def __init__(
1✔
279
        self,
280
        key: ObjectKey,
281
        etag: Optional[ETag] = None,
282
        size: Optional[int] = None,
283
        version_id: Optional[ObjectVersionId] = None,
284
        user_metadata: Optional[Metadata] = None,
285
        system_metadata: Optional[Metadata] = None,
286
        storage_class: StorageClass = StorageClass.STANDARD,
287
        expires: Optional[datetime] = None,
288
        expiration: Optional[Expiration] = None,
289
        checksum_algorithm: Optional[ChecksumAlgorithm] = None,
290
        checksum_value: Optional[str] = None,
291
        checksum_type: Optional[ChecksumType] = ChecksumType.FULL_OBJECT,
292
        encryption: Optional[ServerSideEncryption] = None,
293
        kms_key_id: Optional[SSEKMSKeyId] = None,
294
        sse_key_hash: Optional[SSECustomerKeyMD5] = None,
295
        bucket_key_enabled: bool = False,
296
        lock_mode: Optional[ObjectLockMode | ObjectLockRetentionMode] = None,
297
        lock_legal_status: Optional[ObjectLockLegalHoldStatus] = None,
298
        lock_until: Optional[datetime] = None,
299
        website_redirect_location: Optional[WebsiteRedirectLocation] = None,
300
        acl: Optional[AccessControlPolicy] = None,  # TODO
301
        owner: Optional[Owner] = None,
302
    ):
303
        self.key = key
1✔
304
        self.user_metadata = (
1✔
305
            {k.lower(): v for k, v in user_metadata.items()} if user_metadata else {}
306
        )
307
        self.system_metadata = system_metadata or {}
1✔
308
        self.version_id = version_id
1✔
309
        self.storage_class = storage_class or StorageClass.STANDARD
1✔
310
        self.etag = etag
1✔
311
        self.size = size
1✔
312
        self.expires = expires
1✔
313
        self.checksum_algorithm = checksum_algorithm
1✔
314
        self.checksum_value = checksum_value
1✔
315
        self.checksum_type = checksum_type
1✔
316
        self.encryption = encryption
1✔
317
        self.kms_key_id = kms_key_id
1✔
318
        self.bucket_key_enabled = bucket_key_enabled
1✔
319
        self.sse_key_hash = sse_key_hash
1✔
320
        self.lock_mode = lock_mode
1✔
321
        self.lock_legal_status = lock_legal_status
1✔
322
        self.lock_until = lock_until
1✔
323
        self.acl = acl
1✔
324
        self.expiration = expiration
1✔
325
        self.website_redirect_location = website_redirect_location
1✔
326
        self.is_current = True
1✔
327
        self.last_modified = datetime.now(tz=_gmt_zone_info)
1✔
328
        self.parts = {}
1✔
329
        self.restore = None
1✔
330
        self.owner = owner
1✔
331
        self.internal_last_modified = 0
1✔
332

333
    def get_system_metadata_fields(self) -> dict:
1✔
334
        headers = {
1✔
335
            "LastModified": self.last_modified_rfc1123,
336
            "ContentLength": str(self.size),
337
            "ETag": self.quoted_etag,
338
        }
339
        if self.expires:
1✔
340
            headers["Expires"] = self.expires_rfc1123
1✔
341

342
        for metadata_key, metadata_value in self.system_metadata.items():
1✔
343
            headers[metadata_key] = metadata_value
1✔
344

345
        if self.storage_class != StorageClass.STANDARD:
1✔
346
            headers["StorageClass"] = self.storage_class
1✔
347

348
        return headers
1✔
349

350
    @property
1✔
351
    def last_modified_rfc1123(self) -> str:
1✔
352
        # TODO: verify if we need them with proper snapshot testing, for now it's copied from moto
353
        # Different datetime formats depending on how the key is obtained
354
        # https://github.com/boto/boto/issues/466
355
        return rfc_1123_datetime(self.last_modified)
1✔
356

357
    @property
1✔
358
    def expires_rfc1123(self) -> str:
1✔
359
        return rfc_1123_datetime(self.expires)
1✔
360

361
    @property
1✔
362
    def quoted_etag(self) -> str:
1✔
363
        return f'"{self.etag}"'
1✔
364

365
    def is_locked(self, bypass_governance: bool = False) -> bool:
1✔
366
        if self.lock_legal_status == "ON":
1✔
367
            return True
1✔
368

369
        if bypass_governance and self.lock_mode == ObjectLockMode.GOVERNANCE:
1✔
370
            return False
1✔
371

372
        if self.lock_until:
1✔
373
            return self.lock_until > datetime.now(tz=_gmt_zone_info)
1✔
374

375
        return False
1✔
376

377

378
# TODO: could use dataclass, validate after models are set
379
class S3DeleteMarker:
1✔
380
    key: ObjectKey
1✔
381
    version_id: str
1✔
382
    last_modified: datetime
1✔
383
    is_current: bool
1✔
384

385
    def __init__(self, key: ObjectKey, version_id: ObjectVersionId):
1✔
386
        self.key = key
1✔
387
        self.version_id = version_id
1✔
388
        self.last_modified = datetime.now(tz=_gmt_zone_info)
1✔
389
        self.is_current = True
1✔
390

391
    @staticmethod
1✔
392
    def is_locked(*args, **kwargs) -> bool:
1✔
393
        # an S3DeleteMarker cannot be lock protected
394
        return False
1✔
395

396

397
# TODO: could use dataclass, validate after models are set
398
class S3Part:
1✔
399
    part_number: PartNumber
1✔
400
    etag: Optional[ETag]
1✔
401
    last_modified: datetime
1✔
402
    size: Optional[int]
1✔
403
    checksum_algorithm: Optional[ChecksumAlgorithm]
1✔
404
    checksum_value: Optional[str]
1✔
405

406
    def __init__(
1✔
407
        self,
408
        part_number: PartNumber,
409
        size: int = None,
410
        etag: ETag = None,
411
        checksum_algorithm: Optional[ChecksumAlgorithm] = None,
412
        checksum_value: Optional[str] = None,
413
    ):
414
        self.last_modified = datetime.now(tz=_gmt_zone_info)
1✔
415
        self.part_number = part_number
1✔
416
        self.size = size
1✔
417
        self.etag = etag
1✔
418
        self.checksum_algorithm = checksum_algorithm
1✔
419
        self.checksum_value = checksum_value
1✔
420

421
    @property
1✔
422
    def quoted_etag(self) -> str:
1✔
423
        return f'"{self.etag}"'
1✔
424

425

426
class S3Multipart:
1✔
427
    parts: dict[PartNumber, S3Part]
1✔
428
    object: S3Object
1✔
429
    upload_id: MultipartUploadId
1✔
430
    checksum_value: Optional[str]
1✔
431
    checksum_type: Optional[ChecksumType]
1✔
432
    initiated: datetime
1✔
433
    precondition: bool
1✔
434

435
    def __init__(
1✔
436
        self,
437
        key: ObjectKey,
438
        storage_class: StorageClass | ObjectStorageClass = StorageClass.STANDARD,
439
        expires: Optional[datetime] = None,
440
        expiration: Optional[datetime] = None,  # come from lifecycle
441
        checksum_algorithm: Optional[ChecksumAlgorithm] = None,
442
        checksum_type: Optional[ChecksumType] = None,
443
        encryption: Optional[ServerSideEncryption] = None,  # inherit bucket
444
        kms_key_id: Optional[SSEKMSKeyId] = None,  # inherit bucket
445
        bucket_key_enabled: bool = False,  # inherit bucket
446
        sse_key_hash: Optional[SSECustomerKeyMD5] = None,
447
        lock_mode: Optional[ObjectLockMode] = None,
448
        lock_legal_status: Optional[ObjectLockLegalHoldStatus] = None,
449
        lock_until: Optional[datetime] = None,
450
        website_redirect_location: Optional[WebsiteRedirectLocation] = None,
451
        acl: Optional[AccessControlPolicy] = None,  # TODO
452
        user_metadata: Optional[Metadata] = None,
453
        system_metadata: Optional[Metadata] = None,
454
        initiator: Optional[Owner] = None,
455
        tagging: Optional[dict[str, str]] = None,
456
        owner: Optional[Owner] = None,
457
        precondition: Optional[bool] = None,
458
    ):
459
        self.id = token_urlsafe(96)  # MultipartUploadId is 128 characters long
1✔
460
        self.initiated = datetime.now(tz=_gmt_zone_info)
1✔
461
        self.parts = {}
1✔
462
        self.initiator = initiator
1✔
463
        self.tagging = tagging
1✔
464
        self.checksum_value = None
1✔
465
        self.checksum_type = checksum_type
1✔
466
        self.precondition = precondition
1✔
467
        self.object = S3Object(
1✔
468
            key=key,
469
            user_metadata=user_metadata,
470
            system_metadata=system_metadata,
471
            storage_class=storage_class or StorageClass.STANDARD,
472
            expires=expires,
473
            expiration=expiration,
474
            checksum_algorithm=checksum_algorithm,
475
            checksum_type=checksum_type,
476
            encryption=encryption,
477
            kms_key_id=kms_key_id,
478
            bucket_key_enabled=bucket_key_enabled,
479
            sse_key_hash=sse_key_hash,
480
            lock_mode=lock_mode,
481
            lock_legal_status=lock_legal_status,
482
            lock_until=lock_until,
483
            website_redirect_location=website_redirect_location,
484
            acl=acl,
485
            owner=owner,
486
        )
487

488
    def complete_multipart(
1✔
489
        self, parts: CompletedPartList, mpu_size: int = None, validation_checksum: str = None
490
    ):
491
        last_part_index = len(parts) - 1
1✔
492
        object_etag = hashlib.md5(usedforsecurity=False)
1✔
493
        has_checksum = self.object.checksum_algorithm is not None
1✔
494
        checksum_hash = None
1✔
495
        if has_checksum:
1✔
496
            if self.object.checksum_type == ChecksumType.COMPOSITE:
1✔
497
                checksum_hash = get_s3_checksum(self.object.checksum_algorithm)
1✔
498
            else:
499
                checksum_hash = CombinedCrcHash(self.object.checksum_algorithm)
1✔
500

501
        pos = 0
1✔
502
        parts_map = {}
1✔
503
        for index, part in enumerate(parts):
1✔
504
            part_number = part["PartNumber"]
1✔
505
            part_etag = part["ETag"].strip('"')
1✔
506

507
            s3_part = self.parts.get(part_number)
1✔
508
            if (
1✔
509
                not s3_part
510
                or s3_part.etag != part_etag
511
                or (not has_checksum and any(k.startswith("Checksum") for k in part))
512
            ):
513
                raise InvalidPart(
1✔
514
                    "One or more of the specified parts could not be found.  "
515
                    "The part may not have been uploaded, "
516
                    "or the specified entity tag may not match the part's entity tag.",
517
                    ETag=part_etag,
518
                    PartNumber=part_number,
519
                    UploadId=self.id,
520
                )
521

522
            if has_checksum:
1✔
523
                checksum_key = f"Checksum{self.object.checksum_algorithm.upper()}"
1✔
524
                if not (part_checksum := part.get(checksum_key)):
1✔
525
                    if self.checksum_type == ChecksumType.COMPOSITE:
1✔
526
                        # weird case, they still try to validate a different checksum type than the multipart
527
                        for field in part:
1✔
528
                            if field.startswith("Checksum"):
1✔
529
                                algo = field.removeprefix("Checksum").lower()
1✔
530
                                raise BadDigest(
1✔
531
                                    f"The {algo} you specified for part {part_number} did not match what we received."
532
                                )
533

534
                        raise InvalidRequest(
1✔
535
                            f"The upload was created using a {self.object.checksum_algorithm.lower()} checksum. "
536
                            f"The complete request must include the checksum for each part. "
537
                            f"It was missing for part {part_number} in the request."
538
                        )
539
                elif part_checksum != s3_part.checksum_value:
1✔
540
                    raise InvalidPart(
1✔
541
                        "One or more of the specified parts could not be found.  The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.",
542
                        ETag=part_etag,
543
                        PartNumber=part_number,
544
                        UploadId=self.id,
545
                    )
546

547
                part_checksum_value = base64.b64decode(s3_part.checksum_value)
1✔
548
                if self.checksum_type == ChecksumType.COMPOSITE:
1✔
549
                    checksum_hash.update(part_checksum_value)
1✔
550
                else:
551
                    checksum_hash.combine(part_checksum_value, s3_part.size)
1✔
552

553
            elif any(k.startswith("Checksum") for k in part):
1✔
554
                raise InvalidPart(
×
555
                    "One or more of the specified parts could not be found.  The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.",
556
                    ETag=part_etag,
557
                    PartNumber=part_number,
558
                    UploadId=self.id,
559
                )
560

561
            if index != last_part_index and s3_part.size < S3_UPLOAD_PART_MIN_SIZE:
1✔
562
                raise EntityTooSmall(
1✔
563
                    "Your proposed upload is smaller than the minimum allowed size",
564
                    ETag=part_etag,
565
                    PartNumber=part_number,
566
                    MinSizeAllowed=S3_UPLOAD_PART_MIN_SIZE,
567
                    ProposedSize=s3_part.size,
568
                )
569

570
            object_etag.update(bytes.fromhex(s3_part.etag))
1✔
571
            # keep track of the parts size, as it can be queried afterward on the object as a Range
572
            parts_map[part_number] = (pos, s3_part.size)
1✔
573
            pos += s3_part.size
1✔
574

575
        if mpu_size and mpu_size != pos:
1✔
576
            raise InvalidRequest(
1✔
577
                f"The provided 'x-amz-mp-object-size' header value {mpu_size} "
578
                f"does not match what was computed: {pos}"
579
            )
580

581
        if has_checksum:
1✔
582
            checksum_value = base64.b64encode(checksum_hash.digest()).decode()
1✔
583
            if self.checksum_type == ChecksumType.COMPOSITE:
1✔
584
                checksum_value = f"{checksum_value}-{len(parts)}"
1✔
585

586
            elif self.checksum_type == ChecksumType.FULL_OBJECT:
1✔
587
                if validation_checksum != checksum_value:
1✔
588
                    raise BadDigest(
1✔
589
                        f"The {self.object.checksum_algorithm.lower()} you specified did not match the calculated checksum."
590
                    )
591

592
            self.checksum_value = checksum_value
1✔
593
            self.object.checksum_value = checksum_value
1✔
594

595
        multipart_etag = f"{object_etag.hexdigest()}-{len(parts)}"
1✔
596
        self.object.etag = multipart_etag
1✔
597
        self.object.parts = parts_map
1✔
598

599

600
class KeyStore:
1✔
601
    """
602
    Object representing an S3 Un-versioned Bucket's Key Store. An object is mapped by a key, and you can simply
603
    retrieve the object from that key.
604
    """
605

606
    def __init__(self):
1✔
607
        self._store = {}
1✔
608

609
    def get(self, object_key: ObjectKey) -> S3Object | None:
1✔
610
        return self._store.get(object_key)
1✔
611

612
    def set(self, object_key: ObjectKey, s3_object: S3Object):
1✔
613
        self._store[object_key] = s3_object
1✔
614

615
    def pop(self, object_key: ObjectKey, default=None) -> S3Object | None:
1✔
616
        return self._store.pop(object_key, default)
1✔
617

618
    def values(self, *_, **__) -> list[S3Object | S3DeleteMarker]:
1✔
619
        # we create a shallow copy with dict to avoid size changed during iteration
620
        return [value for value in dict(self._store).values()]
1✔
621

622
    def is_empty(self) -> bool:
1✔
623
        return not self._store
1✔
624

625
    def __contains__(self, item):
1✔
626
        return item in self._store
×
627

628

629
class VersionedKeyStore:
1✔
630
    """
631
    Object representing an S3 Versioned Bucket's Key Store. An object is mapped by a key, and adding an object to the
632
    same key will create a new version of it. When deleting the object, a S3DeleteMarker is created and put on top
633
    of the version stack, to signal the object has been "deleted".
634
    This object allows easy retrieval and saving of new object versions.
635
    See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/versioning-workflows.html
636
    """
637

638
    def __init__(self):
1✔
639
        self._store = defaultdict(dict)
1✔
640

641
    @classmethod
1✔
642
    def from_key_store(cls, keystore: KeyStore) -> "VersionedKeyStore":
1✔
643
        new_versioned_keystore = cls()
1✔
644
        for s3_object in keystore.values():
1✔
645
            # TODO: maybe do the object mutation inside the provider instead? but would need to iterate twice
646
            #  or do this whole operation inside the provider instead, when actually working on versioning
647
            s3_object.version_id = "null"
1✔
648
            new_versioned_keystore.set(object_key=s3_object.key, s3_object=s3_object)
1✔
649

650
        return new_versioned_keystore
1✔
651

652
    def get(
1✔
653
        self, object_key: ObjectKey, version_id: ObjectVersionId = None
654
    ) -> S3Object | S3DeleteMarker | None:
655
        """
656
        :param object_key: the key of the Object we need to retrieve
657
        :param version_id: Optional, if not specified, return the current version (last one inserted)
658
        :return: an S3Object or S3DeleteMarker
659
        """
660
        if not version_id and (versions := self._store.get(object_key)):
1✔
661
            for version_id in reversed(versions):
1✔
662
                return versions.get(version_id)
1✔
663

664
        return self._store.get(object_key, {}).get(version_id)
1✔
665

666
    def set(self, object_key: ObjectKey, s3_object: S3Object | S3DeleteMarker):
1✔
667
        """
668
        Set an S3 object, using its already set VersionId.
669
        If the bucket versioning is `Enabled`, then we're just inserting a new Version.
670
        If the bucket versioning is `Suspended`, the current object version will be set to `null`, so if setting a new
671
        object at the same key, we will override it at the `null` versionId entry.
672
        :param object_key: the key of the Object we are setting
673
        :param s3_object: the S3 object or S3DeleteMarker to set
674
        :return: None
675
        """
676
        existing_s3_object = self.get(object_key)
1✔
677
        if existing_s3_object:
1✔
678
            existing_s3_object.is_current = False
1✔
679

680
        self._store[object_key][s3_object.version_id] = s3_object
1✔
681

682
    def pop(
1✔
683
        self, object_key: ObjectKey, version_id: ObjectVersionId = None, default=None
684
    ) -> S3Object | S3DeleteMarker | None:
685
        versions = self._store.get(object_key)
1✔
686
        if not versions:
1✔
687
            return None
×
688

689
        object_version = versions.pop(version_id, default)
1✔
690
        if not versions:
1✔
691
            self._store.pop(object_key)
1✔
692
        else:
693
            existing_s3_object = self.get(object_key)
1✔
694
            existing_s3_object.is_current = True
1✔
695

696
        return object_version
1✔
697

698
    def values(self, with_versions: bool = False) -> list[S3Object | S3DeleteMarker]:
1✔
699
        if with_versions:
1✔
700
            # we create a shallow copy with dict to avoid size changed during iteration
701
            return [
1✔
702
                object_version
703
                for values in dict(self._store).values()
704
                for object_version in dict(values).values()
705
            ]
706

707
        # if `with_versions` is False, then we need to return only the current version if it's not a DeleteMarker
708
        objects = []
1✔
709
        for object_key, versions in dict(self._store).items():
1✔
710
            # we're getting the last set object in the versions dictionary
711
            for version_id in reversed(versions):
1✔
712
                current_object = versions[version_id]
1✔
713
                if isinstance(current_object, S3DeleteMarker):
1✔
714
                    break
1✔
715

716
                objects.append(versions[version_id])
1✔
717
                break
1✔
718

719
        return objects
1✔
720

721
    def is_empty(self) -> bool:
1✔
722
        return not self._store
1✔
723

724
    def __contains__(self, item):
1✔
725
        return item in self._store
1✔
726

727

728
class S3Store(BaseStore):
1✔
729
    buckets: dict[BucketName, S3Bucket] = CrossRegionAttribute(default=dict)
1✔
730
    global_bucket_map: dict[BucketName, AccountId] = CrossAccountAttribute(default=dict)
1✔
731
    aws_managed_kms_key_id: SSEKMSKeyId = LocalAttribute(default=str)
1✔
732

733
    # static tagging service instance
734
    TAGS: TaggingService = CrossAccountAttribute(default=TaggingService)
1✔
735

736

737
class BucketCorsIndex:
1✔
738
    def __init__(self):
1✔
739
        self._cors_index_cache = None
1✔
740
        self._bucket_index_cache = None
1✔
741

742
    @property
1✔
743
    def cors(self) -> dict[str, CORSConfiguration]:
1✔
744
        if self._cors_index_cache is None:
1✔
745
            self._bucket_index_cache, self._cors_index_cache = self._build_index()
×
746
        return self._cors_index_cache
1✔
747

748
    @property
1✔
749
    def buckets(self) -> set[str]:
1✔
750
        if self._bucket_index_cache is None:
1✔
751
            self._bucket_index_cache, self._cors_index_cache = self._build_index()
1✔
752
        return self._bucket_index_cache
1✔
753

754
    def invalidate(self):
1✔
755
        self._cors_index_cache = None
1✔
756
        self._bucket_index_cache = None
1✔
757

758
    @staticmethod
1✔
759
    def _build_index() -> tuple[set[BucketName], dict[BucketName, CORSConfiguration]]:
1✔
760
        buckets = set()
1✔
761
        cors_index = {}
1✔
762
        # we create a shallow copy with dict to avoid size changed during iteration, as the store could have new account
763
        # or region create from any other requests
764
        for account_id, regions in dict(s3_stores).items():
1✔
765
            for bucket_name, bucket in dict(regions[AWS_REGION_US_EAST_1].buckets).items():
1✔
766
                bucket: S3Bucket
767
                buckets.add(bucket_name)
1✔
768
                if bucket.cors_rules is not None:
1✔
769
                    cors_index[bucket_name] = bucket.cors_rules
1✔
770

771
        return buckets, cors_index
1✔
772

773

774
class EncryptionParameters(NamedTuple):
1✔
775
    encryption: ServerSideEncryption
1✔
776
    kms_key_id: SSEKMSKeyId
1✔
777
    bucket_key_enabled: BucketKeyEnabled
1✔
778

779

780
class ObjectLockParameters(NamedTuple):
1✔
781
    lock_until: ObjectLockRetainUntilDate
1✔
782
    lock_legal_status: ObjectLockLegalHoldStatus
1✔
783
    lock_mode: ObjectLockMode | ObjectLockRetentionMode
1✔
784

785

786
s3_stores = AccountRegionBundle[S3Store]("s3", S3Store)
1✔
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