Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Version 5.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
## 5.0.0

* Features and fixes
* Implement bucket tagging operations: GetBucketTagging, PutBucketTagging, DeleteBucketTagging.
* GetBucketTagging returns `NoSuchTagSet` (404) when no tags are set, matching AWS S3 behaviour.
* Breaking change (file system): Remove "DisplayName" from Owner. (fixes #2738)
* AWS APIs stopped returning "DisplayName" in November 2025.
* This is unfortunately a breaking change for clients starting S3Mock on existing file systems.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [DeleteBucketOwnershipControls](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketOwnershipControls.html) | :x: | |
| [DeleteBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketPolicy.html) | :x: | |
| [DeleteBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketReplication.html) | :x: | |
| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :x: | |
| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :white_check_mark: | |
| [DeleteBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketWebsite.html) | :x: | |
| [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) | :white_check_mark: | |
| [DeleteObjects](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) | :white_check_mark: | |
Expand All @@ -143,7 +143,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [GetBucketPolicyStatus](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicyStatus.html) | :x: | |
| [GetBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketReplication.html) | :x: | |
| [GetBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketRequestPayment.html) | :x: | |
| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :x: | |
| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :white_check_mark: | |
| [GetBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketVersioning.html) | :white_check_mark: | |
| [GetBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketWebsite.html) | :x: | |
| [GetObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html) | :white_check_mark: | |
Expand Down Expand Up @@ -185,7 +185,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [PutBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketPolicy.html) | :x: | |
| [PutBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketReplication.html) | :x: | |
| [PutBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketRequestPayment.html) | :x: | |
| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :x: | |
| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :white_check_mark: | |
| [PutBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketVersioning.html) | :white_check_mark: | |
| [PutBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketWebsite.html) | :x: | |
| [PutObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) | :white_check_mark: | |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2017-2026 Adobe.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.adobe.testing.s3mock.its

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.assertj.core.api.InstanceOfAssertFactories
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import software.amazon.awssdk.awscore.exception.AwsErrorDetails
import software.amazon.awssdk.awscore.exception.AwsServiceException
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.Tag
import software.amazon.awssdk.services.s3.model.Tagging

internal class BucketTaggingIT : S3TestBase() {
private val s3Client: S3Client = createS3Client()

@Test
@S3VerifiedSuccess(year = 2026)
fun `get bucket tagging returns error if not set`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)

assertThatThrownBy {
s3Client.getBucketTagging { it.bucket(bucketName) }
}.isInstanceOf(AwsServiceException::class.java)
.hasMessageContaining("Service: S3, Status Code: 404")
.asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java))
.extracting(AwsServiceException::awsErrorDetails)
.extracting(AwsErrorDetails::errorCode)
.isEqualTo("NoSuchTagSet")
}

@Test
@S3VerifiedSuccess(year = 2026)
fun `put and get bucket tagging succeeds`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)
val tag1 = tag("env" to "production")
val tag2 = tag("team" to "platform")

s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging(Tagging.builder().tagSet(tag1, tag2).build())
}

assertThat(
s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
).contains(tag1, tag2)
}

@Test
@S3VerifiedSuccess(year = 2026)
fun `put get and delete bucket tagging succeeds`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)
val tag1 = tag("env" to "staging")
val tag2 = tag("owner" to "team-a")

s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging(Tagging.builder().tagSet(tag1, tag2).build())
}

assertThat(
s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
).contains(tag1, tag2)

s3Client.deleteBucketTagging { it.bucket(bucketName) }

assertThatThrownBy {
s3Client.getBucketTagging { it.bucket(bucketName) }
}.isInstanceOf(AwsServiceException::class.java)
.hasMessageContaining("Service: S3, Status Code: 404")
.asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java))
.extracting(AwsServiceException::awsErrorDetails)
.extracting(AwsErrorDetails::errorCode)
.isEqualTo("NoSuchTagSet")
}

