Skip to content

CLDSRV-863: Checksums for PutObject and UploadPart#6112

Open
leif-scality wants to merge 10 commits intodevelopment/9.4from
improvement/CLDSRV-863-checksums-put-object-part
Open

CLDSRV-863: Checksums for PutObject and UploadPart#6112
leif-scality wants to merge 10 commits intodevelopment/9.4from
improvement/CLDSRV-863-checksums-put-object-part

Conversation

@leif-scality
Copy link
Contributor

No description provided.

@bert-e
Copy link
Contributor

bert-e commented Mar 16, 2026

Hello leif-scality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request TBA
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e
Copy link
Contributor

bert-e commented Mar 16, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-863 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.4.0

Please check the Fix Version/s of CLDSRV-863, or the target
branch of this pull request.

@codecov
Copy link

codecov bot commented Mar 16, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
8457 1 8456 0
View the full list of 1 ❄️ flaky test(s)
"before all" hook for "should delete a object uploaded in parts successfully"::DELETE object With default signature with multipart upload "before all" hook for "should delete a object uploaded in parts successfully"

Flake rate in main: 100.00% (Passed 0 times, Failed 60 times)

Stack Traces | 0.163s run time
socket hang up

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@claude
Copy link

claude bot commented Mar 16, 2026

LGTM

The checksum pipeline (prepareStream → ChecksumTransform → storeObject validation) is well-structured. The mutable errCb + jsutil.once pattern in storeObject correctly prevents double callbacks across the finish/error race. The defensive err.is && guards in services.js fix a real crash path. Arsenal pinned to tag 8.3.6. Tests cover the key edge cases (chunk boundary splits, trailer parsing, double-callback prevention, batchDelete failures).

Review by Claude Code

@claude
Copy link

claude bot commented Mar 19, 2026

LGTM — well-structured checksum pipeline with good error handling and thorough tests.

- trailingChecksumTransform.js:119: Inconsistent error construction — uses errors.IncompleteBody.customizeDescription(...) inline while the identical error is already a module-level constant (incompleteBodyError on line 5, built from errorInstances). Should reuse the constant for consistency.

Review by Claude Code

return null;
}

if (this.trailerChecksumValue) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateChecksum() uses a truthy check to detect unexpected trailers on line 56. If setExpectedChecksum is called with an empty string value (e.g. from a malformed trailer like x-amz-checksum-crc32: with nothing after the colon), the check evaluates to false and the unexpected trailer goes undetected. Consider using this.trailerChecksumValue !== undefined for consistency with line 27. — Claude Code

@claude
Copy link

claude bot commented Mar 19, 2026

  • ChecksumTransform.validateChecksum() line 56: truthy check on this.trailerChecksumValue can miss an unexpected trailer when the value is an empty string. Use !== undefined instead.

    Review by Claude Code

