Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
| SSM | In-process + EC2 containers | Parameter Store (version history, labels, SecureString, tagging); Run Command (SendCommand, GetCommandInvocation, direct EC2 container execution, agent polling) |
| SQS | In-process | Standard and FIFO queues, DLQ, visibility timeout, batch operations, tagging |
| SNS | In-process | Topics, subscriptions, SQS, Lambda and HTTP delivery, tagging |
| S3 | In-process | Versioning, multipart upload, pre-signed URLs, Object Lock, event notifications |
| S3 | In-process | Versioning, multipart upload, pre-signed URLs, Object Lock, object annotations, event notifications |
| S3 Vectors | In-process | Vector buckets, indexes, put / get / list / delete vectors, cosine similarity queries |
| DynamoDB | In-process | GSI, LSI, Query, Scan, TTL, transactions, batch operations; Streams with shard iterators and Lambda event source mapping |
| Lambda | Real Docker | Runtime environment, execution model, warm container pool, aliases, Function URLs |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package com.floci.test;

import org.junit.jupiter.api.*;

import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.AnnotationDirective;
import software.amazon.awssdk.services.s3.model.AnnotationEntry;
import software.amazon.awssdk.services.s3.model.BucketVersioningStatus;
import software.amazon.awssdk.services.s3.model.ChecksumMode;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectAnnotationResponse;
import software.amazon.awssdk.services.s3.model.ListObjectAnnotationsRequest;
import software.amazon.awssdk.services.s3.model.ListObjectAnnotationsResponse;
import software.amazon.awssdk.services.s3.model.NoSuchAnnotationException;
import software.amazon.awssdk.services.s3.model.PutBucketVersioningRequest;
import software.amazon.awssdk.services.s3.model.PutObjectAnnotationRequest;
import software.amazon.awssdk.services.s3.model.VersioningConfiguration;

import static org.assertj.core.api.Assertions.*;

