From 79c19dc1d3385bf1400966ee4c861cfeadd7265a Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Thu, 15 Oct 2026 15:38:05 +0100 Subject: [PATCH] serve s3: allow copying directory markers CopyObject with a directory marker such as "dir/" as its source failed with InvalidArgument, although HeadObject and GetObject found it. It now copies the marker, making the directory of another marker or an empty object. See #10082 --- cmd/serve/s3/backend.go | 11 +++++++++-- cmd/serve/s3/backend_test.go | 19 +++++++++++++++++++ cmd/serve/s3/serve_s3.md | 7 ++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cmd/serve/s3/backend.go b/cmd/serve/s3/backend.go index 0f1bf608e..55c4fa4b1 100644 --- a/cmd/serve/s3/backend.go +++ b/cmd/serve/s3/backend.go @@ -824,15 +824,22 @@ func (b *s3Backend) CopyObject(ctx context.Context, srcBucket, srcKey, dstBucket if err != nil { return result, err } - fp, err := bucketObjectPath(srcBucket, srcKey) + fp, isMarker, err := bucketDirMarkerPath(srcBucket, srcKey) + if !isMarker { + fp, err = bucketObjectPath(srcBucket, srcKey) + } if err != nil { return result, err } cStat, err := _vfs.Stat(fp) - if err != nil || !cStat.IsFile() { + if err != nil || cStat.IsDir() != isMarker { return result, gofakes3.KeyNotFound(srcKey) } if srcBucket == dstBucket && srcKey == dstKey { + if isMarker { + // Directory markers have no metadata to replace + return result, nil + } return result, b.tenant(ctx).storeMeta(_vfs, fp, meta) } diff --git a/cmd/serve/s3/backend_test.go b/cmd/serve/s3/backend_test.go index 5dd12fb3e..1642a7275 100644 --- a/cmd/serve/s3/backend_test.go +++ b/cmd/serve/s3/backend_test.go @@ -408,6 +408,25 @@ func TestDirMarkers(t *testing.T) { assert.True(t, gofakes3.HasErrorCode(err, gofakes3.ErrNoSuchKey), "%s: %v", key, err) } + // Copying a marker makes the directory of another marker, or an + // empty object + _, err = b.CopyObject(ctx, "bucket", "dir/sub/", "bucket", "copy/", map[string]string{}) + require.NoError(t, err) + assert.True(t, isDir("copy")) + _, err = b.CopyObject(ctx, "bucket", "dir/sub/", "bucket", "empty.txt", map[string]string{}) + require.NoError(t, err) + obj, err = b.HeadObject(ctx, "bucket", "empty.txt") + require.NoError(t, err) + assert.Equal(t, int64(0), obj.Size) + _, err = b.CopyObject(ctx, "bucket", "dir/", "bucket", "dir/", map[string]string{}) + require.NoError(t, err) + + // A copy needs the "/" for a directory and none for an object + for _, key := range []string{"dir", "empty.txt/", "missing/"} { + _, err = b.CopyObject(ctx, "bucket", key, "bucket", "other.txt", map[string]string{}) + assert.True(t, gofakes3.HasErrorCode(err, gofakes3.ErrNoSuchKey), "%s: %v", key, err) + } + // Deleting a marker removes its directory only if empty require.NoError(t, put("dir/sub/file.txt", "data")) require.NoError(t, b.deleteObject(ctx, "bucket", "dir/sub/")) diff --git a/cmd/serve/s3/serve_s3.md b/cmd/serve/s3/serve_s3.md index 3a6fdc0e8..201615351 100644 --- a/cmd/serve/s3/serve_s3.md +++ b/cmd/serve/s3/serve_s3.md @@ -493,9 +493,10 @@ A key ending in `/`, such as `dir/`, is a directory marker, standing for the directory `dir`. Putting an empty directory marker creates the directory (one with data is rejected with `400 Bad Request`), and deleting it removes the directory if it is empty. Directory markers -are found by `HeadObject` and `GetObject` for any directory, but only -the markers of empty directories are listed as objects, as other -directories may not have been made by putting a marker. +are found by `HeadObject`, `GetObject` and `CopyObject` for any +directory, but only the markers of empty directories are listed as +objects, as other directories may not have been made by putting a +marker. As keys are file paths, a key can't be both an object and a directory holding other objects, for example `a/b` and `a/b/c`. Writing an