diff --git a/lib/api/completeMultipartUpload.js b/lib/api/completeMultipartUpload.js index 1f693d1f5a..feb1a09bc1 100644 --- a/lib/api/completeMultipartUpload.js +++ b/lib/api/completeMultipartUpload.js @@ -52,30 +52,24 @@ const allChecksumXmlTags = Object.values(checksumAlgorithms).map(algo => algo.xm * does not match the stored part's ChecksumValue, return InvalidPart. * - If checksumType === 'COMPOSITE' and checksumIsDefault is false, every part * in the request body MUST include the matching Checksum field; - * missing → InvalidRequest. (Relaxed for external backends, which store no - * per-part checksum - but a checksum the client does submit is still checked, - * and rejected, since there is no stored value to match.) + * missing → InvalidRequest. * * @param {object} jsonList - parsed CompleteMultipartUpload XML * @param {array} storedParts - parts as returned by services.getMPUparts * @param {string} mpuSplitter - splitter used in part keys * @param {object} mpuChecksum - { algorithm, type, isDefault } - * @param {boolean} isExternal - external-backend MPU; relax the COMPOSITE - * per-part requirement (external parts carry no stored checksum) * @returns {Error|null} */ -function validatePerPartChecksums(jsonList, storedParts, mpuSplitter, mpuChecksum, isExternal) { +function validatePerPartChecksums(jsonList, storedParts, mpuSplitter, mpuChecksum) { const mpuAlgo = mpuChecksum.algorithm; if (!mpuAlgo) { - // Legacy / pre-checksums MPU, no algorithm tracked, nothing to validate. + // Legacy / pre-checksums MPU, or external-backend MPU, + // no algorithm tracked, nothing to validate. return null; } const expectedTag = checksumAlgorithms[mpuAlgo] ? checksumAlgorithms[mpuAlgo].xmlTag : null; // Skip enforcement if the MPU's algorithm is unknown (shouldn't happen). - // External backends store no per-part checksum, so don't require one; a - // checksum the client does submit is still rejected below (no stored value). - const requireForEachPart = - mpuChecksum.type === 'COMPOSITE' && !mpuChecksum.isDefault && expectedTag !== null && !isExternal; + const requireForEachPart = mpuChecksum.type === 'COMPOSITE' && !mpuChecksum.isDefault && expectedTag !== null; const storedByPartNumber = new Map(); storedParts.forEach(item => { @@ -357,14 +351,21 @@ function completeMultipartUpload(authInfo, request, log, callback) { } const mpuType = storedMetadata.checksumType; if (!mpuType) { - // Legacy MPU created before checksumType was tracked. - const typeErr = errorInstances.InvalidRequest.customizeDescription( - 'The upload was not created with a checksum mode. ' + - 'The complete request must not include a x-amz-checksum-type header.', - ); - return next(typeErr, destBucket); - } - if (headerTypeUpper !== mpuType.toUpperCase()) { + // External-backend MPUs record no checksum config + // (CLDSRV-964): ignore the header, like every other + // checksum input on CompleteMPU for external backends. + const mpuLocation = storedMetadata.controllingLocationConstraint; + const isExternalMpu = + !!constants.externalBackends[config.getLocationConstraintType(mpuLocation)]; + if (!isExternalMpu) { + // Legacy MPU created before checksumType was tracked. + const typeErr = errorInstances.InvalidRequest.customizeDescription( + 'The upload was not created with a checksum mode. ' + + 'The complete request must not include a x-amz-checksum-type header.', + ); + return next(typeErr, destBucket); + } + } else if (headerTypeUpper !== mpuType.toUpperCase()) { const typeErr = errorInstances.InvalidRequest.customizeDescription( `The upload was created using the ${mpuType} checksum mode. ` + 'The complete request must use the same checksum mode.', @@ -469,14 +470,7 @@ function completeMultipartUpload(authInfo, request, log, callback) { type: storedMetadata.checksumType, isDefault: storedMetadata.checksumIsDefault, }; - const isExternalMpu = !!constants.externalBackends[config.getLocationConstraintType(location)]; - const checksumErr = validatePerPartChecksums( - jsonList, - storedParts, - splitter, - mpuChecksum, - isExternalMpu, - ); + const checksumErr = validatePerPartChecksums(jsonList, storedParts, splitter, mpuChecksum); if (checksumErr) { log.debug('per-part checksum validation failed', { error: checksumErr, @@ -611,11 +605,11 @@ function completeMultipartUpload(authInfo, request, log, callback) { totalMPUSize, next, ) { - // External-handled MPUs (ingestion / external backends) come in - // with completeObjData set and no filteredPartsObj — the data - // store already aggregated the parts, and we have no per-part - // info to feed the compute step. Skip in that case. - if (!filteredPartsObj) { + // Skip the final-checksum compute and its header validation: + // - if no filteredPartsObj then there is no per-part info to compute from (aws_s3/gcp/ingestion + // return no filteredPartsObj; azure returns filteredPartsObj, but its parts store no checksum) + // - if completeObjData is present it means the MPU was completed by an external backend + if (!filteredPartsObj || completeObjData) { return continueProcessParts(null); } computeFinalChecksum( diff --git a/lib/api/initiateMultipartUpload.js b/lib/api/initiateMultipartUpload.js index bc6aa0df29..a5331f890e 100644 --- a/lib/api/initiateMultipartUpload.js +++ b/lib/api/initiateMultipartUpload.js @@ -11,21 +11,20 @@ const { cleanUpBucket } = require('./apiUtils/bucket/bucketCreation'); const constants = require('../../constants'); const services = require('../services'); const { standardMetadataValidateBucketAndObj } = require('../metadata/metadataUtils'); -const locationConstraintCheck - = require('./apiUtils/object/locationConstraintCheck'); -const validateWebsiteHeader = require('./apiUtils/object/websiteServing') - .validateWebsiteHeader; +const locationConstraintCheck = require('./apiUtils/object/locationConstraintCheck'); +const validateWebsiteHeader = require('./apiUtils/object/websiteServing').validateWebsiteHeader; const monitoring = require('../utilities/monitoringHandler'); const { data } = require('../data/wrapper'); const applyZenkoUserMD = require('./apiUtils/object/applyZenkoUserMD'); -const { validateHeaders, compareObjectLockInformation } = - require('./apiUtils/object/objectLockHelpers'); +const { validateHeaders, compareObjectLockInformation } = require('./apiUtils/object/objectLockHelpers'); const { getObjectSSEConfiguration } = require('./apiUtils/bucket/bucketEncryption'); const { setExpirationHeaders } = require('./apiUtils/object/expirationHeaders'); const { setSSEHeaders } = require('./apiUtils/object/sseHeaders'); const { updateEncryption } = require('./apiUtils/bucket/updateEncryption'); -const { getChecksumDataFromMPUHeaders, arsenalErrorFromChecksumError } = - require('./apiUtils/integrity/validateChecksums'); +const { + getChecksumDataFromMPUHeaders, + arsenalErrorFromChecksumError, +} = require('./apiUtils/integrity/validateChecksums'); const { config } = require('../Config'); const kms = require('../kms/wrapper'); @@ -58,9 +57,9 @@ function initiateMultipartUpload(authInfo, request, log, callback) { const objectKey = request.objectKey; if (hasNonPrintables(objectKey)) { - return callback(errorInstances.InvalidInput.customizeDescription( - 'object keys cannot contain non-printable characters', - )); + return callback( + errorInstances.InvalidInput.customizeDescription('object keys cannot contain non-printable characters'), + ); } const keyLengthError = validateObjectKeyLength(objectKey, config.objectKeyByteLimit); @@ -74,19 +73,18 @@ function initiateMultipartUpload(authInfo, request, log, callback) { // there is the possiblity that the chosen splitter will occur in the object // name itself. To prevent this, we are restricting the creation of a // multipart upload object with a key containing the splitter. - const websiteRedirectHeader = - request.headers['x-amz-website-redirect-location']; - if (request.headers['x-amz-storage-class'] && - !constants.validStorageClasses.includes(request.headers['x-amz-storage-class'])) { + const websiteRedirectHeader = request.headers['x-amz-website-redirect-location']; + if ( + request.headers['x-amz-storage-class'] && + !constants.validStorageClasses.includes(request.headers['x-amz-storage-class']) + ) { log.trace('invalid storage-class header'); - monitoring.promMetrics('PUT', bucketName, - errorInstances.InvalidStorageClass.code, 'initiateMultipartUpload'); + monitoring.promMetrics('PUT', bucketName, errorInstances.InvalidStorageClass.code, 'initiateMultipartUpload'); return callback(errors.InvalidStorageClass); } if (!validateWebsiteHeader(websiteRedirectHeader)) { const err = errors.InvalidRedirectLocation; - log.debug('invalid x-amz-website-redirect-location' + - `value ${websiteRedirectHeader}`, { error: err }); + log.debug('invalid x-amz-website-redirect-location' + `value ${websiteRedirectHeader}`, { error: err }); return callback(err); } const checksumConfig = getChecksumDataFromMPUHeaders(request.headers); @@ -112,12 +110,10 @@ function initiateMultipartUpload(authInfo, request, log, callback) { // but after authentication so that string to sign is not impacted // This is GH Issue#89 // TODO: remove in CLDSRV-639 - const storageClassOptions = - ['standard', 'standard_ia', 'reduced_redundancy']; + const storageClassOptions = ['standard', 'standard_ia', 'reduced_redundancy']; let storageClass = 'STANDARD'; if (storageClassOptions.indexOf(request.headers['x-amz-storage-class']) > -1) { - storageClass = request.headers['x-amz-storage-class'] - .toUpperCase(); + storageClass = request.headers['x-amz-storage-class'].toUpperCase(); } const metadataValParams = { objectKey, @@ -163,6 +159,13 @@ function initiateMultipartUpload(authInfo, request, log, callback) { } function _getMPUBucket(destinationBucket, log, corsHeaders, uploadId, cipherBundle, locConstraint, callback) { + // External backends don't support MPU checksums so delete them from the metadata. + const isExternalLocation = !!constants.externalBackends[config.getLocationConstraintType(locConstraint)]; + if (isExternalLocation) { + delete metadataStoreParams.checksumAlgorithm; + delete metadataStoreParams.checksumType; + delete metadataStoreParams.checksumIsDefault; + } const xmlParams = { bucketName, objectKey, @@ -171,64 +174,68 @@ function initiateMultipartUpload(authInfo, request, log, callback) { const xml = convertToXml('initiateMultipartUpload', xmlParams); metadataStoreParams.uploadId = uploadId; - services.getMPUBucket(destinationBucket, bucketName, log, - (err, MPUbucket) => { - if (err) { - log.trace('error getting MPUbucket', { - error: err, - }); - return callback(err); - } - // BACKWARD: Remove to remove the old splitter - if (MPUbucket.getMdBucketModelVersion() < 2) { - metadataStoreParams.splitter = constants.oldSplitter; - } - return services.metadataStoreMPObject(MPUbucket.getName(), - cipherBundle, metadataStoreParams, - log, (err, mpuMD) => { - if (err) { - log.trace('error storing multipart object', { - error: err, - }); - monitoring.promMetrics('PUT', bucketName, err.code, - 'initiateMultipartUpload'); - return callback(err, null, corsHeaders); - } - log.addDefaultFields({ uploadId }); - log.trace('successfully initiated mpu'); - pushMetric('initiateMultipartUpload', log, { - authInfo, - bucket: bucketName, - keys: [objectKey], - location: locConstraint, + services.getMPUBucket(destinationBucket, bucketName, log, (err, MPUbucket) => { + if (err) { + log.trace('error getting MPUbucket', { + error: err, + }); + return callback(err); + } + // BACKWARD: Remove to remove the old splitter + if (MPUbucket.getMdBucketModelVersion() < 2) { + metadataStoreParams.splitter = constants.oldSplitter; + } + return services.metadataStoreMPObject( + MPUbucket.getName(), + cipherBundle, + metadataStoreParams, + log, + (err, mpuMD) => { + if (err) { + log.trace('error storing multipart object', { + error: err, }); + monitoring.promMetrics('PUT', bucketName, err.code, 'initiateMultipartUpload'); + return callback(err, null, corsHeaders); + } + log.addDefaultFields({ uploadId }); + log.trace('successfully initiated mpu'); + pushMetric('initiateMultipartUpload', log, { + authInfo, + bucket: bucketName, + keys: [objectKey], + location: locConstraint, + }); - // TODO: rename corsHeaders to headers - setExpirationHeaders(corsHeaders, { - lifecycleConfig: destinationBucket.getLifecycleConfiguration(), - mpuParams: { - key: mpuMD.key, - date: mpuMD.initiated, - }, - }); + // TODO: rename corsHeaders to headers + setExpirationHeaders(corsHeaders, { + lifecycleConfig: destinationBucket.getLifecycleConfiguration(), + mpuParams: { + key: mpuMD.key, + date: mpuMD.initiated, + }, + }); - setSSEHeaders(corsHeaders, - mpuMD['x-amz-server-side-encryption'], - mpuMD['x-amz-server-side-encryption-aws-kms-key-id']); + setSSEHeaders( + corsHeaders, + mpuMD['x-amz-server-side-encryption'], + mpuMD['x-amz-server-side-encryption-aws-kms-key-id'], + ); - // Only respond the headers if the user sent them - if (!checksumConfig.isDefault) { - // eslint-disable-next-line no-param-reassign - corsHeaders['x-amz-checksum-algorithm'] = checksumConfig.algorithm.toUpperCase(); - // eslint-disable-next-line no-param-reassign - corsHeaders['x-amz-checksum-type'] = checksumConfig.type; - } + // Only respond the headers if the user sent them and + // the MPU can honor them (not an external backend). + if (!checksumConfig.isDefault && !isExternalLocation) { + // eslint-disable-next-line no-param-reassign + corsHeaders['x-amz-checksum-algorithm'] = checksumConfig.algorithm.toUpperCase(); + // eslint-disable-next-line no-param-reassign + corsHeaders['x-amz-checksum-type'] = checksumConfig.type; + } - monitoring.promMetrics('PUT', bucketName, '200', - 'initiateMultipartUpload'); - return callback(null, xml, corsHeaders); - }); - }); + monitoring.promMetrics('PUT', bucketName, '200', 'initiateMultipartUpload'); + return callback(null, xml, corsHeaders); + }, + ); + }); } function _storetheMPObject(destinationBucket, corsHeaders, serverSideEncryption) { @@ -243,8 +250,7 @@ function initiateMultipartUpload(authInfo, request, log, callback) { masterKeyId: configuredMasterKeyId || masterKeyId, }; } - const backendInfoObj = locationConstraintCheck(request, null, - destinationBucket, log); + const backendInfoObj = locationConstraintCheck(request, null, destinationBucket, log); if (backendInfoObj.err) { return process.nextTick(() => { callback(backendInfoObj.err); @@ -255,21 +261,17 @@ function initiateMultipartUpload(authInfo, request, log, callback) { metadataStoreParams.dataStoreName = locConstraint; if (request.headers) { - const objectLockValError = - validateHeaders(destinationBucket, request.headers, log); + const objectLockValError = validateHeaders(destinationBucket, request.headers, log); if (objectLockValError) { return callback(objectLockValError); } } const defaultRetention = destinationBucket.getObjectLockConfiguration(); - const finalObjectLockInfo = - compareObjectLockInformation(request.headers, defaultRetention); + const finalObjectLockInfo = compareObjectLockInformation(request.headers, defaultRetention); if (finalObjectLockInfo.retentionInfo) { - metadataStoreParams.retentionMode = - finalObjectLockInfo.retentionInfo.mode; - metadataStoreParams.retentionDate = - finalObjectLockInfo.retentionInfo.date; + metadataStoreParams.retentionMode = finalObjectLockInfo.retentionInfo.mode; + metadataStoreParams.retentionDate = finalObjectLockInfo.retentionInfo.date; } if (finalObjectLockInfo.legalHold) { metadataStoreParams.legalHold = finalObjectLockInfo.legalHold; @@ -287,9 +289,11 @@ function initiateMultipartUpload(authInfo, request, log, callback) { const putVersionId = request.headers['x-scal-s3-version-id']; const isPutVersion = putVersionId || putVersionId === ''; - if (isPutVersion && + if ( + isPutVersion && locConstraint === destinationBucket.getLocationConstraint() && - destinationBucket.isIngestionBucket()) { + destinationBucket.isIngestionBucket() + ) { // When restoring to OOB bucket, we cannot force the versionId of the object written to the // backend, and it is thus not match the versionId of the ingested object. Thus we add extra // user metadata to allow OOB to allow ingestion processor to "match" the (new) restored @@ -297,69 +301,76 @@ function initiateMultipartUpload(authInfo, request, log, callback) { mpuInfo.metaHeaders['x-amz-meta-scal-version-id'] = putVersionId; } - return data.initiateMPU(mpuInfo, websiteRedirectHeader, log, - (err, dataBackendResObj, isVersionedObj) => { - // will return as true and a custom error if external backend does - // not support versioned objects - if (isVersionedObj) { - monitoring.promMetrics('PUT', bucketName, 501, - 'initiateMultipartUpload'); - return callback(err); - } - if (err) { - monitoring.promMetrics('PUT', bucketName, err.code, - 'initiateMultipartUpload'); - return callback(err); - } - // if mpu not handled externally, dataBackendResObj will be null - if (dataBackendResObj) { - uploadId = dataBackendResObj.UploadId; - } else { - // Generate uniqueID without dashes so routing not messed up - uploadId = uuidv4().replace(/-/g, ''); - } - return _getMPUBucket(destinationBucket, log, corsHeaders, - uploadId, cipherBundle, locConstraint, callback); - }); + return data.initiateMPU(mpuInfo, websiteRedirectHeader, log, (err, dataBackendResObj, isVersionedObj) => { + // will return as true and a custom error if external backend does + // not support versioned objects + if (isVersionedObj) { + monitoring.promMetrics('PUT', bucketName, 501, 'initiateMultipartUpload'); + return callback(err); + } + if (err) { + monitoring.promMetrics('PUT', bucketName, err.code, 'initiateMultipartUpload'); + return callback(err); + } + // if mpu not handled externally, dataBackendResObj will be null + if (dataBackendResObj) { + uploadId = dataBackendResObj.UploadId; + } else { + // Generate uniqueID without dashes so routing not messed up + uploadId = uuidv4().replace(/-/g, ''); + } + return _getMPUBucket(destinationBucket, log, corsHeaders, uploadId, cipherBundle, locConstraint, callback); + }); } - async.waterfall([ - next => standardMetadataValidateBucketAndObj(metadataValParams, request.actionImplicitDenies, log, - (error, destinationBucket, destObjMD) => - updateEncryption(error, destinationBucket, destObjMD, objectKey, log, { skipObject: true }, - (error, destinationBucket) => { - const corsHeaders = collectCorsHeaders( - request.headers.origin, request.method, destinationBucket); + async.waterfall( + [ + next => + standardMetadataValidateBucketAndObj( + metadataValParams, + request.actionImplicitDenies, + log, + (error, destinationBucket, destObjMD) => + updateEncryption( + error, + destinationBucket, + destObjMD, + objectKey, + log, + { skipObject: true }, + (error, destinationBucket) => { + const corsHeaders = collectCorsHeaders( + request.headers.origin, + request.method, + destinationBucket, + ); + if (error) { + log.debug('error processing request', { + error, + method: 'metadataValidateBucketAndObj', + }); + monitoring.promMetrics('PUT', bucketName, error.code, 'initiateMultipartUpload'); + return next(error, corsHeaders); + } + return next(null, corsHeaders, destinationBucket); + }, + ), + ), + (corsHeaders, destinationBucket, next) => { + if (destinationBucket.hasDeletedFlag() && accountCanonicalID !== destinationBucket.getOwner()) { + log.trace('deleted flag on bucket and request from non-owner account'); + monitoring.promMetrics('PUT', bucketName, 404, 'initiateMultipartUpload'); + return next(errors.NoSuchBucket, corsHeaders); + } + if (destinationBucket.hasTransientFlag() || destinationBucket.hasDeletedFlag()) { + log.trace('transient or deleted flag so cleaning up bucket'); + return cleanUpBucket(destinationBucket, accountCanonicalID, log, error => { if (error) { - log.debug('error processing request', { + log.debug('error cleaning up bucket with flag', { error, - method: 'metadataValidateBucketAndObj', + transientFlag: destinationBucket.hasTransientFlag(), + deletedFlag: destinationBucket.hasDeletedFlag(), }); - monitoring.promMetrics('PUT', bucketName, error.code, 'initiateMultipartUpload'); - return next(error, corsHeaders); - } - return next(null, corsHeaders, destinationBucket); - })), - (corsHeaders, destinationBucket, next) => { - if (destinationBucket.hasDeletedFlag() && accountCanonicalID !== destinationBucket.getOwner()) { - log.trace('deleted flag on bucket and request from non-owner account'); - monitoring.promMetrics('PUT', bucketName, 404, 'initiateMultipartUpload'); - return next(errors.NoSuchBucket, corsHeaders); - } - if (destinationBucket.hasTransientFlag() || destinationBucket.hasDeletedFlag()) { - log.trace('transient or deleted flag so cleaning up bucket'); - return cleanUpBucket( - destinationBucket, - accountCanonicalID, - log, - error => { - if (error) { - log.debug('error cleaning up bucket with flag', - { - error, - transientFlag: destinationBucket.hasTransientFlag(), - deletedFlag: destinationBucket.hasDeletedFlag(), - }); // To avoid confusing user with error // from cleaning up // bucket return InternalError @@ -368,15 +379,11 @@ function initiateMultipartUpload(authInfo, request, log, callback) { } return next(null, corsHeaders, destinationBucket); }); - } - return next(null, corsHeaders, destinationBucket); - }, - (corsHeaders, destinationBucket, next) => - getObjectSSEConfiguration( - request.headers, - destinationBucket, - log, - (error, objectSSEConfig) => { + } + return next(null, corsHeaders, destinationBucket); + }, + (corsHeaders, destinationBucket, next) => + getObjectSSEConfiguration(request.headers, destinationBucket, log, (error, objectSSEConfig) => { if (error) { log.error('error fetching server-side encryption config', { error, @@ -385,23 +392,23 @@ function initiateMultipartUpload(authInfo, request, log, callback) { return next(error, corsHeaders); } return next(null, corsHeaders, destinationBucket, objectSSEConfig); + }), + // If SSE configured, test kms key encryption access, but ignore cipher bundle + (corsHeaders, destinationBucket, objectSSEConfig, next) => { + if (objectSSEConfig) { + return kms.createCipherBundle(objectSSEConfig, log, err => + next(err, corsHeaders, destinationBucket, objectSSEConfig), + ); } - ), - // If SSE configured, test kms key encryption access, but ignore cipher bundle - (corsHeaders, destinationBucket, objectSSEConfig, next) => { - if (objectSSEConfig) { - return kms.createCipherBundle(objectSSEConfig, log, - err => next(err, corsHeaders, destinationBucket, objectSSEConfig)); - } - return next(null, corsHeaders, destinationBucket, objectSSEConfig); - }, - ], + return next(null, corsHeaders, destinationBucket, objectSSEConfig); + }, + ], (error, corsHeaders, destinationBucket, objectSSEConfig) => { if (error) { return callback(error, null, corsHeaders); } return _storetheMPObject(destinationBucket, corsHeaders, objectSSEConfig); - } + }, ); return undefined; } diff --git a/tests/unit/api/multipartUpload.js b/tests/unit/api/multipartUpload.js index 002e99fa30..1128cd4bb7 100644 --- a/tests/unit/api/multipartUpload.js +++ b/tests/unit/api/multipartUpload.js @@ -1,4 +1,4 @@ -const { errors, storage, versioning } = require('arsenal'); +const { errors, s3middleware, storage, versioning } = require('arsenal'); const assert = require('assert'); const async = require('async'); @@ -3895,30 +3895,6 @@ describe('validatePerPartChecksums', () => { assert.strictEqual(err.message, 'InvalidPart'); }); }); - - describe('external backend MPU (isExternal=true)', () => { - // External parts store no per-part checksum, so the COMPOSITE requirement - // is relaxed - but a checksum the client submits is still rejected, since - // there is no stored value to verify it against. - it('should not require a per-part checksum (external parts store none)', () => { - const mpuChecksum = { algorithm: 'crc32', type: 'COMPOSITE', isDefault: false }; - const stored = [makeStoredPart(1, null)]; - const jsonList = { Part: [makeJsonPart(1, 'etag1')] }; - const err = validatePerPartChecksums(jsonList, stored, splitter, mpuChecksum, true); - assert.ifError(err); - }); - - it('should still return InvalidPart for a client-submitted checksum (nothing to verify against)', () => { - const mpuChecksum = { algorithm: 'crc32', type: 'FULL_OBJECT', isDefault: false }; - const stored = [makeStoredPart(1, null)]; - const jsonList = { - Part: [makeJsonPart(1, 'etag1', { ChecksumCRC32: SAMPLE_DIGESTS.crc32[0] })], - }; - const err = validatePerPartChecksums(jsonList, stored, splitter, mpuChecksum, true); - assert(err); - assert.strictEqual(err.message, 'InvalidPart'); - }); - }); }); describe('CompleteMultipartUpload x-amz-checksum-type header', () => { @@ -4429,10 +4405,10 @@ describe('CompleteMultipartUpload final-object checksum response', () => { }); describe('CompleteMultipartUpload per-part validation on external backends', () => { - // External backend parts store no per-part checksum, so CompleteMPU relaxes - // the COMPOSITE per-part requirement for them - but still rejects any checksum - // the client submits, since it can't be verified. The location is flipped via - // a getLocationConstraintType stub so only that gate differs. + // External-backend MPUs record no checksum config at CreateMPU + // (CLDSRV-964), so CompleteMPU has nothing to validate against and MPU + // checksums are ignored entirely. The location is flipped via a + // getLocationConstraintType stub so only that gate differs. const dataClient = data.client; const prevDataImplName = data.implName; const prevConfigBackendsData = data.config.backends.data; @@ -4498,8 +4474,12 @@ describe('CompleteMultipartUpload per-part validation on external backends', () // Create an MPU on the (external) ingest backend and upload one part. The // part is stored without a per-part checksum, as external backends do. - async function _initiateExternalMpu({ algo = 'CRC32', type = 'COMPOSITE' } = {}) { + // getLocationConstraintType is stubbed for the whole flow: since + // CLDSRV-964 the location type matters at initiate time, where external + // MPUs skip recording the checksum config. + async function _initiateExternalMpu({ algo = 'CRC32', type = 'COMPOSITE', locationType = 'aws_s3' } = {}) { await _bucketPut(authInfo, newPutIngestBucketRequest('us-east-1:ingest'), log); + sinon.stub(config, 'getLocationConstraintType').returns(locationType); const initiate = new DummyRequest({ bucketName, namespace, @@ -4522,21 +4502,293 @@ describe('CompleteMultipartUpload per-part validation on external backends', () describe('COMPOSITE MPU (no per-part checksum)', () => { it('should reject on a local location', async () => { - const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); + const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE', locationType: 'scality' }); const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); - sinon.stub(config, 'getLocationConstraintType').returns('scality'); await assert.rejects(_complete(completeReq), err => { assert.match(err.message, /InvalidRequest/); return true; }); }); - it('should complete on an external location', async () => { + it('should complete on an external location (checksum config not recorded)', async () => { const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); - sinon.stub(config, 'getLocationConstraintType').returns('aws_s3'); const result = await _complete(completeReq); assert(result, 'external COMPOSITE MPU should complete without per-part checksums'); }); + + it('should complete on an external location, ignoring an x-amz-checksum header', async () => { + // No final checksum can be computed for an external MPU, so the + // header cannot be validated; it must not fail the request. + const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + completeReq.headers['x-amz-checksum-crc32'] = `${SAMPLE_DIGESTS.crc32[0]}-1`; + const result = await _complete(completeReq); + assert(result, 'external MPU should complete despite an unverifiable checksum header'); + }); + + it('should complete on an external location, ignoring per-part checksums in the request body', async () => { + // The SDK scenario CLDSRV-964 fixes: a Complete body carrying + // per-part Checksum elements used to fail with InvalidPart + // (no stored value to verify against); with no checksum config + // recorded for the MPU they are ignored instead. + const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + completeReq.post = + '' + + `1"${eTag}"` + + `${SAMPLE_DIGESTS.crc32[0]}` + + ''; + const result = await _complete(completeReq); + assert(result, 'external MPU should ignore per-part checksums in the request body'); + }); + + it('should complete on an external location, ignoring an x-amz-checksum-type header', async () => { + // An SDK that echoes the checksum type it requested at CreateMPU + // must not be rejected: the MPU recorded no checksum config, so + // the header is ignored like the other checksum inputs. + const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + completeReq.headers['x-amz-checksum-type'] = 'COMPOSITE'; + const result = await _complete(completeReq); + assert(result, 'external MPU should ignore the x-amz-checksum-type header'); + }); + + it('should still reject a bogus x-amz-checksum-type header value on an external location', async () => { + const { uploadId, eTag } = await _initiateExternalMpu({ type: 'COMPOSITE' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + completeReq.headers['x-amz-checksum-type'] = 'BOGUS'; + await assert.rejects(_complete(completeReq), err => { + assert.strictEqual(err.message, 'InvalidRequest'); + assert.strictEqual(err.description, 'Value for x-amz-checksum-type header is invalid.'); + return true; + }); + }); + }); + + describe('CreateMPU checksum headers on an external location (CLDSRV-964)', () => { + const _initiate = headers => + new Promise((resolve, reject) => { + const initiate = new DummyRequest({ + bucketName, + namespace, + objectKey, + headers: { host: `${bucketName}.s3.amazonaws.com`, ...headers }, + url: `/${objectKey}?uploads`, + }); + initiateMultipartUpload(authInfo, initiate, log, (err, xml, resHeaders) => { + if (err) { + return reject(err); + } + return resolve({ xml, resHeaders }); + }); + }); + + beforeEach(async () => { + await _bucketPut(authInfo, newPutIngestBucketRequest('us-east-1:ingest'), log); + sinon.stub(config, 'getLocationConstraintType').returns('aws_s3'); + }); + + it('should not acknowledge checksum headers in the response', async () => { + const { resHeaders } = await _initiate({ + 'x-amz-checksum-algorithm': 'CRC32', + 'x-amz-checksum-type': 'COMPOSITE', + }); + assert.strictEqual(resHeaders['x-amz-checksum-algorithm'], undefined); + assert.strictEqual(resHeaders['x-amz-checksum-type'], undefined); + }); + + it('should not record the checksum config in the MPU overview metadata', async () => { + const { xml } = await _initiate({ + 'x-amz-checksum-algorithm': 'CRC32', + 'x-amz-checksum-type': 'COMPOSITE', + }); + const uploadId = (await parseStringPromise(xml)).InitiateMultipartUploadResult.UploadId[0]; + const overviewKey = `overview${splitter}${objectKey}${splitter}${uploadId}`; + const overviewMD = metadata.keyMaps.get(mpuBucket).get(overviewKey); + assert(overviewMD, 'MPU overview metadata should exist'); + assert.strictEqual(overviewMD.checksumAlgorithm, undefined); + assert.strictEqual(overviewMD.checksumType, undefined); + assert.strictEqual(overviewMD.checksumIsDefault, undefined); + }); + + it('should still reject invalid checksum header combinations', async () => { + await assert.rejects( + _initiate({ + 'x-amz-checksum-algorithm': 'CRC64NVME', + 'x-amz-checksum-type': 'COMPOSITE', + }), + err => { + assert.strictEqual(err.message, 'InvalidRequest'); + return true; + }, + ); + }); + }); +}); + +describe('CompleteMultipartUpload final checksum on azure-style external backends', () => { + // Azure is not in mpuMDStoredExternallyBackend and its completeMPU returns + // filteredPartsObj, so unlike aws_s3/gcp the final-checksum compute step + // would run with per-part info - over external parts that store no + // checksum. CompleteMPU must behave like aws_s3/gcp in all cases + // (CLDSRV-964): complete successfully, store no final checksum, return no + // checksum elements, and ignore any x-amz-checksum- header on the + // request. + const dataClient = data.client; + const prevDataImplName = data.implName; + const prevConfigBackendsData = data.config.backends.data; + const partBody = Buffer.from('part body', 'utf8'); + const partETag = crypto.createHash('md5').update(partBody).digest('hex'); + + before(() => { + data.switch( + new storage.data.MultipleBackendGateway( + { + 'us-east-1': dataClient, + 'us-east-2': dataClient, + }, + metadata, + data.locStorageCheckFn, + ), + ); + data.implName = 'multipleBackends'; + data.config.backends.data = 'multiple'; + dataClient.clientType = 'azure'; + }); + + after(() => { + data.switch(dataClient); + data.implName = prevDataImplName; + data.config.backends.data = prevConfigBackendsData; + delete dataClient.clientType; + }); + + beforeEach(() => { + cleanup(); + dataClient.uploadPart = sinon.stub().yields(undefined, { + key: 'mock-azure-key', + dataStoreName: 'us-east-1', + dataStoreType: 'azure', + dataStoreETag: partETag, + }); + // Mirror AzureClient.completeMPU: filter the stored parts against the + // request part list and hand them back for local aggregation. + dataClient.completeMPU = ( + jsonList, + mdInfo, + key, + uploadId, + bucketName, + userMetadata, + contentSettings, + tagging, + log, + cb, + ) => { + const filteredPartsObj = s3middleware.processMpuParts.validateAndFilterMpuParts( + mdInfo.storedParts, + jsonList, + mdInfo.mpuOverviewKey, + mdInfo.splitter, + log, + ); + if (filteredPartsObj.error) { + return cb(filteredPartsObj.error); + } + return cb(null, { key, filteredPartsObj }); + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + const newPutExternalBucketRequest = location => + new DummyRequest({ + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + post: + '' + + '' + + `${location}` + + '', + }); + + // Create an MPU on the azure-style backend and upload one part. The part + // MD is stored locally (unlike aws_s3) but carries no checksum fields. + // getLocationConstraintType is stubbed to azure for the whole flow, so + // initiate skips recording the checksum config (CLDSRV-964). + async function _initiateAzureMpu({ algo, type } = {}) { + await _bucketPut(authInfo, newPutExternalBucketRequest('us-east-1:ingest'), log); + sinon.stub(config, 'getLocationConstraintType').returns('azure'); + const headers = { host: `${bucketName}.s3.amazonaws.com` }; + if (algo) { + headers['x-amz-checksum-algorithm'] = algo; + headers['x-amz-checksum-type'] = type; + } + const initiate = new DummyRequest({ + bucketName, + namespace, + objectKey, + headers, + url: `/${objectKey}?uploads`, + }); + const initRes = await util.promisify(initiateMultipartUpload)(authInfo, initiate, log); + const uploadId = (await parseStringPromise(initRes)).InitiateMultipartUploadResult.UploadId[0]; + const partReq = _createPutPartRequest(uploadId, 1, partBody); + const eTag = await util.promisify(objectPutPart)(authInfo, partReq, undefined, log); + return { uploadId, eTag }; + } + + const _complete = completeReq => + new Promise((resolve, reject) => { + completeMultipartUpload(authInfo, completeReq, log, (err, xml, headers) => { + if (err) { + return reject(err); + } + return resolve({ xml, headers }); + }); + }); + + async function _assertNoChecksumInResult(xml) { + const result = (await parseStringPromise(xml)).CompleteMultipartUploadResult; + Object.keys(algorithms).forEach(algo => { + const xmlTag = algorithms[algo].xmlTag; + assert.strictEqual(result[xmlTag], undefined, `${xmlTag} should not be in the response`); + }); + assert.strictEqual(result.ChecksumType, undefined, 'ChecksumType should not be in the response'); + } + + it('should complete an explicit COMPOSITE MPU without a final checksum', async () => { + const { uploadId, eTag } = await _initiateAzureMpu({ algo: 'CRC32', type: 'COMPOSITE' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + const { xml } = await _complete(completeReq); + await _assertNoChecksumInResult(xml); + }); + + it('should complete an explicit FULL_OBJECT MPU without a final checksum', async () => { + const { uploadId, eTag } = await _initiateAzureMpu({ algo: 'CRC32', type: 'FULL_OBJECT' }); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + const { xml } = await _complete(completeReq); + await _assertNoChecksumInResult(xml); + }); + + it('should complete a default MPU without a final checksum', async () => { + const { uploadId, eTag } = await _initiateAzureMpu(); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + const { xml } = await _complete(completeReq); + await _assertNoChecksumInResult(xml); + }); + + it('should complete a default MPU, ignoring an x-amz-checksum header', async () => { + const { uploadId, eTag } = await _initiateAzureMpu(); + const completeReq = _createCompleteMpuRequest(uploadId, [{ partNumber: 1, eTag }]); + completeReq.headers['x-amz-checksum-crc64nvme'] = SAMPLE_DIGESTS.crc64nvme[0]; + const { xml } = await _complete(completeReq); + await _assertNoChecksumInResult(xml); }); });