Skip to content

Commit 7101875

Browse files
committed
feat(lib-storage): add validation for partCount and contentLength for multipart upload
1 parent 10b3d03 commit 7101875

File tree

2 files changed

+127
-4
lines changed

2 files changed

+127
-4
lines changed

lib/lib-storage/src/Upload.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,4 +792,91 @@ describe(Upload.name, () => {
792792
new Error("@aws-sdk/lib-storage: this instance of Upload has already executed .done(). Create a new instance.")
793793
);
794794
});
795+
796+
describe("Upload Part and parts count validation", () => {
797+
const MOCK_PART_SIZE = 1024 * 1024 * 5; // 5MB
798+
799+
it("should throw error when uploaded parts count doesn't match expected parts count", async () => {
800+
const largeBuffer = Buffer.from("#".repeat(MOCK_PART_SIZE * 2 + 100));
801+
const upload = new Upload({
802+
params: { ...params, Body: largeBuffer },
803+
client: new S3({}),
804+
});
805+
806+
(upload as any).__doConcurrentUpload = vi.fn().mockResolvedValue(undefined);
807+
808+
(upload as any).uploadedParts = [{ PartNumber: 1, ETag: "etag1" }];
809+
(upload as any).isMultiPart = true;
810+
811+
await expect(upload.done()).rejects.toThrow("Expected 3 number of parts but uploaded only 1 part");
812+
});
813+
814+
it("should throw error when part size doesn't match expected size except for laast part", () => {
815+
const upload = new Upload({
816+
params,
817+
client: new S3({}),
818+
});
819+
820+
const invalidPart = {
821+
partNumber: 1,
822+
data: Buffer.from("small"),
823+
lastPart: false,
824+
};
825+
826+
expect(() => {
827+
(upload as any).__validateUploadPart(invalidPart, MOCK_PART_SIZE);
828+
}).toThrow(`The Part size for part number 1, size 5 does not match expected size ${MOCK_PART_SIZE}`);
829+
});
830+
831+
it("should allow smaller size for last part", () => {
832+
const upload = new Upload({
833+
params,
834+
client: new S3({}),
835+
});
836+
837+
const lastPart = {
838+
partNumber: 2,
839+
data: Buffer.from("small"),
840+
lastPart: true,
841+
};
842+
843+
expect(() => {
844+
(upload as any).__validateUploadPart(lastPart, MOCK_PART_SIZE);
845+
}).not.toThrow();
846+
});
847+
848+
it("should throw error when part has zero content length", () => {
849+
const upload = new Upload({
850+
params,
851+
client: new S3({}),
852+
});
853+
854+
const emptyPart = {
855+
partNumber: 1,
856+
data: Buffer.from(""),
857+
lastPart: false,
858+
};
859+
860+
expect(() => {
861+
(upload as any).__validateUploadPart(emptyPart, MOCK_PART_SIZE);
862+
}).toThrow("Content length is missing on the data for part number 1");
863+
});
864+
865+
it("should skip validation for single-part uploads", () => {
866+
const upload = new Upload({
867+
params,
868+
client: new S3({}),
869+
});
870+
871+
const singlePart = {
872+
partNumber: 1,
873+
data: Buffer.from("small"),
874+
lastPart: true,
875+
};
876+
877+
expect(() => {
878+
(upload as any).__validateUploadPart(singlePart, MOCK_PART_SIZE);
879+
}).not.toThrow();
880+
});
881+
});
795882
});

lib/lib-storage/src/Upload.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class Upload extends EventEmitter {
4747

4848
// Defaults.
4949
private readonly queueSize: number = 4;
50-
private readonly partSize = Upload.MIN_PART_SIZE;
50+
private readonly partSize: number;
5151
private readonly leavePartsOnError: boolean = false;
5252
private readonly tags: Tag[] = [];
5353

@@ -66,6 +66,7 @@ export class Upload extends EventEmitter {
6666

6767
private uploadedParts: CompletedPart[] = [];
6868
private uploadEnqueuedPartsCount = 0;
69+
private expectedPartsCount = 0;
6970
/**
7071
* Last UploadId if the upload was done with MultipartUpload and not PutObject.
7172
*/
@@ -81,19 +82,21 @@ export class Upload extends EventEmitter {
8182

8283
// set defaults from options.
8384
this.queueSize = options.queueSize || this.queueSize;
84-
this.partSize = options.partSize || this.partSize;
8585
this.leavePartsOnError = options.leavePartsOnError || this.leavePartsOnError;
8686
this.tags = options.tags || this.tags;
8787

8888
this.client = options.client;
8989
this.params = options.params;
9090

91-
this.__validateInput();
92-
9391
// set progress defaults
9492
this.totalBytes = byteLength(this.params.Body);
9593
this.bytesUploadedSoFar = 0;
9694
this.abortController = options.abortController ?? new AbortController();
95+
96+
this.partSize = this.__calculatePartSize(this.totalBytes ?? 0, Upload.MIN_PART_SIZE);
97+
this.expectedPartsCount = this.totalBytes ? Math.ceil(this.totalBytes / this.partSize) : 1;
98+
99+
this.__validateInput();
97100
}
98101

99102
async abort(): Promise<void> {
@@ -234,6 +237,8 @@ export class Upload extends EventEmitter {
234237
return;
235238
}
236239

240+
this.__validateUploadPart(dataPart, this.partSize);
241+
237242
// Use put instead of multipart for one chunk uploads.
238243
if (dataPart.partNumber === 1 && dataPart.lastPart) {
239244
return await this.__uploadUsingPut(dataPart);
@@ -364,6 +369,12 @@ export class Upload extends EventEmitter {
364369

365370
let result;
366371
if (this.isMultiPart) {
372+
if (this.totalBytes && this.uploadedParts.length !== this.expectedPartsCount) {
373+
throw new Error(
374+
`Expected ${this.expectedPartsCount} number of parts but uploaded only ${this.uploadedParts.length} parts`
375+
);
376+
}
377+
367378
this.uploadedParts.sort((a, b) => a.PartNumber! - b.PartNumber!);
368379

369380
const uploadCompleteParams = {
@@ -427,6 +438,31 @@ export class Upload extends EventEmitter {
427438
});
428439
}
429440

441+
private __calculatePartSize(targetPartSizeBytes: number, minPartSize: number): number {
442+
const calculatedPartSize = Math.floor(targetPartSizeBytes / this.MAX_PARTS);
443+
return Math.max(minPartSize, calculatedPartSize);
444+
}
445+
446+
private __validateUploadPart(dataPart: RawDataPart, partSize: number): void {
447+
const actualPartSize = byteLength(dataPart.data) || 0;
448+
449+
// Skip validation for single-part uploads (PUT operations)
450+
if (dataPart.partNumber === 1 && dataPart.lastPart) {
451+
return;
452+
}
453+
454+
if (actualPartSize === 0) {
455+
throw new Error(`Content length is missing on the data for part number ${dataPart.partNumber}`);
456+
}
457+
458+
// Validate part size (last part may be smaller)
459+
if (!dataPart.lastPart && actualPartSize !== partSize) {
460+
throw new Error(
461+
`The Part size for part number ${dataPart.partNumber}, size ${actualPartSize} does not match expected size ${partSize}`
462+
);
463+
}
464+
}
465+
430466
private __validateInput(): void {
431467
if (!this.params) {
432468
throw new Error(`InputError: Upload requires params to be passed to upload.`);

0 commit comments

Comments
 (0)