Skip to content

Commit ff5b400

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

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
/*
@@ -611,9 +617,16 @@ function completeMultipartUpload(authInfo, request, log, callback) {
611617
uploadId,
612618
log,
613619
).then(
614-
fc => {
620+
({ result, error }) => {
621+
if (error) {
622+
log.error('failing CompleteMPU due to final-object checksum error', {
623+
uploadId,
624+
error,
625+
});
626+
return callNext(error, destBucket);
627+
}
615628
try {
616-
finalChecksum = fc;
629+
finalChecksum = result;
617630
const checksumErr = validateCompleteMultipartUploadChecksum(request.headers, finalChecksum);
618631
if (checksumErr) {
619632
log.debug('x-amz-checksum-<algo> header validation failed on CompleteMPU', {

tests/unit/api/completeMultipartUpload.js

Lines changed: 96 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,23 @@ describe('computeFinalChecksum', () => {
574574
}));
575575
}
576576

577-
it('should return null when MPU has no checksumAlgorithm', async () => {
577+
function assertSoftNull(got) {
578+
assert.deepStrictEqual(got, { result: null, error: null });
579+
}
580+
581+
function assertInternalError(got) {
582+
assert.strictEqual(got.result, null);
583+
assert(got.error, 'expected an error on the result');
584+
assert.strictEqual(got.error.is.InternalError, true);
585+
}
586+
587+
it('should return { result: null, error: null } when MPU has no checksumAlgorithm', async () => {
578588
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
579589
const got = await computeFinalChecksum(stored, partListFromStored(stored), {}, SPLITTER, uploadId, log);
580-
assert.strictEqual(got, null);
590+
assertSoftNull(got);
581591
});
582592

583-
it('should return null when MPU has no checksumType', async () => {
593+
it('should return { result: null, error: null } when MPU has no checksumType', async () => {
584594
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
585595
const got = await computeFinalChecksum(
586596
stored,
@@ -590,7 +600,7 @@ describe('computeFinalChecksum', () => {
590600
uploadId,
591601
log,
592602
);
593-
assert.strictEqual(got, null);
603+
assertSoftNull(got);
594604
});
595605

596606
it('should return COMPOSITE checksum with -N suffix for SHA256 MPU', async () => {
@@ -600,25 +610,26 @@ describe('computeFinalChecksum', () => {
600610
makeStoredPart(2, { algorithm: 'sha256', value: d2 }),
601611
makeStoredPart(3, { algorithm: 'sha256', value: d3 }),
602612
];
603-
const got = await computeFinalChecksum(
613+
const { result, error } = await computeFinalChecksum(
604614
stored,
605615
partListFromStored(stored),
606616
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
607617
SPLITTER,
608618
uploadId,
609619
log,
610620
);
611-
assert(got);
612-
assert.strictEqual(got.algorithm, 'sha256');
613-
assert.strictEqual(got.type, 'COMPOSITE');
614-
assert(got.value.endsWith('-3'), `expected -N suffix, got ${got.value}`);
621+
assert.strictEqual(error, null);
622+
assert(result);
623+
assert.strictEqual(result.algorithm, 'sha256');
624+
assert.strictEqual(result.type, 'COMPOSITE');
625+
assert(result.value.endsWith('-3'), `expected -N suffix, got ${result.value}`);
615626
// computeCompositeMPUChecksum's deterministic output for these
616627
// exact placeholder digests:
617628
const expected = crypto
618629
.createHash('sha256')
619630
.update(Buffer.concat([d1, d2, d3].map(x => Buffer.from(x, 'base64'))))
620631
.digest('base64');
621-
assert.strictEqual(got.value, `${expected}-3`);
632+
assert.strictEqual(result.value, `${expected}-3`);
622633
});
623634

624635
['sha1', 'crc32', 'crc32c'].forEach(algo => {
@@ -628,18 +639,19 @@ describe('computeFinalChecksum', () => {
628639
makeStoredPart(1, { algorithm: algo, value: d1 }),
629640
makeStoredPart(2, { algorithm: algo, value: d2 }),
630641
];
631-
const got = await computeFinalChecksum(
642+
const { result, error } = await computeFinalChecksum(
632643
stored,
633644
partListFromStored(stored),
634645
{ checksumAlgorithm: algo, checksumType: 'COMPOSITE' },
635646
SPLITTER,
636647
uploadId,
637648
log,
638649
);
639-
assert(got);
640-
assert.strictEqual(got.algorithm, algo);
641-
assert.strictEqual(got.type, 'COMPOSITE');
642-
assert(got.value.endsWith('-2'));
650+
assert.strictEqual(error, null);
651+
assert(result);
652+
assert.strictEqual(result.algorithm, algo);
653+
assert.strictEqual(result.type, 'COMPOSITE');
654+
assert(result.value.endsWith('-2'));
643655
});
644656
});
645657

@@ -672,23 +684,47 @@ describe('computeFinalChecksum', () => {
672684
},
673685
},
674686
];
675-
const got = await computeFinalChecksum(
687+
const { result, error } = await computeFinalChecksum(
676688
stored,
677689
partListFromStored(stored),
678690
{ checksumAlgorithm: 'crc64nvme', checksumType: 'FULL_OBJECT' },
679691
SPLITTER,
680692
uploadId,
681693
log,
682694
);
683-
assert(got);
684-
assert.strictEqual(got.algorithm, 'crc64nvme');
685-
assert.strictEqual(got.type, 'FULL_OBJECT');
686-
assert(!got.value.includes('-'), `FULL_OBJECT should have no -N suffix, got ${got.value}`);
695+
assert.strictEqual(error, null);
696+
assert(result);
697+
assert.strictEqual(result.algorithm, 'crc64nvme');
698+
assert.strictEqual(result.type, 'FULL_OBJECT');
699+
assert(!result.value.includes('-'), `FULL_OBJECT should have no -N suffix, got ${result.value}`);
687700
const expected = await algorithms.crc64nvme.digest(Buffer.concat([a, b]));
688-
assert.strictEqual(got.value, expected);
701+
assert.strictEqual(result.value, expected);
702+
});
703+
704+
// Soft-null (`{ result: null, error: null }`) is intentional only for
705+
// default MPUs — the client didn't opt in to a checksum, so missing it
706+
// on the response is graceful degradation. Explicit MPUs return
707+
// `{ result: null, error: InternalError }` because silently dropping
708+
// a checksum the client asked for would violate the CreateMPU contract.
709+
710+
it('should soft-null when a default-MPU part is missing ChecksumValue', async () => {
711+
const stored = [
712+
makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] }),
713+
makeStoredPart(2, null),
714+
makeStoredPart(3, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[1] }),
715+
];
716+
const got = await computeFinalChecksum(
717+
stored,
718+
partListFromStored(stored),
719+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'FULL_OBJECT', checksumIsDefault: true },
720+
SPLITTER,
721+
uploadId,
722+
log,
723+
);
724+
assertSoftNull(got);
689725
});
690726

691-
it('should return null and log when a part is missing ChecksumValue', async () => {
727+
it('should return InternalError when an explicit-MPU part is missing ChecksumValue', async () => {
692728
const stored = [
693729
makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] }),
694730
makeStoredPart(2, null),
@@ -697,42 +733,55 @@ describe('computeFinalChecksum', () => {
697733
const got = await computeFinalChecksum(
698734
stored,
699735
partListFromStored(stored),
700-
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
736+
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE', checksumIsDefault: false },
737+
SPLITTER,
738+
uploadId,
739+
log,
740+
);
741+
assertInternalError(got);
742+
});
743+
744+
it('should soft-null when checksumType is unknown on a default MPU', async () => {
745+
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
746+
const got = await computeFinalChecksum(
747+
stored,
748+
partListFromStored(stored),
749+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'WEIRD', checksumIsDefault: true },
701750
SPLITTER,
702751
uploadId,
703752
log,
704753
);
705-
assert.strictEqual(got, null);
754+
assertSoftNull(got);
706755
});
707756

708-
it('should return null when checksumType is unknown', async () => {
757+
it('should return InternalError when checksumType is unknown on an explicit MPU', async () => {
709758
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
710759
const got = await computeFinalChecksum(
711760
stored,
712761
partListFromStored(stored),
713-
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD' },
762+
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD', checksumIsDefault: false },
714763
SPLITTER,
715764
uploadId,
716765
log,
717766
);
718-
assert.strictEqual(got, null);
767+
assertInternalError(got);
719768
});
720769

721-
it(
722-
'should return null when underlying compute reports an error ' + '(crc64nvme COMPOSITE is not allowed)',
723-
async () => {
724-
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
725-
const got = await computeFinalChecksum(
726-
stored,
727-
partListFromStored(stored),
728-
{ checksumAlgorithm: 'crc64nvme', checksumType: 'COMPOSITE' },
729-
SPLITTER,
730-
uploadId,
731-
log,
732-
);
733-
assert.strictEqual(got, null);
734-
},
735-
);
770+
it('should return InternalError when underlying compute reports an error on an explicit MPU', async () => {
771+
// crc64nvme + COMPOSITE is not allowed by computeCompositeMPUChecksum.
772+
// Reaching here on an explicit MPU means upstream validation failed,
773+
// which is exactly the kind of internal-state bug we want to surface.
774+
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
775+
const got = await computeFinalChecksum(
776+
stored,
777+
partListFromStored(stored),
778+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'COMPOSITE', checksumIsDefault: false },
779+
SPLITTER,
780+
uploadId,
781+
log,
782+
);
783+
assertInternalError(got);
784+
});
736785

737786
it('should compute over filteredPartList (subset), not all storedParts', async () => {
738787
const [d1, d2, d3] = [SAMPLE_DIGESTS.sha256[0], SAMPLE_DIGESTS.sha256[1], SAMPLE_DIGESTS.sha256[0]];
@@ -748,21 +797,22 @@ describe('computeFinalChecksum', () => {
748797
size: s.value.Size,
749798
locations: s.value.partLocations,
750799
}));
751-
const got = await computeFinalChecksum(
800+
const { result, error } = await computeFinalChecksum(
752801
stored,
753802
filtered,
754803
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
755804
SPLITTER,
756805
uploadId,
757806
log,
758807
);
759-
assert(got);
760-
assert(got.value.endsWith('-2'), `should reflect 2 completed parts, got ${got.value}`);
808+
assert.strictEqual(error, null);
809+
assert(result);
810+
assert(result.value.endsWith('-2'), `should reflect 2 completed parts, got ${result.value}`);
761811
const expected = crypto
762812
.createHash('sha256')
763813
.update(Buffer.concat([d1, d3].map(x => Buffer.from(x, 'base64'))))
764814
.digest('base64');
765-
assert.strictEqual(got.value, `${expected}-2`);
815+
assert.strictEqual(result.value, `${expected}-2`);
766816
});
767817
});
768818

0 commit comments

Comments
 (0)