const dataStream = stripTrailingChecksumStream(dataStreamTmp, log, cbOnce);
return data.put(
cipherBundle, dataStream, size, objectContext, backendInfo, log,
cipherBundle, checksumedStream.stream, size, objectContext, backendInfo, log,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storeObject.js passes checksumedStream.stream (a ChecksumTransform) to data.put, but ChecksumTransform never has .headers set on it. In the old code, prepareStream always returned a stream with .headers = stream.headers. If any data backend or middleware reads .headers from the stream passed to data.put, this will be undefined.

I checked lib/data/wrapper.js and it does not reference .headers, but worth verifying against all backend implementations (sproxyd, azure, gcp, etc.) that none rely on it.

— Claude Code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.headers: checked — no backend in lib/data/ or Arsenal reads .headers off the stream passed to data.put.

if (checksumedStream.stream.writableFinished) {
return doValidate();
}
checksumedStream.stream.once('finish', doValidate);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race: if ChecksumTransform errors during _flush (e.g. crc64nvme digest fails), the error goes through errCb which calls cbOnce. But if writableFinished was false, the doValidate listener is also registered on 'finish'. Since _flush called callback(error), the stream will not emit 'finish' so the listener is harmless — but the 'finish' and error paths are independent, and both can potentially reach cbOnce. The jsutil.once(cb) guard prevents double-callback, but the upgraded onStreamError (line 88-96) could trigger batchDelete even though doValidate also triggers batchDelete on checksum failure. Consider whether both paths could fire in an edge case.

— Claude Code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double-delete: not a risk — finish and error are mutually exclusive in the Node.js stream lifecycle, so both handlers can't fire for the same stream.

@claude
Copy link

claude bot commented Mar 19, 2026

Well-structured PR that adds checksum validation for PutObject and UploadPart across all streaming modes. The ChecksumTransform, trailer parsing, and error mapping are clean. Two items to verify:

- ChecksumTransform (returned by prepareStream) does not have .headers set, unlike the old code which always set .headers on the returned stream. Verify no data backend reads .headers from the stream passed to data.put.
- In storeObject.js, the finish-wait path (line 121) and the upgraded onStreamError (line 88) are both registered after data.put succeeds. If both could fire (unlikely but worth reasoning about), batchDelete could be called twice for the same key. The jsutil.once(cb) prevents double-callback but not double-delete.

Review by Claude Code

@claude
Copy link

claude bot commented Mar 19, 2026

LGTM

The stream pipeline design is solid: ChecksumTransform always wraps the data path, the mutable errCb upgrade pattern in storeObject.js correctly handles cleanup after data.put, and jsutil.once guards prevent double callbacks across piped streams. Arsenal is correctly pinned to tag 8.3.6. The defensive err.is check in services.js is a good drive-by fix. Test coverage is comprehensive across all protocol modes and edge cases.

Review by Claude Code

@leif-scality leif-scality force-pushed the improvement/CLDSRV-863-checksums-put-object-part branch from a284c9f to dade07b Compare March 20, 2026 09:54
@claude
Copy link

claude bot commented Mar 20, 2026

LGTM

Well-structured PR. The new ChecksumTransform pipeline, trailer parsing, once-guard error handling, and the mutable-errCb upgrade pattern in storeObject are all cleanly implemented. The race condition fix in services.js (isCompleteMPUInProgress returning true on NoSuchKey, and the defensive err.is guard in metadataValidateMultipart) is correct. Arsenal pinned to tag 8.3.6. No issues found.

Review by Claude Code

@leif-scality leif-scality force-pushed the improvement/CLDSRV-863-checksums-put-object-part branch from a45ea10 to 65aa501 Compare March 23, 2026 10:52

for (const protocol of protocols) {
for (const algo of algos) {
itSkipIfAWS(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test names using itSkipIfAWS() in makeScenarioTests start with should, but the dynamically generated tests in the for loops (e.g. line 472 below) don't. They should follow the same convention.

— Claude Code

@claude
Copy link

claude bot commented Mar 23, 2026

LGTM — solid implementation of streaming checksum validation for PutObject and UploadPart. The stream pipeline design (V4Transform / TrailingChecksumTransform → ChecksumTransform), the once-guarded error callbacks, and the mutable onStreamError upgrade pattern in storeObject are all well done. The race condition fix in services.js (NoSuchKey guard for isCompleteMPUInProgress) is a good defensive addition.

One minor issue flagged inline:
- tests/functional/raw-node/test/checksumPutObjectUploadPart.js: dynamically generated test names in the for-loops don't start with should

Review by Claude Code

for (const protocol of protocols) {
for (const algo of algos) {
itSkipIfAWS(
`${protocol.name} with wrong x-amz-checksum-${algo.name}: returns 400 BadDigest`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test names passed to itSkipIfAWS() (which wraps it()) should start with should per project convention.

— Claude Code

@claude
Copy link

claude bot commented Mar 23, 2026

  • Test naming: dynamically generated test names in checksumPutObjectUploadPart.js (line 471) do not start with should
    • Prefix the template literal with should, e.g. `should return 400 BadDigest for ...`

Review by Claude Code

@claude
Copy link

claude bot commented Mar 23, 2026

LGTM

Well-structured PR. The ChecksumTransform pipeline is correctly composed for all three streaming modes (V4, unsigned-trailer, unsigned/default). Error handling uses once-guards throughout to prevent double callbacks, the mutable errCb reference in storeObject correctly upgrades to include batchDelete after data.put succeeds, and the finish-wait path handles the async _flush correctly. The services.js defensive fix for err.is and the NoSuchKey race condition handling are sound. Arsenal is pinned to tag 8.3.6. Tests are comprehensive with good coverage of chunk-boundary edge cases.

Review by Claude Code

Copy link
Contributor

@jonathan-gramain jonathan-gramain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review (stopped at commit "emit trailer in TrailingChecksumTransform"), will continue tomorrow

Comment on lines +41 to +48
if (!this.streamClosed && this.readingTrailer && this.trailerBuffer.length === 0) {
// Nothing came after "0\r\n", don't fail.
// If the x-amz-trailer header was present then the trailer is required and ChecksumTransform will fail.
return callback();
} else if (!this.streamClosed && this.readingTrailer && this.trailerBuffer.length !== 0) {
this.log.error('stream ended without trailer "\r\n"');
return callback(incompleteBodyError);
} else if (!this.streamClosed && !this.readingTrailer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could simplify the flow to avoid repeating conditions, e.g.:

if (streamClosed) {
    // return ok
}
if (!readingTrailer) {
    // log and return incomplete body error
}
if (trailerBuffer.length === 0) {
    // no trailer: ok
}
// log and return incomplete body error

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be even cleaner with an FSM when state management becomes a bit more complex, by maintaining an explicit state variable and acting based on its value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reworked as FSM

@@ -20,6 +24,10 @@ class TrailingChecksumTransform extends Transform {
this.bytesToDiscard = 0; // when trailing \r\n are present, we discard them but they can be in different chunks
this.bytesToRead = 0; // when a chunk is advertised, the size is put here and we forward all bytes
this.streamClosed = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not related to this PR, but how about renaming this variable with a more specific name like this.receivedLastChunk? I got confused as it can easily be mistaken for a nodejs stream state. But feel free to ignore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reworked as FSM

* headers and trailing checksum trailer from the stream, forwarding only
* the raw object data. The trailer name and value are emitted via a
* 'trailer' event so that ChecksumTransform can validate the checksum.
*/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc comment has broken indentation — lines 208-210 are missing a leading space before the *.

```suggestion

  • This class handles the chunked-upload body format used by
  • STREAMING-UNSIGNED-PAYLOAD-TRAILER requests. It strips the chunk-size
  • headers and trailing checksum trailer from the stream, forwarding only
  • the raw object data. The trailer name and value are emitted via a
  • 'trailer' event so that ChecksumTransform can validate the checksum.

}

const chunkLenStr = this.buffer.toString('ascii', 0, this.bufferOffset).trim();
// AWS does not do this check, it returns 500 if it is to large.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "to large" should be "too large".

```suggestion
// AWS does not do this check, it returns 500 if it is too large.

@claude
Copy link

claude bot commented Mar 24, 2026

  • Broken JSDoc indentation in trailingChecksumTransform.js:208-210 — lines are missing a leading space before the *
    - Add the missing space to align with the opening /** block
    - Typo in trailingChecksumTransform.js:82 — "to large" should be "too large"
    - Fix the typo

    Review by Claude Code

@leif-scality leif-scality force-pushed the improvement/CLDSRV-863-checksums-put-object-part branch from bd3d857 to 22c656c Compare March 24, 2026 21:56
@claude
Copy link

claude bot commented Mar 24, 2026

LGTM

Review by Claude Code

Copy link
Contributor

@jonathan-gramain jonathan-gramain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +41 to +48
if (!this.streamClosed && this.readingTrailer && this.trailerBuffer.length === 0) {
// Nothing came after "0\r\n", don't fail.
// If the x-amz-trailer header was present then the trailer is required and ChecksumTransform will fail.
return callback();
} else if (!this.streamClosed && this.readingTrailer && this.trailerBuffer.length !== 0) {
this.log.error('stream ended without trailer "\r\n"');
return callback(incompleteBodyError);
} else if (!this.streamClosed && !this.readingTrailer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be even cleaner with an FSM when state management becomes a bit more complex, by maintaining an explicit state variable and acting based on its value.

if (xAmzChecksumCnt > 1) {
return { error: ChecksumError.MultipleChecksumTypes, details: { algorithms: checksumHeaders } };
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick suggestion for clarity:

  • add const checksumHeader = checksumHeaders[0];
  • thereafter, check if a header is present via if (checksumHeader) and use it directly, instead of checking the count and using checksumHeaders[0]

}

_transform(chunk, encoding, callback) {
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably assume that it's a Buffer, I don't think we would send string contents to this stream in any case.


_transform(chunk, encoding, callback) {
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
this.hash.update(input, encoding);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since input is a buffer, encoding will be ignored, so better leave it out (if you wanted to support strings, then you would have to pass encoding to the Buffer.from call above, but as I said I don't think we would have to deal with strings).

this.trailerChecksumValue = undefined;
}

setExpectedChecksum(name, value) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like whether the checksum comes from the checksum header or a trailer, they should be treated the same way when it comes to comparing with the computed checksum.
I understand that you defer validation of the checksum value itself to validateChecksum, but maybe it would be best to validate it directly here, then update this.expectedChecksum so the following logic of validateChecksum can be agnostic of where the checksum comes from. (If you really need to defer the error reporting to validateChecksum, you could set an error flag this.checksumError or similar and check it at the beginning of validateChecksum, but it may not be needed)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is that when we call setExpectedChecksum we don't know if _flush has been called and finished, so this.digest may not be ready. In dataStore we make sure the checksumed stream is finished before calling validateChecksum, so we know this.digest is ready.

Let me know if this responds your concern or if I missed something

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant is that every sanity check done in the this.isTrailer case (https://github.com/scality/cloudserver/pull/6112/changes#diff-5ea0da6c64111c8a94b664848cc7c8e9201b530aa77f0a6e962a905af8055f9eR25) except the final digest check could be done within setExpectedChecksum, where the function would:

  • on success, set this.expectedChecksum to be validated by validateChecksum at the end, i.e. using the same code path than for non-trailer checksum
  • on error, either return a validation error immediately or mark the error in an attribute this.checksumError (for example) to be checked and returned first thing by validateChecksum

Copy link
Contributor

@jonathan-gramain jonathan-gramain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will review the new FSM tomorrow

stream.pipe(trailingChecksumTransform);
trailingChecksumTransform.headers = stream.headers;
return trailingChecksumTransform;
let stream = request;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you benefit from creating and overwriting this temporary variable, you could just use request or middleware streams like v4Transform directly where stream is used.

const doValidate = () => {
const checksumErr = checksumedStream.stream.validateChecksum();
if (checksumErr) {
log.debug('failed checksum validation stream', checksumErr);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.debug('failed checksum validation stream', checksumErr);
log.debug('failed checksum validation stream', { error: checksumErr });

});
});

it('should not delete stored data when checksum validation passes', done => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test is marked as "checksum validation passes" but it seems there is no checksum passed at all, is it meant to pass a valid x-amz-checksum-crc32 header matching the hello world payload for example?

];

// Build the raw chunked body for STREAMING-UNSIGNED-PAYLOAD-TRAILER
function buildTrailerBody(algoName, wrongDigest) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick on the variable name wrongDigest: technically this function doesn't care if the digest is correct or not, so it could just be called digest (meaning it could also potentially be used to build correct bodies)

// Constants for protocol scenario tests

const trailerContent = Buffer.from('trailer content'); // 15 bytes, hex 'f'
const trailerContentSha256 = '4x74k2oA6j6knXzpDwogNkS6E3MM49tPpJMjfD+ES68=';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const trailerContentSha256 = '4x74k2oA6j6knXzpDwogNkS6E3MM49tPpJMjfD+ES68=';
const trailerContentSha256 = crypto.createHash('sha256').update(trailerContent).digest('base64');


// Create the 24 common protocol-scenario tests for a given URL factory.
// urlFn() is called lazily at test runtime so that uploadId is available.
function makeScenarioTests(urlFn) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be worth adding some tests with larger synthetic bodies (e.g. 10MB of As), both with correct and wrong checksum, to check that streaming checksumming works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants