Files
rclone/backend/s3/s3_internal_test.go
T
Chris a3e1312d9d s3: fix Content-MD5 for Object Lock uploads and add GCS quirk
AWS S3 requires Content-MD5 for PutObject with Object Lock parameters.
Since rclone passes a non-seekable io.Reader, the SDK cannot compute
checksums automatically. Buffer the body and compute MD5 manually for
singlepart PutObject and presigned request uploads when Object Lock
parameters are set. Multipart uploads are unaffected as Object Lock
headers go on CreateMultipartUpload which has no body.

Add object_lock_supported provider quirk (default true) to allow
skipping Object Lock integration tests on providers with incomplete
S3 API support. Set to false for GCS which uses non-standard
x-goog-bypass-governance-retention header and doesn't implement
PutObjectLegalHold/GetObjectLegalHold.

Add Multipart and Presigned subtests to Object Lock integration tests
to cover all three upload paths.

Fixes #9199
2026-03-14 22:18:43 +00:00

786 lines
24 KiB
Go

package s3
import (
"bytes"
"compress/gzip"
"context"
"crypto/md5"
"errors"
"fmt"
"path"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/cache"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/fstest/fstests"
"github.com/rclone/rclone/lib/bucket"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/lib/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func gz(t *testing.T, s string) string {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, err := zw.Write([]byte(s))
require.NoError(t, err)
err = zw.Close()
require.NoError(t, err)
return buf.String()
}
func md5sum(t *testing.T, s string) string {
hash := md5.Sum([]byte(s))
return fmt.Sprintf("%x", hash)
}
func (f *Fs) InternalTestMetadata(t *testing.T) {
ctx := context.Background()
original := random.String(1000)
contents := gz(t, original)
item := fstest.NewItem("test-metadata", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
btime := time.Now()
metadata := fs.Metadata{
"cache-control": "no-cache",
"content-disposition": "inline",
"content-encoding": "gzip",
"content-language": "en-US",
"content-type": "text/plain",
"mtime": "2009-05-06T04:05:06.499999999Z",
// "tier" - read only
// "btime" - read only
}
// Cloudflare insists on decompressing `Content-Encoding: gzip` unless
// `Cache-Control: no-transform` is supplied. This is a deviation from
// AWS but we fudge the tests here rather than breaking peoples
// expectations of what Cloudflare does.
//
// This can always be overridden by using
// `--header-upload "Cache-Control: no-transform"`
if f.opt.Provider == "Cloudflare" {
metadata["cache-control"] = "no-transform"
}
obj := fstests.PutTestContentsMetadata(ctx, t, f, &item, true, contents, true, "text/html", metadata)
defer func() {
assert.NoError(t, obj.Remove(ctx))
}()
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
for k, v := range metadata {
got := gotMetadata[k]
switch k {
case "mtime":
assert.True(t, fstest.Time(v).Equal(fstest.Time(got)))
case "btime":
gotBtime := fstest.Time(got)
dt := gotBtime.Sub(btime)
assert.True(t, dt < time.Minute && dt > -time.Minute, fmt.Sprintf("btime more than 1 minute out want %v got %v delta %v", btime, gotBtime, dt))
assert.True(t, fstest.Time(v).Equal(fstest.Time(got)))
case "tier":
assert.NotEqual(t, "", got)
default:
assert.Equal(t, v, got, k)
}
}
t.Run("GzipEncoding", func(t *testing.T) {
// Test that the gzipped file we uploaded can be
// downloaded with and without decompression
checkDownload := func(wantContents string, wantSize int64, wantHash string) {
gotContents := fstests.ReadObject(ctx, t, o, -1)
assert.Equal(t, wantContents, gotContents)
assert.Equal(t, wantSize, o.Size())
gotHash, err := o.Hash(ctx, hash.MD5)
require.NoError(t, err)
assert.Equal(t, wantHash, gotHash)
}
t.Run("NoDecompress", func(t *testing.T) {
checkDownload(contents, int64(len(contents)), md5sum(t, contents))
})
t.Run("Decompress", func(t *testing.T) {
f.opt.Decompress = true
defer func() {
f.opt.Decompress = false
}()
checkDownload(original, -1, "")
})
})
}
func (f *Fs) InternalTestNoHead(t *testing.T) {
ctx := context.Background()
// Set NoHead for this test
f.opt.NoHead = true
defer func() {
f.opt.NoHead = false
}()
contents := random.String(1000)
item := fstest.NewItem("test-no-head", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
assert.NoError(t, obj.Remove(ctx))
}()
// PutTestcontents checks the received object
}
func TestVersionLess(t *testing.T) {
key1 := "key1"
key2 := "key2"
t1 := fstest.Time("2022-01-21T12:00:00+01:00")
t2 := fstest.Time("2022-01-21T12:00:01+01:00")
for n, test := range []struct {
a, b *types.ObjectVersion
want bool
}{
{a: nil, b: nil, want: true},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1}, b: nil, want: false},
{a: nil, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: true},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: false},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1}, b: &types.ObjectVersion{Key: &key1, LastModified: &t2}, want: false},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t2}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: true},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1}, b: &types.ObjectVersion{Key: &key2, LastModified: &t1}, want: true},
{a: &types.ObjectVersion{Key: &key2, LastModified: &t1}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: false},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1, IsLatest: aws.Bool(false)}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: false},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1, IsLatest: aws.Bool(true)}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1}, want: true},
{a: &types.ObjectVersion{Key: &key1, LastModified: &t1, IsLatest: aws.Bool(false)}, b: &types.ObjectVersion{Key: &key1, LastModified: &t1, IsLatest: aws.Bool(true)}, want: false},
} {
got := versionLess(test.a, test.b)
assert.Equal(t, test.want, got, fmt.Sprintf("%d: %+v", n, test))
}
}
func TestMergeDeleteMarkers(t *testing.T) {
key1 := "key1"
key2 := "key2"
t1 := fstest.Time("2022-01-21T12:00:00+01:00")
t2 := fstest.Time("2022-01-21T12:00:01+01:00")
for n, test := range []struct {
versions []types.ObjectVersion
markers []types.DeleteMarkerEntry
want []types.ObjectVersion
}{
{
versions: []types.ObjectVersion{},
markers: []types.DeleteMarkerEntry{},
want: []types.ObjectVersion{},
},
{
versions: []types.ObjectVersion{
{
Key: &key1,
LastModified: &t1,
},
},
markers: []types.DeleteMarkerEntry{},
want: []types.ObjectVersion{
{
Key: &key1,
LastModified: &t1,
},
},
},
{
versions: []types.ObjectVersion{},
markers: []types.DeleteMarkerEntry{
{
Key: &key1,
LastModified: &t1,
},
},
want: []types.ObjectVersion{
{
Key: &key1,
LastModified: &t1,
Size: isDeleteMarker,
},
},
},
{
versions: []types.ObjectVersion{
{
Key: &key1,
LastModified: &t2,
},
{
Key: &key2,
LastModified: &t2,
},
},
markers: []types.DeleteMarkerEntry{
{
Key: &key1,
LastModified: &t1,
},
},
want: []types.ObjectVersion{
{
Key: &key1,
LastModified: &t2,
},
{
Key: &key1,
LastModified: &t1,
Size: isDeleteMarker,
},
{
Key: &key2,
LastModified: &t2,
},
},
},
} {
got := mergeDeleteMarkers(test.versions, test.markers)
assert.Equal(t, test.want, got, fmt.Sprintf("%d: %+v", n, test))
}
}
func TestRemoveAWSChunked(t *testing.T) {
ps := func(s string) *string {
return &s
}
tests := []struct {
name string
in *string
want *string
}{
{"nil", nil, nil},
{"empty", ps(""), nil},
{"only aws", ps("aws-chunked"), nil},
{"leading aws", ps("aws-chunked, gzip"), ps("gzip")},
{"trailing aws", ps("gzip, aws-chunked"), ps("gzip")},
{"middle aws", ps("gzip, aws-chunked, br"), ps("gzip,br")},
{"case insensitive", ps("GZip, AwS-ChUnKeD, Br"), ps("GZip,Br")},
{"duplicates", ps("aws-chunked , aws-chunked"), nil},
{"no aws normalize spaces", ps(" gzip , br "), ps(" gzip , br ")},
{"surrounding spaces", ps(" aws-chunked "), nil},
{"no change", ps("gzip, br"), ps("gzip, br")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := removeAWSChunked(tc.in)
check := func(want, got *string) {
t.Helper()
if tc.want == nil {
assert.Nil(t, got)
} else {
require.NotNil(t, got)
assert.Equal(t, *tc.want, *got)
}
}
check(tc.want, got)
// Idempotent
got2 := removeAWSChunked(got)
check(got, got2)
})
}
}
func (f *Fs) InternalTestVersions(t *testing.T) {
ctx := context.Background()
// Enable versioning for this bucket during this test
_, err := f.setGetVersioning(ctx, "Enabled")
if err != nil {
t.Skipf("Couldn't enable versioning: %v", err)
}
defer func() {
// Disable versioning for this bucket
_, err := f.setGetVersioning(ctx, "Suspended")
assert.NoError(t, err)
}()
// Small pause to make the LastModified different since AWS
// only seems to track them to 1 second granularity
time.Sleep(2 * time.Second)
// Create an object
const dirName = "versions"
const fileName = dirName + "/" + "test-versions.txt"
contents := random.String(100)
item := fstest.NewItem(fileName, contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
assert.NoError(t, obj.Remove(ctx))
}()
// Small pause
time.Sleep(2 * time.Second)
// Remove it
assert.NoError(t, obj.Remove(ctx))
// Small pause to make the LastModified different since AWS only seems to track them to 1 second granularity
time.Sleep(2 * time.Second)
// And create it with different size and contents
newContents := random.String(101)
newItem := fstest.NewItem(fileName, newContents, fstest.Time("2002-05-06T04:05:06.499999999Z"))
newObj := fstests.PutTestContents(ctx, t, f, &newItem, newContents, true)
t.Run("Versions", func(t *testing.T) {
// Set --s3-versions for this test
f.opt.Versions = true
defer func() {
f.opt.Versions = false
}()
// Read the contents
entries, err := f.List(ctx, dirName)
require.NoError(t, err)
tests := 0
var fileNameVersion string
for _, entry := range entries {
t.Log(entry)
remote := entry.Remote()
if remote == fileName {
t.Run("ReadCurrent", func(t *testing.T) {
assert.Equal(t, newContents, fstests.ReadObject(ctx, t, entry.(fs.Object), -1))
})
tests++
} else if versionTime, p := version.Remove(remote); !versionTime.IsZero() && p == fileName {
t.Run("ReadVersion", func(t *testing.T) {
assert.Equal(t, contents, fstests.ReadObject(ctx, t, entry.(fs.Object), -1))
})
assert.WithinDuration(t, obj.(*Object).lastModified, versionTime, time.Second, "object time must be with 1 second of version time")
fileNameVersion = remote
tests++
}
}
assert.Equal(t, 2, tests, "object missing from listing")
// Check we can read the object with a version suffix
t.Run("NewObject", func(t *testing.T) {
o, err := f.NewObject(ctx, fileNameVersion)
require.NoError(t, err)
require.NotNil(t, o)
assert.Equal(t, int64(100), o.Size(), o.Remote())
})
// Check we can make a NewFs from that object with a version suffix
t.Run("NewFs", func(t *testing.T) {
newPath := bucket.Join(fs.ConfigStringFull(f), fileNameVersion)
// Make sure --s3-versions is set in the config of the new remote
fs.Debugf(nil, "oldPath = %q", newPath)
lastColon := strings.LastIndex(newPath, ":")
require.True(t, lastColon >= 0)
newPath = newPath[:lastColon] + ",versions" + newPath[lastColon:]
fs.Debugf(nil, "newPath = %q", newPath)
fNew, err := cache.Get(ctx, newPath)
// This should return pointing to a file
require.Equal(t, fs.ErrorIsFile, err)
require.NotNil(t, fNew)
// With the directory the directory above
assert.Equal(t, dirName, path.Base(fs.ConfigStringFull(fNew)))
})
})
t.Run("VersionAt", func(t *testing.T) {
// We set --s3-version-at for this test so make sure we reset it at the end
defer func() {
f.opt.VersionAt = fs.Time{}
}()
var (
firstObjectTime = obj.(*Object).lastModified
secondObjectTime = newObj.(*Object).lastModified
)
for _, test := range []struct {
what string
at time.Time
want []fstest.Item
wantErr error
wantSize int64
}{
{
what: "Before",
at: firstObjectTime.Add(-time.Second),
want: fstests.InternalTestFiles,
wantErr: fs.ErrorObjectNotFound,
},
{
what: "AfterOne",
at: firstObjectTime.Add(time.Second),
want: append([]fstest.Item{item}, fstests.InternalTestFiles...),
wantSize: 100,
},
{
what: "AfterDelete",
at: secondObjectTime.Add(-time.Second),
want: fstests.InternalTestFiles,
wantErr: fs.ErrorObjectNotFound,
},
{
what: "AfterTwo",
at: secondObjectTime.Add(time.Second),
want: append([]fstest.Item{newItem}, fstests.InternalTestFiles...),
wantSize: 101,
},
} {
t.Run(test.what, func(t *testing.T) {
f.opt.VersionAt = fs.Time(test.at)
t.Run("List", func(t *testing.T) {
fstest.CheckListing(t, f, test.want)
})
t.Run("NewObject", func(t *testing.T) {
gotObj, gotErr := f.NewObject(ctx, fileName)
assert.Equal(t, test.wantErr, gotErr)
if gotErr == nil {
assert.Equal(t, test.wantSize, gotObj.Size())
}
})
})
}
})
t.Run("Mkdir", func(t *testing.T) {
// Test what happens when we create a bucket we already own and see whether the
// quirk is set correctly
req := s3.CreateBucketInput{
Bucket: &f.rootBucket,
ACL: types.BucketCannedACL(f.opt.BucketACL),
}
if f.opt.LocationConstraint != "" {
req.CreateBucketConfiguration = &types.CreateBucketConfiguration{
LocationConstraint: types.BucketLocationConstraint(f.opt.LocationConstraint),
}
}
err := f.pacer.Call(func() (bool, error) {
_, err := f.c.CreateBucket(ctx, &req)
return f.shouldRetry(ctx, err)
})
var errString string
var awsError smithy.APIError
if err == nil {
errString = "No Error"
} else if errors.As(err, &awsError) {
errString = awsError.ErrorCode()
} else {
assert.Fail(t, "Unknown error %T %v", err, err)
}
t.Logf("Creating a bucket we already have created returned code: %s", errString)
switch errString {
case "BucketAlreadyExists":
assert.False(t, f.opt.UseAlreadyExists.Value, "Need to clear UseAlreadyExists quirk")
case "No Error", "BucketAlreadyOwnedByYou":
assert.True(t, f.opt.UseAlreadyExists.Value, "Need to set UseAlreadyExists quirk")
default:
assert.Fail(t, "Unknown error string %q", errString)
}
})
t.Run("Cleanup", func(t *testing.T) {
require.NoError(t, f.CleanUpHidden(ctx))
items := append([]fstest.Item{newItem}, fstests.InternalTestFiles...)
fstest.CheckListing(t, f, items)
// Set --s3-versions for this test
f.opt.Versions = true
defer func() {
f.opt.Versions = false
}()
fstest.CheckListing(t, f, items)
})
// Purge gets tested later
}
func (f *Fs) InternalTestObjectLock(t *testing.T) {
if !f.opt.ObjectLockSupported.Value {
t.Skip("Object Lock not supported by this provider (quirk object_lock_supported = false)")
}
ctx := context.Background()
// Create a temporary bucket with Object Lock enabled to test on.
// This exercises our BucketObjectLockEnabled option and isolates
// the test from the main test bucket.
lockBucket := f.rootBucket + "-object-lock-" + random.String(8)
lockBucket = strings.ToLower(lockBucket)
// Try to create bucket with Object Lock enabled
objectLockEnabled := true
req := s3.CreateBucketInput{
Bucket: &lockBucket,
ACL: types.BucketCannedACL(f.opt.BucketACL),
ObjectLockEnabledForBucket: &objectLockEnabled,
}
if f.opt.LocationConstraint != "" {
req.CreateBucketConfiguration = &types.CreateBucketConfiguration{
LocationConstraint: types.BucketLocationConstraint(f.opt.LocationConstraint),
}
}
err := f.pacer.Call(func() (bool, error) {
_, err := f.c.CreateBucket(ctx, &req)
return f.shouldRetry(ctx, err)
})
if err != nil {
t.Skipf("Object Lock not supported by this provider: CreateBucket with Object Lock failed: %v", err)
}
// Verify Object Lock is actually enabled on the new bucket.
// Some S3-compatible servers (e.g. rclone serve s3) accept the
// ObjectLockEnabledForBucket flag but don't actually implement Object Lock.
var lockCfg *s3.GetObjectLockConfigurationOutput
err = f.pacer.Call(func() (bool, error) {
var err error
lockCfg, err = f.c.GetObjectLockConfiguration(ctx, &s3.GetObjectLockConfigurationInput{
Bucket: &lockBucket,
})
return f.shouldRetry(ctx, err)
})
if err != nil || lockCfg.ObjectLockConfiguration == nil ||
lockCfg.ObjectLockConfiguration.ObjectLockEnabled != types.ObjectLockEnabledEnabled {
_ = f.pacer.Call(func() (bool, error) {
_, err := f.c.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: &lockBucket})
return f.shouldRetry(ctx, err)
})
t.Skipf("Object Lock not functional on this provider (GetObjectLockConfiguration: %v)", err)
}
// Switch f to use the Object Lock bucket for this test
oldBucket := f.rootBucket
oldRoot := f.root
oldRootDir := f.rootDirectory
f.rootBucket = lockBucket
f.root = lockBucket
f.rootDirectory = ""
defer func() {
f.rootBucket = oldBucket
f.root = oldRoot
f.rootDirectory = oldRootDir
}()
// Helper to remove an object with Object Lock protection
removeLocked := func(t *testing.T, obj fs.Object) {
t.Helper()
o := obj.(*Object)
// Remove legal hold if present
_ = o.setObjectLegalHold(ctx, types.ObjectLockLegalHoldStatusOff)
// Enable bypass governance retention for deletion
o.fs.opt.BypassGovernanceRetention = true
err := obj.Remove(ctx)
o.fs.opt.BypassGovernanceRetention = false
assert.NoError(t, err)
}
// Clean up the temporary bucket after all sub-tests
defer func() {
// List and remove all object versions
var objectVersions []types.ObjectIdentifier
listReq := &s3.ListObjectVersionsInput{Bucket: &lockBucket}
for {
var resp *s3.ListObjectVersionsOutput
err := f.pacer.Call(func() (bool, error) {
var err error
resp, err = f.c.ListObjectVersions(ctx, listReq)
return f.shouldRetry(ctx, err)
})
if err != nil {
t.Logf("Failed to list object versions for cleanup: %v", err)
break
}
for _, v := range resp.Versions {
objectVersions = append(objectVersions, types.ObjectIdentifier{
Key: v.Key,
VersionId: v.VersionId,
})
}
for _, m := range resp.DeleteMarkers {
objectVersions = append(objectVersions, types.ObjectIdentifier{
Key: m.Key,
VersionId: m.VersionId,
})
}
if !aws.ToBool(resp.IsTruncated) {
break
}
listReq.KeyMarker = resp.NextKeyMarker
listReq.VersionIdMarker = resp.NextVersionIdMarker
}
if len(objectVersions) > 0 {
bypass := true
_ = f.pacer.Call(func() (bool, error) {
_, err := f.c.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: &lockBucket,
BypassGovernanceRetention: &bypass,
Delete: &types.Delete{
Objects: objectVersions,
Quiet: aws.Bool(true),
},
})
return f.shouldRetry(ctx, err)
})
}
_ = f.pacer.Call(func() (bool, error) {
_, err := f.c.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: &lockBucket})
return f.shouldRetry(ctx, err)
})
}()
retainUntilDate := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
t.Run("Retention", func(t *testing.T) {
// Set Object Lock options for this test
f.opt.ObjectLockMode = "GOVERNANCE"
f.opt.ObjectLockRetainUntilDate = retainUntilDate.Format(time.RFC3339)
defer func() {
f.opt.ObjectLockMode = ""
f.opt.ObjectLockRetainUntilDate = ""
}()
// Upload an object with Object Lock retention
contents := random.String(100)
item := fstest.NewItem("test-object-lock-retention", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
removeLocked(t, obj)
}()
// Read back metadata and verify Object Lock settings
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "GOVERNANCE", gotMetadata["object-lock-mode"])
gotRetainDate, err := time.Parse(time.RFC3339, gotMetadata["object-lock-retain-until-date"])
require.NoError(t, err)
assert.WithinDuration(t, retainUntilDate, gotRetainDate, time.Second)
})
t.Run("LegalHold", func(t *testing.T) {
// Set Object Lock legal hold option
f.opt.ObjectLockLegalHoldStatus = "ON"
defer func() {
f.opt.ObjectLockLegalHoldStatus = ""
}()
// Upload an object with legal hold
contents := random.String(100)
item := fstest.NewItem("test-object-lock-legal-hold", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
removeLocked(t, obj)
}()
// Verify legal hold is ON
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "ON", gotMetadata["object-lock-legal-hold-status"])
// Set legal hold to OFF
err = o.setObjectLegalHold(ctx, types.ObjectLockLegalHoldStatusOff)
require.NoError(t, err)
// Clear cached metadata and re-read
o.meta = nil
gotMetadata, err = o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "OFF", gotMetadata["object-lock-legal-hold-status"])
})
t.Run("SetAfterUpload", func(t *testing.T) {
// Test the post-upload API path (PutObjectRetention + PutObjectLegalHold)
f.opt.ObjectLockSetAfterUpload = true
f.opt.ObjectLockMode = "GOVERNANCE"
f.opt.ObjectLockRetainUntilDate = retainUntilDate.Format(time.RFC3339)
f.opt.ObjectLockLegalHoldStatus = "ON"
defer func() {
f.opt.ObjectLockSetAfterUpload = false
f.opt.ObjectLockMode = ""
f.opt.ObjectLockRetainUntilDate = ""
f.opt.ObjectLockLegalHoldStatus = ""
}()
// Upload an object - lock applied AFTER upload via separate API calls
contents := random.String(100)
item := fstest.NewItem("test-object-lock-after-upload", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
removeLocked(t, obj)
}()
// Verify all Object Lock settings were applied
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "GOVERNANCE", gotMetadata["object-lock-mode"])
gotRetainDate, err := time.Parse(time.RFC3339, gotMetadata["object-lock-retain-until-date"])
require.NoError(t, err)
assert.WithinDuration(t, retainUntilDate, gotRetainDate, time.Second)
assert.Equal(t, "ON", gotMetadata["object-lock-legal-hold-status"])
})
t.Run("Multipart", func(t *testing.T) {
// Force multipart upload by setting a very low cutoff
oldCutoff := f.opt.UploadCutoff
f.opt.UploadCutoff = fs.SizeSuffix(1)
f.opt.ObjectLockMode = "GOVERNANCE"
f.opt.ObjectLockRetainUntilDate = retainUntilDate.Format(time.RFC3339)
defer func() {
f.opt.UploadCutoff = oldCutoff
f.opt.ObjectLockMode = ""
f.opt.ObjectLockRetainUntilDate = ""
}()
contents := random.String(100)
item := fstest.NewItem("test-object-lock-multipart", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
removeLocked(t, obj)
}()
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "GOVERNANCE", gotMetadata["object-lock-mode"])
})
t.Run("Presigned", func(t *testing.T) {
// Use presigned request upload path
f.opt.UsePresignedRequest = true
f.opt.ObjectLockMode = "GOVERNANCE"
f.opt.ObjectLockRetainUntilDate = retainUntilDate.Format(time.RFC3339)
defer func() {
f.opt.UsePresignedRequest = false
f.opt.ObjectLockMode = ""
f.opt.ObjectLockRetainUntilDate = ""
}()
contents := random.String(100)
item := fstest.NewItem("test-object-lock-presigned", contents, fstest.Time("2001-05-06T04:05:06.499999999Z"))
obj := fstests.PutTestContents(ctx, t, f, &item, contents, true)
defer func() {
removeLocked(t, obj)
}()
o := obj.(*Object)
gotMetadata, err := o.Metadata(ctx)
require.NoError(t, err)
assert.Equal(t, "GOVERNANCE", gotMetadata["object-lock-mode"])
})
}
func (f *Fs) InternalTest(t *testing.T) {
t.Run("Metadata", f.InternalTestMetadata)
t.Run("NoHead", f.InternalTestNoHead)
t.Run("Versions", f.InternalTestVersions)
t.Run("ObjectLock", f.InternalTestObjectLock)
}
var _ fstests.InternalTester = (*Fs)(nil)