diff --git a/lib/api/apiUtils/object/versioning.js b/lib/api/apiUtils/object/versioning.js index a9018c0a60..7fb8deb6eb 100644 --- a/lib/api/apiUtils/object/versioning.js +++ b/lib/api/apiUtils/object/versioning.js @@ -494,6 +494,29 @@ function restoreMetadata(objMD, metadataStoreParams) { } }); + metadataStoreParams.contentType = objMD['content-type']; + metadataStoreParams.cacheControl = objMD['cache-control']; + metadataStoreParams.contentDisposition = objMD['content-disposition']; + metadataStoreParams.contentEncoding = objMD['content-encoding']; + metadataStoreParams.expires = objMD.expires; + metadataStoreParams.retentionMode = objMD.retentionMode; + metadataStoreParams.retentionDate = objMD.retentionDate; + + // Object lock info and ACL come from the archived object only, not from + // the restore request or the bucket default retention + delete metadataStoreParams.defaultRetention; + if (metadataStoreParams.headers) { + metadataStoreParams.headers = { ...metadataStoreParams.headers }; + delete metadataStoreParams.headers['x-amz-object-lock-mode']; + delete metadataStoreParams.headers['x-amz-object-lock-retain-until-date']; + delete metadataStoreParams.headers['x-amz-object-lock-legal-hold']; + delete metadataStoreParams.headers['x-amz-acl']; + delete metadataStoreParams.headers['x-amz-grant-full-control']; + delete metadataStoreParams.headers['x-amz-grant-read']; + delete metadataStoreParams.headers['x-amz-grant-read-acp']; + delete metadataStoreParams.headers['x-amz-grant-write-acp']; + } + if (objMD['x-amz-website-redirect-location']) { if (!metadataStoreParams.headers) { metadataStoreParams.headers = {}; diff --git a/package.json b/package.json index 477f45a2e4..bdc263d28a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.4.5", + "version": "9.4.6", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { diff --git a/tests/functional/aws-node-sdk/test/object/mpuVersion.js b/tests/functional/aws-node-sdk/test/object/mpuVersion.js index c42b97cacd..4efecad8cb 100644 --- a/tests/functional/aws-node-sdk/test/object/mpuVersion.js +++ b/tests/functional/aws-node-sdk/test/object/mpuVersion.js @@ -1019,8 +1019,8 @@ describe('MPU with x-scal-s3-version-id header', () => { // make sure data related metadatas ar not the same before and after assert.notStrictEqual(finalObjMD['x-amz-server-side-encryption'], 'aws:kms'); assert.notStrictEqual(finalObjMD['content-length'], 99); - assert.notStrictEqual(finalObjMD['content-encoding'], 'testencoding'); - assert.notStrictEqual(finalObjMD['content-type'], 'testtype'); + assert.strictEqual(finalObjMD['content-encoding'], 'testencoding'); + assert.strictEqual(finalObjMD['content-type'], 'testtype'); // make sure we keep the same etag and add the new restored // data's etag inside x-amz-restore assert.strictEqual(finalObjMD['content-md5'], 'testmd5'); diff --git a/tests/functional/aws-node-sdk/test/object/putVersion.js b/tests/functional/aws-node-sdk/test/object/putVersion.js index d49e869d40..5c582b80df 100644 --- a/tests/functional/aws-node-sdk/test/object/putVersion.js +++ b/tests/functional/aws-node-sdk/test/object/putVersion.js @@ -1159,8 +1159,8 @@ describe('PUT object with x-scal-s3-version-id header', () => { // make sure data related metadatas ar not the same before and after assert.notStrictEqual(objMD['x-amz-server-side-encryption'], 'aws:kms'); assert.notStrictEqual(objMD['content-length'], 99); - assert.notStrictEqual(objMD['content-encoding'], 'testencoding'); - assert.notStrictEqual(objMD['content-type'], 'testtype'); + assert.strictEqual(objMD['content-encoding'], 'testencoding'); + assert.strictEqual(objMD['content-type'], 'testtype'); // make sure we keep the same etag and add the new restored // data's etag inside x-amz-restore assert.strictEqual(objMD['content-md5'], 'testmd5'); diff --git a/tests/unit/api/apiUtils/versioning.js b/tests/unit/api/apiUtils/versioning.js index c89eca7aba..cead9bafab 100644 --- a/tests/unit/api/apiUtils/versioning.js +++ b/tests/unit/api/apiUtils/versioning.js @@ -698,6 +698,16 @@ describe('versioning helpers', () => { archiveID: '126783123678', }; const now = Date.now(); + // always taken from the archived object, so cleared when it had none + const clearedSystemMD = { + contentType: undefined, + cacheControl: undefined, + contentDisposition: undefined, + contentEncoding: undefined, + expires: undefined, + retentionMode: undefined, + retentionDate: undefined, + }; let clock; beforeEach(() => { @@ -938,6 +948,240 @@ describe('versioning helpers', () => { }, }, }, + { + description: 'Should keep the system metadata of the archived object', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'content-type': 'application/zip', + 'cache-control': 'no-cache', + 'content-disposition': 'attachment; filename="archive.zip"', + 'content-encoding': 'gzip', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + 'x-amz-website-redirect-location': '/elsewhere', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + contentType: 'binary/octet-stream', + headers: {}, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + contentType: 'application/zip', + cacheControl: 'no-cache', + contentDisposition: 'attachment; filename="archive.zip"', + contentEncoding: 'gzip', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + headers: { + 'x-amz-website-redirect-location': '/elsewhere', + }, + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should drop the system metadata sent with the restore request', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + contentType: 'binary/octet-stream', + cacheControl: 'no-store', + contentDisposition: 'inline', + contentEncoding: 'identity', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should keep the object lock retention', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + retentionMode: 'GOVERNANCE', + retentionDate: '2026-10-21T07:28:00.000Z', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + retentionMode: 'GOVERNANCE', + retentionDate: '2026-10-21T07:28:00.000Z', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should not apply the bucket default retention', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + defaultRetention: { + rule: { mode: 'COMPLIANCE', days: 1 }, + }, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should drop object lock headers', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + headers: { + host: 'localhost', + 'x-amz-object-lock-mode': 'GOVERNANCE', + 'x-amz-object-lock-retain-until-date': new Date(now).toISOString(), + 'x-amz-object-lock-legal-hold': 'ON', + }, + }, + expectedRes: { + headers: { host: 'localhost' }, + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should drop ACL headers', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + headers: { + host: 'localhost', + 'x-amz-acl': 'public-read-write', + 'x-amz-grant-full-control': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-read': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-read-acp': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-write-acp': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + }, + }, + expectedRes: { + headers: { host: 'localhost' }, + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, { description: 'Should keep ACLs', objMD: { @@ -1031,7 +1275,7 @@ describe('versioning helpers', () => { } const options = overwritingVersioning(testCase.objMD, metadataStoreParams); assert.deepStrictEqual(options.versionId, testCase.objMD.versionId); - assert.deepStrictEqual(metadataStoreParams, testCase.expectedRes); + assert.deepStrictEqual(metadataStoreParams, { ...clearedSystemMD, ...testCase.expectedRes }); if (testCase.objMD.isNull) { assert.deepStrictEqual(options.extraMD.nullVersionId, 'vnull'); diff --git a/tests/unit/api/objectPut.js b/tests/unit/api/objectPut.js index 2477ad04d6..f92713f93a 100644 --- a/tests/unit/api/objectPut.js +++ b/tests/unit/api/objectPut.js @@ -299,6 +299,184 @@ describe('objectPut API', () => { ); }); + it('should not apply the bucket default retention when restoring an object without retention', done => { + const testObjLockRequest = { + bucketName, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + post: objectLockTestUtils.generateXml('COMPLIANCE', 30, 'Days'), + }; + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, testPutObjectRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => bucketPutObjectLock(authInfo, testObjLockRequest, log, next), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(md.retentionMode, undefined); + assert.strictEqual(md.retentionDate, undefined); + next(); + }), + ], + done, + ); + }); + + it('should ignore object lock headers when restoring an object', done => { + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, testPutObjectRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + 'x-amz-object-lock-mode': 'GOVERNANCE', + 'x-amz-object-lock-retain-until-date': moment().add(1, 'days').toISOString(), + 'x-amz-object-lock-legal-hold': 'ON', + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(md.retentionMode, undefined); + assert.strictEqual(md.retentionDate, undefined); + assert(!md.legalHold); + next(); + }), + ], + done, + ); + }); + + it('should ignore ACL headers when restoring an object', done => { + const putRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { 'x-amz-acl': 'public-read' }, + url: `/${bucketName}/${objectName}`, + }, + postBody, + ); + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + let originalAcl; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, putRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + originalAcl = md?.acl; + next(err); + }), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + 'x-amz-acl': 'public-read-write', + 'x-amz-grant-full-control': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(originalAcl.Canned, 'public-read'); + assert.deepStrictEqual(md.acl, originalAcl); + next(); + }), + ], + done, + ); + }); + it('should successfully put an object with legal hold ON', done => { const request = new DummyRequest( {