@Test
@S3VerifiedSuccess(year = 2026)
fun `put bucket tagging replaces existing tags`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)
val tag1 = tag("env" to "dev")

s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging(Tagging.builder().tagSet(tag1).build())
}

val tag2 = tag("env" to "prod")
s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging(Tagging.builder().tagSet(tag2).build())
}

val result = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
assertThat(result).containsExactly(tag2)
assertThat(result).doesNotContain(tag1)
}

private fun tag(pair: Pair<String, String>): Tag =
Tag.builder().key(pair.first).value(pair.second).build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ class S3Exception
HttpStatus.NOT_FOUND.value(), "NoSuchLifecycleConfiguration",
"The lifecycle configuration does not exist."
)
val NO_SUCH_TAG_SET: S3Exception = S3Exception(
HttpStatus.NOT_FOUND.value(), "NoSuchTagSet",
"There is no tag set associated with the bucket."
)
val NO_SUCH_KEY: S3Exception =
S3Exception(HttpStatus.NOT_FOUND.value(), "NoSuchKey", "The specified key does not exist.")
val NO_SUCH_VERSION: S3Exception = S3Exception(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import com.adobe.testing.s3mock.dto.LocationConstraint
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
import com.adobe.testing.s3mock.dto.ObjectOwnership
import com.adobe.testing.s3mock.dto.Region
import com.adobe.testing.s3mock.dto.Tag
import com.adobe.testing.s3mock.dto.TagSet
import com.adobe.testing.s3mock.dto.Tagging
import com.adobe.testing.s3mock.dto.VersioningConfiguration
import com.adobe.testing.s3mock.service.BucketService
import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENABLED
Expand All @@ -45,11 +48,13 @@ import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIST_TYPE
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LOCATION
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_OBJECT_LOCK
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_TAGGING
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_UPLOADS
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONING
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONS
import com.adobe.testing.s3mock.util.AwsHttpParameters.OBJECT_LOCK
import com.adobe.testing.s3mock.util.AwsHttpParameters.START_AFTER
import com.adobe.testing.s3mock.util.AwsHttpParameters.TAGGING
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONING
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONS
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSION_ID_MARKER
Expand Down Expand Up @@ -119,7 +124,8 @@ class BucketController(private val bucketService: BucketService) {
params = [
NOT_OBJECT_LOCK,
NOT_LIFECYCLE,
NOT_VERSIONING
NOT_VERSIONING,
NOT_TAGGING
]
)
@S3Verified(year = 2025)
Expand Down Expand Up @@ -186,7 +192,8 @@ class BucketController(private val bucketService: BucketService) {
// AWS SDK V1 pattern
"/{bucketName:.+}/"
], params = [
NOT_LIFECYCLE
NOT_LIFECYCLE,
NOT_TAGGING
]
)
@S3Verified(year = 2025)
Expand Down Expand Up @@ -365,6 +372,76 @@ class BucketController(private val bucketService: BucketService) {
return ResponseEntity.noContent().build()
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html).
*/
@GetMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
TAGGING,
NOT_LIST_TYPE
],
produces = [
MediaType.APPLICATION_XML_VALUE
]
)
@S3Verified(year = 2026)
fun getBucketTagging(@PathVariable bucketName: String): ResponseEntity<Tagging> {
bucketService.verifyBucketExists(bucketName)
val tags = bucketService.getBucketTagging(bucketName)
return ResponseEntity.ok(Tagging(TagSet(tags)))
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html).
*/
@PutMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
TAGGING
]
)
@S3Verified(year = 2026)
fun putBucketTagging(
@PathVariable bucketName: String,
@RequestBody tagging: Tagging
): ResponseEntity<Void> {
bucketService.verifyBucketExists(bucketName)
bucketService.setBucketTagging(bucketName, tagging.tagSet.tags)
return ResponseEntity.ok().build()
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html).
*/
@DeleteMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
TAGGING
]
)
@S3Verified(year = 2026)
fun deleteBucketTagging(@PathVariable bucketName: String): ResponseEntity<Void> {
bucketService.verifyBucketExists(bucketName)
bucketService.deleteBucketTagging(bucketName)
return ResponseEntity.noContent().build()
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html).
*/
Expand Down Expand Up @@ -406,7 +483,8 @@ class BucketController(private val bucketService: BucketService) {
NOT_LIFECYCLE,
NOT_LOCATION,
NOT_VERSIONS,
NOT_VERSIONING
NOT_VERSIONING,
NOT_TAGGING
],
produces = [
MediaType.APPLICATION_XML_VALUE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.adobe.testing.s3mock.dto.Owner
import com.adobe.testing.s3mock.dto.Prefix
import com.adobe.testing.s3mock.dto.Region
import com.adobe.testing.s3mock.dto.S3Object
import com.adobe.testing.s3mock.dto.Tag
import com.adobe.testing.s3mock.dto.VersioningConfiguration
import com.adobe.testing.s3mock.store.BucketMetadata
import com.adobe.testing.s3mock.store.BucketStore
Expand Down Expand Up @@ -181,6 +182,20 @@ open class BucketService(
return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION
}

fun setBucketTagging(bucketName: String, tags: List<Tag>?) {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
bucketStore.storeBucketTagging(bucketMetadata, tags)
}

fun deleteBucketTagging(bucketName: String) {
setBucketTagging(bucketName, null)
}

fun getBucketTagging(bucketName: String): List<Tag> {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
return bucketMetadata.tags?.takeIf { it.isNotEmpty() } ?: throw S3Exception.NO_SUCH_TAG_SET
}

fun getS3Objects(bucketName: String, prefix: String?): List<S3Object> {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
return bucketStore.lookupIdsInBucket(prefix, bucketName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration
import com.adobe.testing.s3mock.dto.LocationInfo
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
import com.adobe.testing.s3mock.dto.ObjectOwnership
import com.adobe.testing.s3mock.dto.Tag
import com.adobe.testing.s3mock.dto.VersioningConfiguration
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import java.nio.file.Path
import java.util.UUID
Expand All @@ -41,6 +43,8 @@ data class BucketMetadata(
val bucketRegion: String,
val bucketInfo: BucketInfo?,
val locationInfo: LocationInfo?,
@JsonInclude(JsonInclude.Include.NON_NULL)
val tags: List<Tag>? = null,
@param:JsonProperty("objects")
private val _objects: MutableMap<String, UUID> = mutableMapOf()
) {
Expand Down Expand Up @@ -69,6 +73,9 @@ data class BucketMetadata(
fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata =
this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration)

fun withTags(tags: List<Tag>?): BucketMetadata =
this.copy(tags = tags)

@get:JsonIgnore
val isVersioningEnabled: Boolean
get() = this.versioningConfiguration?.status == VersioningConfiguration.Status.ENABLED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.adobe.testing.s3mock.dto.LocationInfo
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
import com.adobe.testing.s3mock.dto.ObjectLockEnabled.ENABLED
import com.adobe.testing.s3mock.dto.ObjectOwnership
import com.adobe.testing.s3mock.dto.Tag
import com.adobe.testing.s3mock.dto.VersioningConfiguration
import org.slf4j.Logger
import org.slf4j.LoggerFactory
Expand Down Expand Up @@ -195,6 +196,15 @@ open class BucketStore(
}
}

fun storeBucketTagging(
metadata: BucketMetadata,
tags: List<Tag>?
) {
synchronized(lockStore[metadata.name]!!) {
writeToDisk(metadata.withTags(tags))
}
}

fun isBucketEmpty(bucketName: String): Boolean {
check(doesBucketExist(bucketName)) { "Requested Bucket does not exist: $bucketName" }
return getBucketMetadata(bucketName).objects.isEmpty()
Expand Down
Loading