Skip to content

Commit a90bd94

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

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
@@ -128,26 +128,44 @@ function validatePerPartChecksums(jsonList, storedParts, mpuSplitter, mpuChecksu
128128

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

153171
const storedByKey = new Map();
@@ -166,41 +184,29 @@ async function computeFinalChecksum(storedParts, filteredPartList, storedMetadat
166184
}
167185

168186
if (missingPartNumbers.length > 0) {
169-
log.error('one or more MPU parts missing checksum value; ' + 'skipping final-object checksum computation', {
170-
uploadId,
171-
algorithm,
172-
type,
173-
missingPartNumbers,
174-
});
175-
return null;
187+
return failOrSkip('one or more MPU parts missing checksum value', { missingPartNumbers });
176188
}
177189

178-
let result;
190+
let computed;
179191
if (type === 'COMPOSITE') {
180-
result = await computeCompositeMPUChecksum(
192+
computed = await computeCompositeMPUChecksum(
181193
algorithm,
182194
partInputs.map(p => p.value),
183195
);
184196
} else if (type === 'FULL_OBJECT') {
185-
result = computeFullObjectMPUChecksum(algorithm, partInputs);
197+
computed = computeFullObjectMPUChecksum(algorithm, partInputs);
186198
} else {
187-
log.error('unknown MPU checksumType; skipping final-object checksum computation', {
188-
uploadId,
189-
checksumType: type,
190-
});
191-
return null;
199+
return failOrSkip('unknown MPU checksumType', { checksumType: type });
192200
}
193201

194-
if (result.error) {
195-
log.error('final-object checksum computation failed', {
196-
uploadId,
197-
checksumErrorCode: result.error.code,
198-
checksumErrorDetails: result.error.details,
202+
if (computed.error) {
203+
return failOrSkip('final-object checksum computation failed', {
204+
checksumErrorCode: computed.error.code,
205+
checksumErrorDetails: computed.error.details,
199206
});
200-
return null;
201207
}
202208

203-
return { algorithm, type, value: result.checksum };
209+
return { result: { algorithm, type, value: computed.checksum }, error: null };
204210
}
205211

206212
/*
@@ -613,9 +619,16 @@ function completeMultipartUpload(authInfo, request, log, callback) {
613619
uploadId,
614620
log,
615621
).then(
616-
fc => {
622+
({ result, error }) => {
623+
if (error) {
624+
log.error('failing CompleteMPU due to final-object checksum error', {
625+
uploadId,
626+
error,
627+
});
628+
return callNext(error, destBucket);
629+
}
617630
try {
618-
finalChecksum = fc;
631+
finalChecksum = result;
619632
const checksumErr = validateCompleteMultipartUploadChecksum(request.headers, finalChecksum);
620633
if (checksumErr) {
621634
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
@@ -573,13 +573,23 @@ describe('computeFinalChecksum', () => {
573573
}));
574574
}
575575

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

582-
it('should return null when MPU has no checksumType', async () => {
592+
it('should return { result: null, error: null } when MPU has no checksumType', async () => {
583593
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
584594
const got = await computeFinalChecksum(
585595
stored,
@@ -589,7 +599,7 @@ describe('computeFinalChecksum', () => {
589599
uploadId,
590600
log,
591601
);
592-
assert.strictEqual(got, null);
602+
assertSoftNull(got);
593603
});
594604

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

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

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

690-
it('should return null and log when a part is missing ChecksumValue', async () => {
726+
it('should return InternalError when an explicit-MPU part is missing ChecksumValue', async () => {
691727
const stored = [
692728
makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] }),
693729
makeStoredPart(2, null),
@@ -696,42 +732,55 @@ describe('computeFinalChecksum', () => {
696732
const got = await computeFinalChecksum(
697733
stored,
698734
partListFromStored(stored),
699-
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
735+
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE', checksumIsDefault: false },
736+
SPLITTER,
737+
uploadId,
738+
log,
739+
);
740+
assertInternalError(got);
741+
});
742+
743+
it('should soft-null when checksumType is unknown on a default MPU', async () => {
744+
const stored = [makeStoredPart(1, { algorithm: 'crc64nvme', value: SAMPLE_DIGESTS.crc64nvme[0] })];
745+
const got = await computeFinalChecksum(
746+
stored,
747+
partListFromStored(stored),
748+
{ checksumAlgorithm: 'crc64nvme', checksumType: 'WEIRD', checksumIsDefault: true },
700749
SPLITTER,
701750
uploadId,
702751
log,
703752
);
704-
assert.strictEqual(got, null);
753+
assertSoftNull(got);
705754
});
706755

707-
it('should return null when checksumType is unknown', async () => {
756+
it('should return InternalError when checksumType is unknown on an explicit MPU', async () => {
708757
const stored = [makeStoredPart(1, { algorithm: 'sha256', value: SAMPLE_DIGESTS.sha256[0] })];
709758
const got = await computeFinalChecksum(
710759
stored,
711760
partListFromStored(stored),
712-
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD' },
761+
{ checksumAlgorithm: 'sha256', checksumType: 'WEIRD', checksumIsDefault: false },
713762
SPLITTER,
714763
uploadId,
715764
log,
716765
);
717-
assert.strictEqual(got, null);
766+
assertInternalError(got);
718767
});
719768

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

736785
it('should compute over filteredPartList (subset), not all storedParts', async () => {
737786
const [d1, d2, d3] = [SAMPLE_DIGESTS.sha256[0], SAMPLE_DIGESTS.sha256[1], SAMPLE_DIGESTS.sha256[0]];
@@ -747,21 +796,22 @@ describe('computeFinalChecksum', () => {
747796
size: s.value.Size,
748797
locations: s.value.partLocations,
749798
}));
750-
const got = await computeFinalChecksum(
799+
const { result, error } = await computeFinalChecksum(
751800
stored,
752801
filtered,
753802
{ checksumAlgorithm: 'sha256', checksumType: 'COMPOSITE' },
754803
SPLITTER,
755804
uploadId,
756805
log,
757806
);
758-
assert(got);
759-
assert(got.value.endsWith('-2'), `should reflect 2 completed parts, got ${got.value}`);
807+
assert.strictEqual(error, null);
808+
assert(result);
809+
assert(result.value.endsWith('-2'), `should reflect 2 completed parts, got ${result.value}`);
760810
const expected = crypto
761811
.createHash('sha256')
762812
.update(Buffer.concat([d1, d3].map(x => Buffer.from(x, 'base64'))))
763813
.digest('base64');
764-
assert.strictEqual(got.value, `${expected}-2`);
814+
assert.strictEqual(result.value, `${expected}-2`);
765815
});
766816
});
767817

0 commit comments

Comments
 (0)