Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvement/s3 c 9769/ignore trailing checksum #5751

Conversation

fredmnl
Copy link
Contributor

@fredmnl fredmnl commented Feb 26, 2025

Motivation

Since aws-cli 2.23 (and associated SDK version), put-object now defaults to using unsigned payload with trailing checksums for integrity.

This is particularly nice from a computational point of view because the client only needs to read their file once to perform the upload. With AuthV4 signed payload, you would need to first compute the checksum of the object, to eventually include it in the signature contained in the header. With trailing checksums (and unsigned payload), the checksum is computed while the upload is happening and the checksum is sent at the end of the body.

Implementation

In this first iteration we are only interested in accepting the incoming requests using trailing checksums and unsigned payload. We are not yet verifying the checksum although the current proposal is extensible to perform this checksum verification as well.

@bert-e
Copy link
Contributor

bert-e commented Feb 26, 2025

Hello fredmnl,

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 Feb 26, 2025

Incorrect Jira project

The Jira issue S3C-9769 specified in the source
branch name, does not belong to project CLDSRV.

@fredmnl fredmnl force-pushed the improvement/S3C-9769/ignore-trailing-checksum branch from 76addf8 to bc68eb3 Compare February 27, 2025 18:42
@fredmnl
Copy link
Contributor Author

fredmnl commented Mar 3, 2025

@jonathan-gramain Thanks again for the review, I reworked the whole class to address your concerns.

I thought that my tests were covering all of the tricky splits that could happen but actually the http request module used was squashing the chunks together before sending. I resorted to coding a unit tests for the class which is much neater. I might have overdone it but they run pretty quickly so we could keep them all. 2487 passing (389ms)

I reworked the logic in a way which I think is much neater. I'm only detecting \r instead of \r\n, which can't be split across chunks. I'm not actually checking for the '\n', but I think that's acceptable as the number of bytes cannot contain \r anyway. (We have never checked the \r\n after data for example). I'm effectively looking for regex [^\r]+\r..{number_of_bytes}.. until 0\r is found.

I think that eventually we will want to refactor V4Transform to integrate this as well as checksum checking.

continue;
}

const lineBreakIndex2 = chunk.indexOf('\r');
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks functional to me, I still have a slight preference for finding the full sequence \r\n in the chunkSizeBuffer because it also validates the stream contents more strongly. But it's not blocking for me, I'm fine with this approach too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'd like to refactor both Transforms (the AuthV4 too) into a single processor that would actually work on a configurable FSM to both validate and process the stream. I think it would also make the V4Transform much more readable. With that in mind, we could deliver this iteration as is (we're aiming for a timely release on this fix). There will at some point be a further iteration to validate the checksums (prioritization aside)

@fredmnl fredmnl force-pushed the improvement/S3C-9769/ignore-trailing-checksum branch from c34a49d to dba1a83 Compare March 4, 2025 14:46
@fredmnl fredmnl marked this pull request as ready for review March 4, 2025 17:38
@fredmnl fredmnl requested a review from jonathan-gramain March 5, 2025 07:46
@fredmnl fredmnl force-pushed the improvement/S3C-9769/ignore-trailing-checksum branch from dba1a83 to 7862de5 Compare March 5, 2025 07:49
@fredmnl
Copy link
Contributor Author

fredmnl commented Mar 5, 2025

@jonathan-gramain I think we're ready for another review here

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.

LGTM with some minor suggestions

