Skip to content

Commit bb0a6da

Browse files
committed
fix(s3): include non-versioned objects in ListObjectVersions response
1 parent 095eefc commit bb0a6da

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

compatibility-tests/sdk-test-java/src/test/java/com/floci/test/S3FeaturesTest.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ class S3FeaturesTest {
2525
private static final String BUCKET_VERSIONS = "compat-versions-bucket";
2626
private static final String BUCKET_PAB = "compat-pab-bucket";
2727
private static final String BUCKET_PAGINATE = "compat-paginate-bucket";
28+
private static final String BUCKET_334 = "compat-334-bucket";
2829

2930
@BeforeAll
3031
static void setup() {
3132
s3 = TestFixtures.s3Client();
3233
createBucket(BUCKET_VERSIONS);
3334
createBucket(BUCKET_PAB);
3435
createBucket(BUCKET_PAGINATE);
36+
createBucket(BUCKET_334);
3537
}
3638

3739
@AfterAll
@@ -40,9 +42,11 @@ static void cleanup() {
4042
deleteBucketContents(BUCKET_VERSIONS);
4143
deleteBucketContents(BUCKET_PAB);
4244
deleteBucketContents(BUCKET_PAGINATE);
45+
deleteBucketContents(BUCKET_334);
4346
quietDeleteBucket(BUCKET_VERSIONS);
4447
quietDeleteBucket(BUCKET_PAB);
4548
quietDeleteBucket(BUCKET_PAGINATE);
49+
quietDeleteBucket(BUCKET_334);
4650
s3.close();
4751
}
4852

@@ -129,6 +133,69 @@ void listObjectVersionsPaginatorPaginates() {
129133
assertThat(allVersions.size()).isGreaterThanOrEqualTo(5);
130134
}
131135

136+
// ─────────────────────────────────────────────────────────────────────────
137+
// Issue #334 — listObjectVersions must return non-versioned objects
138+
// ─────────────────────────────────────────────────────────────────────────
139+
140+
/**
141+
* Objects uploaded to a bucket that has never had versioning enabled must appear in
142+
* ListObjectVersions with VersionId="null" (the literal string, per AWS spec).
143+
*/
144+
@Test
145+
@Order(13)
146+
@DisplayName("#334 listObjectVersions: non-versioned bucket returns objects with VersionId=null")
147+
void listObjectVersionsNonVersionedBucketReturnsObjects() {
148+
s3.putObject(PutObjectRequest.builder().bucket(BUCKET_334).key("file-a.txt").build(),
149+
RequestBody.fromString("content-a"));
150+
s3.putObject(PutObjectRequest.builder().bucket(BUCKET_334).key("file-b.txt").build(),
151+
RequestBody.fromString("content-b"));
152+
153+
ListObjectVersionsResponse response = s3.listObjectVersions(
154+
ListObjectVersionsRequest.builder().bucket(BUCKET_334).build());
155+
156+
List<ObjectVersion> versions = response.versions();
157+
assertThat(versions).hasSize(2);
158+
159+
List<String> keys = versions.stream().map(ObjectVersion::key).toList();
160+
assertThat(keys).containsExactlyInAnyOrder("file-a.txt", "file-b.txt");
161+
162+
// AWS returns the literal string "null" for objects uploaded without versioning
163+
assertThat(versions).allMatch(v -> "null".equals(v.versionId()));
164+
assertThat(versions).allMatch(ObjectVersion::isLatest);
165+
}
166+
167+
/**
168+
* Objects uploaded before versioning was enabled must appear in ListObjectVersions
169+
* alongside objects uploaded after versioning was enabled.
170+
* Pre-versioning objects appear with VersionId="null"; post-versioning objects have a UUID.
171+
*/
172+
@Test
173+
@Order(14)
174+
@DisplayName("#334 listObjectVersions: pre-versioning objects appear alongside versioned entries")
175+
void listObjectVersionsPreVersioningObjectsAppearsWithNullVersionId() {
176+
// plain.txt was put at order 10, before versioning was enabled at order 11.
177+
// It should appear in the listing with VersionId="null".
178+
ListObjectVersionsResponse response = s3.listObjectVersions(
179+
ListObjectVersionsRequest.builder().bucket(BUCKET_VERSIONS).build());
180+
181+
List<ObjectVersion> all = response.versions();
182+
183+
// Pre-versioning object
184+
List<ObjectVersion> plainVersions = all.stream()
185+
.filter(v -> "plain.txt".equals(v.key()))
186+
.toList();
187+
assertThat(plainVersions).hasSize(1);
188+
assertThat(plainVersions.get(0).versionId()).isEqualTo("null");
189+
assertThat(plainVersions.get(0).isLatest()).isTrue();
190+
191+
// Versioned objects uploaded after versioning was enabled must have UUID version IDs
192+
List<ObjectVersion> versioned = all.stream()
193+
.filter(v -> "versioned.txt".equals(v.key()))
194+
.toList();
195+
assertThat(versioned).hasSizeGreaterThanOrEqualTo(2);
196+
assertThat(versioned).allMatch(v -> v.versionId() != null && !"null".equals(v.versionId()));
197+
}
198+
132199
// ─────────────────────────────────────────────────────────────────────────
133200
// Issue #236 — PutPublicAccessBlock must not return BucketAlreadyOwnedByYou
134201
// ─────────────────────────────────────────────────────────────────────────

src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -949,7 +949,7 @@ private Response handleListObjectVersions(String bucket, String prefix, Integer
949949
} else {
950950
xml.start("Version")
951951
.elem("Key", obj.getKey())
952-
.elem("VersionId", obj.getVersionId())
952+
.elem("VersionId", obj.getVersionId() != null ? obj.getVersionId() : "null")
953953
.elem("IsLatest", obj.isLatest())
954954
.elem("LastModified", ISO_FORMAT.format(obj.getLastModified()))
955955
.elem("ETag", obj.getETag())

src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,15 @@ public ListVersionsResult listObjectVersions(String bucketName, String prefix, i
663663
List<S3Object> versions = new ArrayList<>(objectStore.scan(key ->
664664
key.startsWith(fullPrefix) && key.contains("#v#")));
665665

666+
// Also include non-versioned objects (no #v# in storage key, versionId == null).
667+
// These are objects uploaded when versioning was disabled or before versioning was enabled.
668+
// Versioned latest-pointer entries (also stored at the plain key) are excluded because
669+
// they have a non-null versionId; their #v# entry is already captured above.
670+
objectStore.scan(key -> key.startsWith(fullPrefix) && !key.contains("#v#"))
671+
.stream()
672+
.filter(obj -> obj.getVersionId() == null)
673+
.forEach(versions::add);
674+
666675
// Sort by key, then by lastModified descending
667676
versions.sort((a, b) -> {
668677
int keyCompare = a.getKey().compareTo(b.getKey());

0 commit comments

Comments
 (0)