Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ 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`.
* 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,84 @@
/*
* 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.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.Tag

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

@Test
@S3VerifiedTodo
fun `GET BucketTagging returns empty tag set when no tags are set`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)

val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()

assertThat(tagSet).isEmpty()
}

@Test
@S3VerifiedTodo
fun `PUT and GET BucketTagging succeeds`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)
val tag1 = tag("env", "prod")
val tag2 = tag("team", "backend")

s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging { t -> t.tagSet(tag1, tag2) }
}

val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()

assertThat(tagSet).contains(tag1, tag2)
}

@Test
@S3VerifiedTodo
fun `PUT and GET and DELETE BucketTagging succeeds`(testInfo: TestInfo) {
val bucketName = givenBucket(testInfo)
val tag1 = tag("env", "staging")
val tag2 = tag("owner", "team-a")

s3Client.putBucketTagging {
it.bucket(bucketName)
it.tagging { t -> t.tagSet(tag1, tag2) }
}

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

s3Client.deleteBucketTagging { it.bucket(bucketName) }

assertThat(s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet())
.isEmpty()
}

private fun tag(
key: String,
value: String,
): Tag =
Tag
.builder()
.key(key)
.value(value)
.build()
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2025 Adobe.
* 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.
Expand All @@ -26,6 +26,7 @@ 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.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 +46,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,6 +122,7 @@ class BucketController(private val bucketService: BucketService) {
params = [
NOT_OBJECT_LOCK,
NOT_LIFECYCLE,
NOT_TAGGING,
NOT_VERSIONING
]
)
Expand Down Expand Up @@ -186,7 +190,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 +370,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 tagging = bucketService.getBucketTagging(bucketName)
return ResponseEntity.ok(tagging)
}

/**
* [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)
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 @@ -404,6 +479,7 @@ class BucketController(private val bucketService: BucketService) {
NOT_OBJECT_LOCK,
NOT_LIST_TYPE,
NOT_LIFECYCLE,
NOT_TAGGING,
NOT_LOCATION,
NOT_VERSIONS,
NOT_VERSIONING
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2025 Adobe.
* 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.
Expand Down Expand Up @@ -34,6 +34,9 @@ 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.Tagging
import com.adobe.testing.s3mock.dto.TagSet
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 +184,31 @@ open class BucketService(
return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html).
*/
fun getBucketTagging(bucketName: String): Tagging {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
val tags = bucketMetadata.tagging ?: emptyList()
return Tagging(TagSet(tags))
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html).
*/
fun setBucketTagging(bucketName: String, tagging: Tagging) {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
bucketStore.storeBucketTagging(bucketMetadata, tagging.tagSet.tags)
}

/**
Comment on lines +201 to +204
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setBucketTagging() persists incoming tags without validating them (e.g., max tag count, duplicate keys, disallowed aws: prefix, key/value length/charset). Object tagging already enforces these constraints via ObjectService.verifyObjectTags() and throws S3Exception.INVALID_TAG; bucket tagging should apply the same rules to match S3 behavior and keep bucket/object tagging consistent (and add/adjust unit tests accordingly).

Suggested change
bucketStore.storeBucketTagging(bucketMetadata, tagging.tagSet.tags)
}
/**
val tags = tagging.tagSet.tags
verifyBucketTags(tags)
bucketStore.storeBucketTagging(bucketMetadata, tags)
}
/**
* Validates bucket tags to match S3 constraints and object-tagging behavior.
* Throws [S3Exception.INVALID_TAG] if any constraint is violated.
*/
private fun verifyBucketTags(tags: List<Tag>) {
// S3 allows up to 50 tags per resource.
if (tags.size > 50) {
throw S3Exception.INVALID_TAG
}
val seenKeys = HashSet<String>()
for (tag in tags) {
val key = tag.key
val value = tag.value
// Key must be 1–128 characters.
if (key.isEmpty() || key.length > 128) {
throw S3Exception.INVALID_TAG
}
// Key must not start with the reserved "aws:" prefix (case-insensitive).
if (key.startsWith("aws:", ignoreCase = true)) {
throw S3Exception.INVALID_TAG
}
// Keys must be unique within the tag set.
if (!seenKeys.add(key)) {
throw S3Exception.INVALID_TAG
}
// Value must be at most 256 characters.
if (value.length > 256) {
throw S3Exception.INVALID_TAG
}
// Disallow ISO control characters in keys or values.
if (key.any { Character.isISOControl(it) } ||
value.any { Character.isISOControl(it) }
) {
throw S3Exception.INVALID_TAG
}
}
}
/**

Copilot uses AI. Check for mistakes.
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html).
*/
fun deleteBucketTagging(bucketName: String) {
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
bucketStore.storeBucketTagging(bucketMetadata, null)
}

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
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2025 Adobe.
* 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.
Expand All @@ -21,6 +21,7 @@ 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.JsonProperty
Expand All @@ -41,6 +42,7 @@ data class BucketMetadata(
val bucketRegion: String,
val bucketInfo: BucketInfo?,
val locationInfo: LocationInfo?,
val tagging: List<Tag>? = null,
@param:JsonProperty("objects")
private val _objects: MutableMap<String, UUID> = mutableMapOf()
) {
Expand Down Expand Up @@ -69,6 +71,9 @@ data class BucketMetadata(
fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata =
this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration)

fun withTagging(tagging: List<Tag>?): BucketMetadata =
this.copy(tagging = tagging)

@get:JsonIgnore
val isVersioningEnabled: Boolean
get() = this.versioningConfiguration?.status == VersioningConfiguration.Status.ENABLED
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2025 Adobe.
* 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.
Expand All @@ -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,18 @@ open class BucketStore(
}
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html).
*/
fun storeBucketTagging(
metadata: BucketMetadata,
tagging: List<Tag>?
) {
synchronized(lockStore[metadata.name]!!) {
writeToDisk(metadata.withTagging(tagging))
}
}

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