From fe9734a5049c949dc4fac0f7319f9badba34c875 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 21 Mar 2026 13:23:27 -0700 Subject: [PATCH] test: add directory marker Content-Type tests for #8712 Integration test (s3_list_empty_directory_test.go): - Reproduces exact scenario from #8712: PutObject with explicit Content-Type, then ListObjects with prefix - Covers ListV1/V2, with/without prefix, with/without delimiter - Verifies mixed MIME types coexist in listings - Asserts Size == 0 for all directory markers Unit test (s3api_object_handlers_list_test.go): - Tests doListFilerEntries with Mime set directly (pre-existing path) - Tests Mime empty but ExtMimeType set in extended attributes (the actual #8712 scenario after filer strips application/octet-stream) --- .../s3/normal/s3_list_empty_directory_test.go | 135 ++++++++++++++++++ weed/s3api/s3api_object_handlers_list_test.go | 62 ++++++++ 2 files changed, 197 insertions(+) diff --git a/test/s3/normal/s3_list_empty_directory_test.go b/test/s3/normal/s3_list_empty_directory_test.go index b8c64180f..b35efdd08 100644 --- a/test/s3/normal/s3_list_empty_directory_test.go +++ b/test/s3/normal/s3_list_empty_directory_test.go @@ -223,6 +223,141 @@ func TestS3ListObjectsEmptyDirectoryMarkers(t *testing.T) { }) } +// TestS3ListObjectsDirectoryMarkerWithContentType reproduces GitHub issue #8712: +// Directory markers created with an explicit Content-Type (e.g. application/octet-stream) +// must appear in ListObjects results, just like markers created without Content-Type. +func TestS3ListObjectsDirectoryMarkerWithContentType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + cluster, err := startMiniCluster(t) + require.NoError(t, err) + defer cluster.Stop() + + bucketName := createTestBucket(t, cluster, "test-content-type-dirs-") + + // Create a directory marker with explicit Content-Type (the scenario from #8712) + _, err = cluster.s3Client.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-content/empty/"), + Body: bytes.NewReader([]byte{}), + ContentType: aws.String("application/octet-stream"), + }) + require.NoError(t, err, "failed to create directory marker with content-type") + + // Verify the directory marker exists via HeadObject + headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-content/empty/"), + }) + require.NoError(t, err, "directory marker should exist via HeadObject") + assert.Equal(t, "application/octet-stream", aws.StringValue(headResp.ContentType), + "Content-Type should be preserved") + + // Test 1: ListObjectsV2 with prefix (no delimiter) — the exact scenario from #8712 + t.Run("ListV2_WithPrefix_NoDelimiter", func(t *testing.T) { + resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + Prefix: aws.String("test-content"), + }) + require.NoError(t, err) + + keys := collectKeys(resp.Contents) + assert.Equal(t, []string{"test-content/empty/"}, keys, + "directory marker with explicit Content-Type should be listed") + + // Verify the directory marker has zero size + require.Equal(t, 1, len(resp.Contents), "should have exactly one object in Contents") + assert.Equal(t, int64(0), aws.Int64Value(resp.Contents[0].Size), + "directory marker should have Size == 0") + }) + + // Test 2: ListObjectsV1 with prefix + t.Run("ListV1_WithPrefix_NoDelimiter", func(t *testing.T) { + resp, err := cluster.s3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + Prefix: aws.String("test-content"), + }) + require.NoError(t, err) + + keys := collectKeysV1(resp.Contents) + assert.Equal(t, []string{"test-content/empty/"}, keys, + "directory marker with explicit Content-Type should be listed") + + // Verify the directory marker has zero size + require.Equal(t, 1, len(resp.Contents), "should have exactly one object in Contents") + assert.Equal(t, int64(0), aws.Int64Value(resp.Contents[0].Size), + "directory marker should have Size == 0") + }) + + // Test 3: Without prefix + t.Run("ListV2_NoPrefix", func(t *testing.T) { + resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + keys := collectKeys(resp.Contents) + assert.Contains(t, keys, "test-content/empty/", + "directory marker with explicit Content-Type should appear in full listing") + + // Find and verify the directory marker has zero size + var dirMarker *s3.Object + for _, obj := range resp.Contents { + if aws.StringValue(obj.Key) == "test-content/empty/" { + dirMarker = obj + break + } + } + require.NotNil(t, dirMarker, "directory marker should be in Contents") + assert.Equal(t, int64(0), aws.Int64Value(dirMarker.Size), + "directory marker should have Size == 0") + }) + + // Test 4: With delimiter — should appear as CommonPrefix + t.Run("ListV2_WithDelimiter", func(t *testing.T) { + resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + Delimiter: aws.String("/"), + }) + require.NoError(t, err) + + prefixes := collectPrefixes(resp.CommonPrefixes) + assert.Contains(t, prefixes, "test-content/", + "parent of directory marker should appear as CommonPrefix") + }) + + // Test 5: Alongside a default-MIME directory marker — both should be listed + t.Run("ListV2_MixedMimeTypes", func(t *testing.T) { + _, err := cluster.s3Client.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-content/default/"), + Body: bytes.NewReader([]byte{}), + // No ContentType — defaults to httpd/unix-directory + }) + require.NoError(t, err) + + resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + Prefix: aws.String("test-content"), + }) + require.NoError(t, err) + + keys := collectKeys(resp.Contents) + sort.Strings(keys) + assert.Equal(t, []string{"test-content/default/", "test-content/empty/"}, keys, + "both directory markers (custom and default MIME) should be listed") + + // Verify both directory markers have zero size + require.Equal(t, 2, len(resp.Contents), "should have exactly two objects in Contents") + for _, obj := range resp.Contents { + assert.Equal(t, int64(0), aws.Int64Value(obj.Size), + "directory marker %s should have Size == 0", aws.StringValue(obj.Key)) + } + }) +} + func collectKeys(contents []*s3.Object) []string { keys := make([]string, 0, len(contents)) for _, obj := range contents { diff --git a/weed/s3api/s3api_object_handlers_list_test.go b/weed/s3api/s3api_object_handlers_list_test.go index ffbe81c80..975869183 100644 --- a/weed/s3api/s3api_object_handlers_list_test.go +++ b/weed/s3api/s3api_object_handlers_list_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/stretchr/testify/assert" grpc "google.golang.org/grpc" @@ -284,6 +285,67 @@ func TestAllowUnorderedParameterValidation(t *testing.T) { }) } +// TestDoListFilerEntries_DirectoryKeyObjectWithCustomMimeType is a regression test +// for GitHub issue #8712: directory markers created via PutObject with an explicit +// Content-Type (e.g., "application/octet-stream") must appear in ListObjects results. +// Only the default "httpd/unix-directory" MIME was being listed before the fix. +func TestDoListFilerEntries_DirectoryKeyObjectWithCustomMimeType(t *testing.T) { + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + tests := []struct { + name string + entry *filer_pb.Entry + }{ + {"default FolderMimeType", &filer_pb.Entry{ + Name: "empty", IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{Mime: "httpd/unix-directory"}, + }}, + {"Mime set to application/octet-stream", &filer_pb.Entry{ + Name: "empty", IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{Mime: "application/octet-stream"}, + }}, + {"custom MIME", &filer_pb.Entry{ + Name: "empty", IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{Mime: "application/x-directory"}, + }}, + // The real-world scenario for #8712: the filer drops Mime on directories, + // so only the ExtMimeType extended attribute survives. + {"Mime empty but ExtMimeType set", &filer_pb.Entry{ + Name: "empty", IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{}, + Extended: map[string][]byte{s3_constants.ExtMimeType: []byte("application/octet-stream")}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/bucket": { + {Name: "test-content", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}, + }, + "/buckets/bucket/test-content": {tt.entry}, + "/buckets/bucket/test-content/empty": {}, + }, + } + + cursor := &ListingCursor{maxKeys: 1000} + var seen []string + _, err := s3a.doListFilerEntries(client, "/buckets/bucket", "test-content", cursor, "", "", false, "bucket", func(dir string, entry *filer_pb.Entry) { + seen = append(seen, entry.Name) + }) + + assert.NoError(t, err) + assert.Contains(t, seen, "empty", + "directory key object should be listed for case %q", tt.name) + }) + } +} + func TestDoListFilerEntries_BucketRootPrefixSlashDelimiterSlash_ListsDirectories(t *testing.T) { // Regression test for a bug where doListFilerEntries returned early when // prefix == "/" && delimiter == "/", causing bucket-root folder listings