const lineBreakIndex = chunk.indexOf('\r');
if (lineBreakIndex === -1) {
if (this.chunkSizeBuffer.byteLength + chunk.byteLength > 10) {
this.log.debug('chunk size field too big', {
Copy link
Contributor

Choose a reason for hiding this comment

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

Two suggestions:

Turn into info level, as it can be useful for debugging on production platforms in case their app sends chunks that are too big for us to support

You could do first:

this.chunkSizeBuffer = Buffer.concat([this.chunkSizeBuffer, chunk]);

then check that the size is below what we support.

Then you can log directly this.chunkSizeBuffer.subarray(0, 16).toString():

  • You can remove the hex encoding since it's already JSON-encoded in logs

  • I think 16 chars allow to see the full size, while 8 chars may mask the least significant digits of the hex-encoded length

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Also see comment below, which I wrote first)

5GB is an AWS limit, it's forbidden to perform a single upload of more than 5GB hence the hard specification limit here, and 5GB needs 9 hex characters to be written, we give it an extra character for \r.

this.chunkSizeBuffer = Buffer.concat([this.chunkSizeBuffer, chunk]);

could potentially make us perform a huge buffer concatenation (although I think that Node would rechunk things before reaching this part of the code)

👍 on hex

👍 Yes 16 is better, in case the chunkSizeBuffer is empty, we would not see the whole 10 characters that we could have been interested in.

}
const dataSize = Number.parseInt(this.chunkSizeBuffer.toString(), 16);
if (Number.isNaN(dataSize)) {
this.log.debug('unable to parse chunk size', {
Copy link
Contributor

Choose a reason for hiding this comment

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

This case now looks unlikely due to the sanity check, so I think you can log it with error level

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, will do 👍

@fredmnl fredmnl force-pushed the improvement/S3C-9769/ignore-trailing-checksum branch from f8c4593 to 20011c1 Compare March 7, 2025 08:35
@fredmnl fredmnl force-pushed the improvement/S3C-9769/ignore-trailing-checksum branch from 20011c1 to 7b413ba Compare March 7, 2025 08:36
@fredmnl
Copy link
Contributor Author

fredmnl commented Mar 7, 2025

/approve

@bert-e
Copy link
Contributor

bert-e commented Mar 7, 2025

Incorrect Jira project

The Jira issue S3C-9769 specified in the source
branch name, does not belong to project CLDSRV.

The following options are set: approve

Copy link
Contributor

@williamlardier williamlardier left a comment

Choose a reason for hiding this comment

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

First review iteration, I will need to complete my review of the TrailingChecksumTransform class

Comment on lines +36 to +38
if (stream.headers['x-amz-trailer'] === undefined ||
stream.headers['x-amz-trailer'] === '') {
return stream;
Copy link
Contributor

Choose a reason for hiding this comment

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

We can just check if the value is set (both cases would return true here, and I believe we want to handle null the same way)

Suggested change
if (stream.headers['x-amz-trailer'] === undefined ||
stream.headers['x-amz-trailer'] === '') {
return stream;
if (!stream.headers['x-amz-trailer']) {
return stream;


if (headers['x-amz-trailer'] !== undefined &&
headers['x-amz-content-sha256'] !== 'STREAMING-UNSIGNED-PAYLOAD-TRAILER') {
return errors.BadRequest.customizeDescription('signed trailing checksum is not supported');
Copy link
Contributor

Choose a reason for hiding this comment

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

usually BadRequest (error 400) is for errors from client side, here, I would instead return a NotImplemented (error code 501)

constructor(log, errCb) {
super({});
this.log = log;
this.errCb = errCb;
Copy link
Contributor

Choose a reason for hiding this comment

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

We store this error callback in the class (which is a bit strange as passing such error function as a member of a class makes it complex to know when this will be called, and the impacts on the call path for the API).

But the main point is that this callback is not used anywhere: either we expect no error and we should remove this callback, or we can have error (handling of the Transform events?) and we need to use it somehow...

return stream;
}

const trailingChecksumTransform = new TrailingChecksumTransform(log, errCb);
Copy link
Contributor

Choose a reason for hiding this comment

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

We should not pass a callback for error handling here: we just pipe two streams, and then we can handle the errors in the same place as before.

Note: piping streams, in the past, led to memory leaks. We must ensure all errors /events are properly handled not to miss something here. See here for an example.

Comment on lines +74 to +76
this.log.info('chunk size is not a valid hex number', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
});
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
this.log.info('chunk size is not a valid hex number', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
});
this.log.error('chunk size is not a valid hex number', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
});

Comment on lines +56 to +58
this.log.info('chunk size field too big', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
truncatedChunk: chunk.subarray(0, 16).toString(),
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
this.log.info('chunk size field too big', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
truncatedChunk: chunk.subarray(0, 16).toString(),
this.log.error('chunk size field too big', {
chunkSizeBuffer: this.chunkSizeBuffer.toString(),
truncatedChunk: chunk.subarray(0, 16).toString(),

}

this.chunkSizeBuffer = Buffer.concat([this.chunkSizeBuffer, chunk.subarray(0, lineBreakIndex)]);
chunk = chunk.subarray(lineBreakIndex);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this is correct (I may be wrong)
The delimiter is \r, but don't we have \n\r at the end of each chunk?

In such case, should we do something like

if (lineBreakIndex === -1 || lineBreakIndex + 1 >= chunk.length || chunk[lineBreakIndex + 1] !== '\n'.charCodeAt(0)) {
    // Handle error or wait for more data
}
this.chunkSizeBuffer = Buffer.concat([this.chunkSizeBuffer, chunk.subarray(0, lineBreakIndex)]);
chunk = chunk.subarray(lineBreakIndex + 2);

@fredmnl fredmnl closed this Mar 17, 2025
@fredmnl
Copy link
Contributor Author

fredmnl commented Mar 17, 2025

Closing this PR in favor of #5757

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.

5 participants