Skip to content

Commit 7c40524

Browse files
committed
CLDSRV-898: reject CompleteMPU if explicit checksum but checksum missing in part metadata
1 parent 19e4b5e commit 7c40524

2 files changed

Lines changed: 141 additions & 78 deletions

File tree

lib/api/completeMultipartUpload.js

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -126,26 +126,44 @@ function validatePerPartChecksums(jsonList, storedParts, mpuSplitter, mpuChecksu
126126

127127
/**
128128
* Compute the final-object checksum for a CompleteMultipartUpload from the
129-
* stored MPU configuration and per-part checksums. Returns null when the MPU
130-
* has no checksum configured, when any part is missing its stored
131-
* ChecksumValue (defensive — should not happen in steady state), or when the
132-
* compute primitive itself reports an error. Errors are logged; the caller
133-
* proceeds without a final-object checksum so the response simply omits the
134-
* checksum fields rather than failing the whole CompleteMPU.
129+
* stored MPU configuration and per-part checksums.
130+
*
131+
* Returns `{ result, error }`:
132+
* - Success: `{ result: { algorithm, type, value }, error: null }`
133+
* - No MPU checksum configured: `{ result: null, error: null }`
134+
* - "Should-not-happen" branch (missing stored ChecksumValue, unknown
135+
* checksumType, compute primitive error): behavior depends on whether
136+
* the client opted in to checksums —
137+
* - Default MPU (`checksumIsDefault === true`): the client never
138+
* asked for a checksum; soft-fail with `{ result: null, error: null }`
139+
* so the response simply omits the checksum field.
140+
* - Explicit MPU (`checksumIsDefault === false`): the client asked
141+
* for a checksum at CreateMPU; silently dropping it would violate
142+
* the contract. Return `{ result: null, error: <InternalError> }`
143+
* for the caller to fail the CompleteMPU.
135144
*
136145
* @param {array} storedParts - parts from services.getMPUparts (carry ChecksumValue)
137146
* @param {array} filteredPartList - validated, ordered subset matching jsonList
138147
* @param {object} storedMetadata - MPU overview metadata
139148
* @param {string} mpuSplitter - splitter used in part keys
140149
* @param {string} uploadId - for log context
141150
* @param {object} log - werelogs logger
142-
* @returns {object|null} { algorithm, type, value } or null
151+
* @returns {Promise<{ result: ({algorithm, type, value}|null), error: (ArsenalError|null) }>}
143152
*/
144153
async function computeFinalChecksum(storedParts, filteredPartList, storedMetadata, mpuSplitter, uploadId, log) {
145154
const algorithm = storedMetadata.checksumAlgorithm;
146155
const type = storedMetadata.checksumType;
147156
if (!algorithm || !type) {
148-
return null;
157+
return { result: null, error: null };
158+
}
159+
const isDefault = !!storedMetadata.checksumIsDefault;
160+
161+
function failOrSkip(message, extra) {
162+
log.error(message, { uploadId, algorithm, type, ...extra });
163+
if (isDefault) {
164+
return { result: null, error: null };
165+
}
166+
return { result: null, error: errorInstances.InternalError.customizeDescription(message) };
149167
}
150168

151169
const storedByKey = new Map();
@@ -164,41 +182,29 @@ async function computeFinalChecksum(storedParts, filteredPartList, storedMetadat
164182
}
165183

166184
if (missingPartNumbers.length > 0) {
167-
log.error('one or more MPU parts missing checksum value; ' + 'skipping final-object checksum computation', {
168-
uploadId,
169-
algorithm,
170-
type,
171-
missingPartNumbers,
172-
});
173-
return null;
185+
return failOrSkip('one or more MPU parts missing checksum value', { missingPartNumbers });
174186
}
175187

176-
let result;
188+
let computed;
177189
if (type === 'COMPOSITE') {
178-
result = await computeCompositeMPUChecksum(
190+
computed = await computeCompositeMPUChecksum(
179191
algorithm,
180192
partInputs.map(p => p.value),
181193
);
182194
} else if (type === 'FULL_OBJECT') {
183-
result = computeFullObjectMPUChecksum(algorithm, partInputs);
195+
computed = computeFullObjectMPUChecksum(algorithm, partInputs);
184196
} else {
185-
log.error('unknown MPU checksumType; skipping final-object checksum computation', {
186-
uploadId,
187-
checksumType: type,
188-
});
189-
return null;
197+
return failOrSkip('unknown MPU checksumType', { checksumType: type });
190198
}
191199

192-
if (result.error) {
193-
log.error('final-object checksum computation failed', {
194-
uploadId,
195-
checksumErrorCode: result.error.code,
196-
checksumErrorDetails: result.error.details,
200+
if (computed.error) {
201+
return failOrSkip('final-object checksum computation failed', {
202+
checksumErrorCode: computed.error.code,
203+
checksumErrorDetails: computed.error.details,
197204
});
198-
return null;
199205
}
200206

201-
return { algorithm, type, value: result.checksum };
207+
return { result: { algorithm, type, value: computed.checksum }, error: null };
202208
}
203209

204210
/*
@@ -620,9 +626,16 @@ function completeMultipartUpload(authInfo, request, log, callback) {
620626
uploadId,
621627
log,
622628
).then(
623-
fc => {
629+
({ result, error }) => {
630+
if (error) {
631+
log.error('failing CompleteMPU due to final-object checksum error', {
632+
uploadId,
633+
error,
634+
});
635+
return callNext(error, destBucket);
636+
}
624637
try {
625-
finalChecksum = fc;
638+
finalChecksum = result;
626639
const checksumErr = validateCompleteMultipartUploadChecksum(request.headers, finalChecksum);
627640
if (checksumErr) {
628641
log.debug('x-amz-checksum-<algo> header validation failed on CompleteMPU', {

tests/unit/api/multipartUpload.js

Lines changed: 96 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4201,13 +4201,23 @@ describe('computeFinalChecksum', () => {
42014201
}));
42024202
}
42034203

4204-
it('should return null when MPU has no checksumAlgorithm', async () => {
4204+
function assertSoftNull(got) {
4205+
assert.deepStrictEqual(got, { result: null, error: null });
4206+
}
4207+
4208+
function assertInternalError(got) {
4209+
assert.strictEqual(got.result, null);
4210+
assert(got.error, 'expected an error on the result');
4211+
assert.strictEqual(got.error.is.InternalError, true);
4212+
}
4213+
4214+
it('should return { result: null, error: null } when MPU has no checksumAlgorithm', async () => {
42054215
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
42064216
const got = await computeFinalChecksum(stored, partListFromStored(stored), {}, splitter, uploadId, log);
4207-
assert.strictEqual(got, null);
4217+
assertSoftNull(got);
42084218
});
42094219

4210-
it('should return null when MPU has no checksumType', async () => {
4220+
it('should return { result: null, error: null } when MPU has no checksumType', async () => {
42114221
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
42124222
const got = await computeFinalChecksum(
42134223
stored,
@@ -4217,7 +4227,7 @@ describe('computeFinalChecksum', () => {
42174227
uploadId,
42184228
log,
42194229
);
4220-
assert.strictEqual(got, null);
4230+
assertSoftNull(got);
42214231
});
42224232

42234233
it('should return COMPOSITE checksum with -N suffix for SHA256 MPU', async () => {
@@ -4227,25 +4237,26 @@ describe('computeFinalChecksum', () => {
42274237
makeStoredPart(2, { algorithm: 'sha256', value: d2 }),
42284238
makeStoredPart(3, { algorithm: 'sha256', value: d3 }),
42294239
];
4230-
const got = await computeFinalChecksum(
4240+
const { result, error } = await computeFinalChecksum(
42314241
stored,
42324242
partListFromStored(stored),
42334243
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
42344244
splitter,
42354245
uploadId,
42364246
log,
42374247
);
4238-
assert(got);
4239-
assert.strictEqual(got.algorithm, 'sha256');
4240-
assert.strictEqual(got.type, 'COMPOSITE');
4241-
assert(got.value.endsWith('-3'), `expected -N suffix, got ${got.value}`);
4248+
assert.strictEqual(error, null);
4249+
assert(result);
4250+
assert.strictEqual(result.algorithm, 'sha256');
4251+
assert.strictEqual(result.type, 'COMPOSITE');
4252+
assert(result.value.endsWith('-3'), `expected -N suffix, got ${result.value}`);
42424253
// computeCompositeMPUChecksum's deterministic output for these
42434254
// exact placeholder digests:
42444255
const expected = crypto
42454256
.createHash('sha256')
42464257
.update(Buffer.concat([d1, d2, d3].map(x => Buffer.from(x, 'base64'))))
42474258
.digest('base64');
4248-
assert.strictEqual(got.value, `${expected}-3`);
4259+
assert.strictEqual(result.value, `${expected}-3`);
42494260
});
42504261

42514262
['sha1', 'crc32', 'crc32c'].forEach(algo => {
@@ -4255,18 +4266,19 @@ describe('computeFinalChecksum', () => {
42554266
makeStoredPart(1, { algorithm: algo, value: d1 }),
42564267
makeStoredPart(2, { algorithm: algo, value: d2 }),
42574268
];
4258-
const got = await computeFinalChecksum(
4269+
const { result, error } = await computeFinalChecksum(
42594270
stored,
42604271
partListFromStored(stored),
42614272
{ checksumAlgorithm: algo, checksumType: 'COMPOSITE' },
42624273
splitter,
42634274
uploadId,
42644275
log,
42654276
);
4266-
assert(got);
4267-
assert.strictEqual(got.algorithm, algo);
4268-
assert.strictEqual(got.type, 'COMPOSITE');
4269-
assert(got.value.endsWith('-2'));
4277+
assert.strictEqual(error, null);
4278+
assert(result);
4279+
assert.strictEqual(result.algorithm, algo);
4280+
assert.strictEqual(result.type, 'COMPOSITE');
4281+
assert(result.value.endsWith('-2'));
42704282
});
42714283
});
42724284

@@ -4299,23 +4311,47 @@ describe('computeFinalChecksum', () => {
42994311
},
43004312
},
43014313
];
4302-
const got = await computeFinalChecksum(
4314+
const { result, error } = await computeFinalChecksum(
43034315
stored,
43044316
partListFromStored(stored),
43054317
{ checksumAlgorithm: 'crc64nvme', checksumType: 'FULL_OBJECT' },
43064318
splitter,
43074319
uploadId,
43084320
log,
43094321
);
4310-
assert(got);
4311-
assert.strictEqual(got.algorithm, 'crc64nvme');
4312-
assert.strictEqual(got.type, 'FULL_OBJECT');
4313-
assert(!got.value.includes('-'), `FULL_OBJECT should have no -N suffix, got ${got.value}`);
4322+
assert.strictEqual(error, null);
4323+
assert(result);
4324+
assert.strictEqual(result.algorithm, 'crc64nvme');
4325+
assert.strictEqual(result.type, 'FULL_OBJECT');
4326+
assert(!result.value.includes('-'), `FULL_OBJECT should have no -N suffix, got ${result.value}`);
43144327
const expected = await algorithms.crc64nvme.digest(Buffer.concat([a, b]));
4315-
assert.strictEqual(got.value, expected);
4328+
assert.strictEqual(result.value, expected);
4329+
});
4330+
4331+
// Soft-null (`{ result: null, error: null }`) is intentional only for
4332+
// default MPUs — the client didn't opt in to a checksum, so missing it
4333+
// on the response is graceful degradation. Explicit MPUs return
4334+
// `{ result: null, error: InternalError }` because silently dropping
4335+
// a checksum the client asked for would violate the CreateMPU contract.
4336+
4337+
it('should soft-null when a default-MPU part is missing ChecksumValue', async () => {
4338+
const stored = [
4339+
makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] }),
4340+
makeStoredPart(2, null),
4341+
makeStoredPart(3, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[1] }),
4342+
];
4343+
const got = await computeFinalChecksum(
4344+
stored,
4345+
partListFromStored(stored),
4346+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'FULL_OBJECT', checksumIsDefault: true },
4347+
splitter,
4348+
uploadId,
4349+
log,
4350+
);
4351+
assertSoftNull(got);
43164352
});
43174353

4318-
it('should return null and log when a part is missing ChecksumValue', async () => {
4354+
it('should return InternalError when an explicit-MPU part is missing ChecksumValue', async () => {
43194355
const stored = [
43204356
makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] }),
43214357
makeStoredPart(2, null),
@@ -4324,42 +4360,55 @@ describe('computeFinalChecksum', () => {
43244360
const got = await computeFinalChecksum(
43254361
stored,
43264362
partListFromStored(stored),
4327-
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
4363+
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE', checksumIsDefault: false },
4364+
splitter,
4365+
uploadId,
4366+
log,
4367+
);
4368+
assertInternalError(got);
4369+
});
4370+
4371+
it('should soft-null when checksumType is unknown on a default MPU', async () => {
4372+
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
4373+
const got = await computeFinalChecksum(
4374+
stored,
4375+
partListFromStored(stored),
4376+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'WEIRD', checksumIsDefault: true },
43284377
splitter,
43294378
uploadId,
43304379
log,
43314380
);
4332-
assert.strictEqual(got, null);
4381+
assertSoftNull(got);
43334382
});
43344383

4335-
it('should return null when checksumType is unknown', async () => {
4384+
it('should return InternalError when checksumType is unknown on an explicit MPU', async () => {
43364385
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
43374386
const got = await computeFinalChecksum(
43384387
stored,
43394388
partListFromStored(stored),
4340-
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD' },
4389+
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD', checksumIsDefault: false },
43414390
splitter,
43424391
uploadId,
43434392
log,
43444393
);
4345-
assert.strictEqual(got, null);
4394+
assertInternalError(got);
43464395
});
43474396

4348-
it(
4349-
'should return null when underlying compute reports an error ' + '(crc64nvme COMPOSITE is not allowed)',
4350-
async () => {
4351-
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
4352-
const got = await computeFinalChecksum(
4353-
stored,
4354-
partListFromStored(stored),
4355-
{ checksumAlgorithm: 'crc64nvme', checksumType: 'COMPOSITE' },
4356-
splitter,
4357-
uploadId,
4358-
log,
4359-
);
4360-
assert.strictEqual(got, null);
4361-
},
4362-
);
4397+
it('should return InternalError when underlying compute reports an error on an explicit MPU', async () => {
4398+
// crc64nvme + COMPOSITE is not allowed by computeCompositeMPUChecksum.
4399+
// Reaching here on an explicit MPU means upstream validation failed,
4400+
// which is exactly the kind of internal-state bug we want to surface.
4401+
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
4402+
const got = await computeFinalChecksum(
4403+
stored,
4404+
partListFromStored(stored),
4405+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'COMPOSITE', checksumIsDefault: false },
4406+
splitter,
4407+
uploadId,
4408+
log,
4409+
);
4410+
assertInternalError(got);
4411+
});
43634412

43644413
it('should compute over filteredPartList (subset), not all storedParts', async () => {
43654414
const [d1, d2, d3] = [SAMPLE_DIGESTS.sha256[0], SAMPLE_DIGESTS.sha256[1], SAMPLE_DIGESTS.sha256[0]];
@@ -4375,21 +4424,22 @@ describe('computeFinalChecksum', () => {
43754424
size: s.value.Size,
43764425
locations: s.value.partLocations,
43774426
}));
4378-
const got = await computeFinalChecksum(
4427+
const { result, error } = await computeFinalChecksum(
43794428
stored,
43804429
filtered,
43814430
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
43824431
splitter,
43834432
uploadId,
43844433
log,
43854434
);
4386-
assert(got);
4387-
assert(got.value.endsWith('-2'), `should reflect 2 completed parts, got ${got.value}`);
4435+
assert.strictEqual(error, null);
4436+
assert(result);
4437+
assert(result.value.endsWith('-2'), `should reflect 2 completed parts, got ${result.value}`);
43884438
const expected = crypto
43894439
.createHash('sha256')
43904440
.update(Buffer.concat([d1, d3].map(x => Buffer.from(x, 'base64'))))
43914441
.digest('base64');
4392-
assert.strictEqual(got.value, `${expected}-2`);
4442+
assert.strictEqual(result.value, `${expected}-2`);
43934443
});
43944444
});
43954445

0 commit comments

Comments
 (0)