@DisplayName("S3 Object Annotations")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class S3AnnotationsTest {

private static S3Client s3;
private static final String BUCKET = "sdk-annotations-bucket";
private static final String KEY = "docs/annotated.txt";

@BeforeAll
static void setup() {
s3 = TestFixtures.s3Client();
s3.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
s3.putObject(r -> r.bucket(BUCKET).key(KEY), RequestBody.fromString("annotated object body"));
}

@AfterAll
static void teardown() {
for (String key : new String[]{"docs/annotated.txt", "docs/annotated-copy.txt", "docs/annotated-copied.txt"}) {
try {
s3.deleteObject(r -> r.bucket(BUCKET).key(key));
} catch (Exception ignored) {
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
try {
s3.deleteBucket(r -> r.bucket(BUCKET));
} catch (Exception ignored) {
}
}

@Test
@Order(1)
void putObjectAnnotation() {
var response = s3.putObjectAnnotation(PutObjectAnnotationRequest.builder()
.bucket(BUCKET)
.key(KEY)
.annotationName("classification")
.build(),
RequestBody.fromString("{\"label\": \"report\"}"));

assertThat(response.key()).isEqualTo(KEY);
assertThat(response.annotationName()).isEqualTo("classification");
assertThat(response.eTag()).isNotBlank();
// CRC64NVME is the AWS default algorithm for annotations without an explicit one.
// The SDK computes a CRC32 payload checksum client-side by default and expects it echoed.
assertThat(response.checksumCRC32()).isNotBlank();
assertThat(response.checksumTypeAsString()).isEqualTo("FULL_OBJECT");
}

@Test
@Order(2)
void getObjectAnnotation() {
ResponseBytes<GetObjectAnnotationResponse> response = s3.getObjectAnnotationAsBytes(
r -> r.bucket(BUCKET).key(KEY).annotationName("classification").build());

assertThat(response.asUtf8String()).isEqualTo("{\"label\": \"report\"}");
assertThat(response.response().eTag()).isNotBlank();
assertThat(response.response().lastModified()).isNotNull();
assertThat(response.response().contentLength()).isEqualTo(19L);
}

@Test
@Order(3)
void getObjectAnnotationWithChecksumMode() {
ResponseBytes<GetObjectAnnotationResponse> response = s3.getObjectAnnotationAsBytes(
r -> r.bucket(BUCKET).key(KEY)
.annotationName("classification")
.checksumMode(ChecksumMode.ENABLED)
.build());

assertThat(response.response().checksumCRC32()).isNotBlank();
assertThat(response.response().checksumTypeAsString()).isEqualTo("FULL_OBJECT");
}

@Test
@Order(4)
void listObjectAnnotations() {
s3.putObjectAnnotation(PutObjectAnnotationRequest.builder()
.bucket(BUCKET).key(KEY).annotationName("summary")
.build(),
RequestBody.fromString("summary text"));

ListObjectAnnotationsResponse response = s3.listObjectAnnotations(
ListObjectAnnotationsRequest.builder().bucket(BUCKET).key(KEY).build());

assertThat(response.bucket()).isEqualTo(BUCKET);
assertThat(response.key()).isEqualTo(KEY);
assertThat(response.annotationCount()).isEqualTo(2);
assertThat(response.annotations())
.extracting(AnnotationEntry::annotationName)
.containsExactly("classification", "summary");
assertThat(response.annotations())
.filteredOn(a -> "summary".equals(a.annotationName()))
.allSatisfy(a -> assertThat(a.size()).isEqualTo(12L));
assertThat(response.nextContinuationToken()).isNull();
assertThat(response.maxAnnotationResults()).isEqualTo(1000);
}

@Test
@Order(5)
void putObjectAnnotationWithChecksum() {
var response = s3.putObjectAnnotation(PutObjectAnnotationRequest.builder()
.bucket(BUCKET).key(KEY).annotationName("hashed")
.checksumAlgorithm(software.amazon.awssdk.services.s3.model.ChecksumAlgorithm.SHA256)
.build(),
RequestBody.fromString("checksummed payload"));

assertThat(response.checksumSHA256()).isNotBlank();
assertThat(response.eTag()).isNotBlank();
}

@Test
@Order(6)
void getMissingAnnotationThrows() {
assertThatThrownBy(() -> s3.getObjectAnnotationAsBytes(r -> r.bucket(BUCKET).key(KEY)
.annotationName("missing").build()))
.isInstanceOf(NoSuchAnnotationException.class);
}

@Test
@Order(7)
void deleteObjectAnnotationIsIdempotent() {
s3.deleteObjectAnnotation(r -> r.bucket(BUCKET).key(KEY).annotationName("hashed"));

assertThatThrownBy(() -> s3.getObjectAnnotationAsBytes(r -> r.bucket(BUCKET).key(KEY)
.annotationName("hashed").build()))
.isInstanceOf(NoSuchAnnotationException.class);

// Deleting a nonexistent annotation is not an error.
assertThatCode(() -> s3.deleteObjectAnnotation(r -> r.bucket(BUCKET).key(KEY)
.annotationName("hashed"))).doesNotThrowAnyException();
}

@Test
@Order(8)
void copyObjectExcludeDirectiveSkipsAnnotations() {
s3.copyObject(r -> r.sourceBucket(BUCKET).sourceKey(KEY)
.destinationBucket(BUCKET).destinationKey("docs/annotated-copy.txt")
.annotationDirective(AnnotationDirective.EXCLUDE));

assertThatThrownBy(() -> s3.getObjectAnnotationAsBytes(r -> r.bucket(BUCKET)
.key("docs/annotated-copy.txt").annotationName("classification").build()))
.isInstanceOf(NoSuchAnnotationException.class);
}

// ========== Versioned bucket behavior ==========

@Test
@Order(10)
void enableVersioning() {
s3.putBucketVersioning(r -> r.bucket(BUCKET)
.versioningConfiguration(VersioningConfiguration.builder()
.status(BucketVersioningStatus.ENABLED)
.build()));
}

@Test
@Order(11)
void annotationAttachesToSpecificVersion() {
s3.putObject(r -> r.bucket(BUCKET).key(KEY), RequestBody.fromString("version one"));
var v1 = s3.putObjectAnnotation(PutObjectAnnotationRequest.builder()
.bucket(BUCKET).key(KEY).annotationName("v1-note")
.build(),
RequestBody.fromString("on version one"));
assertThat(v1.objectVersionId()).isNotBlank();

s3.putObject(r -> r.bucket(BUCKET).key(KEY), RequestBody.fromString("version two"));

// The new version has no annotations; the old one's stay reachable by versionId.
assertThatThrownBy(() -> s3.getObjectAnnotationAsBytes(r -> r.bucket(BUCKET).key(KEY)
.annotationName("v1-note").build()))
.isInstanceOf(NoSuchAnnotationException.class);

ResponseBytes<GetObjectAnnotationResponse> fromV1 = s3.getObjectAnnotationAsBytes(
r -> r.bucket(BUCKET).key(KEY)
.annotationName("v1-note")
.versionId(v1.objectVersionId())
.build());
assertThat(fromV1.asUtf8String()).isEqualTo("on version one");
assertThat(fromV1.response().objectVersionId()).isEqualTo(v1.objectVersionId());
}

@Test
@Order(12)
void copyObjectCopiesAnnotationsByDefault() {
// The latest version (the copy source) must carry an annotation for the copy to take.
s3.putObjectAnnotation(PutObjectAnnotationRequest.builder()
.bucket(BUCKET).key(KEY).annotationName("latest-note")
.build(),
RequestBody.fromString("copied annotation"));
s3.copyObject(r -> r.sourceBucket(BUCKET).sourceKey(KEY)
.destinationBucket(BUCKET).destinationKey("docs/annotated-copied.txt"));

ResponseBytes<GetObjectAnnotationResponse> copied = s3.getObjectAnnotationAsBytes(
r -> r.bucket(BUCKET)
.key("docs/annotated-copied.txt").annotationName("latest-note")
.versionId(s3.headObject(b -> b.bucket(BUCKET).key("docs/annotated-copied.txt"))
.versionId())
.build());
assertThat(copied.asUtf8String()).isEqualTo("copied annotation");
s3.deleteObject(r -> r.bucket(BUCKET).key("docs/annotated-copied.txt"));
}
}
35 changes: 35 additions & 0 deletions docs/services/s3.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
| **Multipart** | CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload, ListMultipartUploads |
| **Versioning** | PutBucketVersioning, GetBucketVersioning |
| **Tagging** | PutBucketTagging, GetBucketTagging, PutObjectTagging, GetObjectTagging, DeleteObjectTagging |
| **Annotations** | PutObjectAnnotation, GetObjectAnnotation, ListObjectAnnotations, DeleteObjectAnnotation |
| **Policy** | PutBucketPolicy, GetBucketPolicy, DeleteBucketPolicy |
| **CORS** | PutBucketCors, GetBucketCors, DeleteBucketCors |
| **Lifecycle** | PutBucketLifecycle, GetBucketLifecycle, DeleteBucketLifecycle |
Expand All @@ -38,6 +39,40 @@

Browser and presigned POST uploads emit `s3:ObjectCreated:Post`, matching AWS S3. They do not emit
`s3:ObjectCreated:Put`; use `s3:ObjectCreated:*` to subscribe to objects created by either method.
Annotation changes emit `s3:ObjectAnnotation:Put` and `s3:ObjectAnnotation:Delete`.

## Object Annotations

Annotations are named UTF-8 text payloads (up to 1 MiB each, 1,000 per object version) attached to a
specific object version through the four `?annotation` operations. Notes on the emulation:

- Annotation names allow letters (any language), digits, `_`, `.`, and `-`; names longer than 512
bytes, empty or whitespace-only names, names with other characters, and names starting with `aws`
or `s3` (case-insensitive) are rejected.
- Payloads must be valid UTF-8 text between 1 byte and 1 MiB; anything else is rejected with 400,
and non-UTF-8 payloads return 415 `UnsupportedMediaType`.
- `x-amz-object-if-match` is validated against the parent object's ETag on put and delete.
- Versioning semantics match AWS: annotations attach to one object version, new versions do not
inherit them, overwriting a non-versioned object or deleting it drops its annotations, a delete
marker preserves the underlying version's annotations, and deleting a specific version deletes
its annotations. Annotation deletion is permanent.
- `CopyObject` copies annotations by default; the `x-amz-object-annotation-directive` header
(as the AWS SDK sends it; `x-amz-annotation-directive` is also accepted) set to `EXCLUDE`
skips them.
- Checksums are per-annotation and independent of the object checksum. The default algorithm is
CRC64NVME. Supported: CRC32, CRC32C, CRC64NVME, SHA1, SHA256. SHA512, XXHASH64, XXHASH3, XXHASH128,
and MD5 are rejected as unsupported.
- Annotations on SSE-C encrypted objects are rejected, as on AWS, and are not copied onto SSE-C
copy destinations.
- Annotation operations serialize against object writes on the same bucket. On Object
Lock-protected versions, annotation put and delete follow the same rules as object delete:
governance retention requires `x-amz-bypass-governance-retention` (put never takes the bypass),
compliance and legal hold always block.
- The literal `versionId=null` (as reported by ListObjectVersions for pre-versioning objects)
addresses the pre-versioning entry.
- Annotations are stored per AWS account. With `globalBucketNamespace` enabled, object reads
resolve cross-account but annotation reads, writes, and listings stay in the caller's account.
- S3 Metadata annotation tables and annotation replication are not implemented.

## Website Hosting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,8 @@ static boolean isReadOnlyEvent(String eventName) {
if (eventName == null) return true;
return switch (eventName) {
case "GetObject", "HeadObject", "ListObjects", "ListObjectsV2",
"GetObjectAcl", "GetObjectTagging", "ListMultipartUploads" -> true;
"GetObjectAcl", "GetObjectTagging", "ListMultipartUploads",
"GetObjectAnnotation", "ListObjectAnnotations" -> true;
default -> false;
};
}
Expand Down
Loading