Skip to content

Commit 0562a0d

Browse files
authored
fix(lib-storage): add validation for partCount and contentLength for multipart upload (#7363)
1 parent 5284b82 commit 0562a0d

File tree

3 files changed

+160
-4
lines changed

3 files changed

+160
-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 part(s) but uploaded 1 part(s).");
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 byte 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(`The byte size for part number 1, size 0 does not match expected size ${MOCK_PART_SIZE}`);
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: 36 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?: number;
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 = Math.max(Upload.MIN_PART_SIZE, Math.floor((this.totalBytes || 0) / this.MAX_PARTS));
97+
this.expectedPartsCount = this.totalBytes !== undefined ? Math.ceil(this.totalBytes / this.partSize) : undefined;
98+
99+
this.__validateInput();
97100
}
98101

99102
async abort(): Promise<void> {
@@ -282,6 +285,8 @@ export class Upload extends EventEmitter {
282285

283286
this.uploadEnqueuedPartsCount += 1;
284287

288+
this.__validateUploadPart(dataPart);
289+
285290
const partResult = await this.client.send(
286291
new UploadPartCommand({
287292
...this.params,
@@ -364,6 +369,11 @@ export class Upload extends EventEmitter {
364369

365370
let result;
366371
if (this.isMultiPart) {
372+
const { expectedPartsCount, uploadedParts } = this;
373+
if (expectedPartsCount !== undefined && uploadedParts.length !== expectedPartsCount) {
374+
throw new Error(`Expected ${expectedPartsCount} part(s) but uploaded ${uploadedParts.length} part(s).`);
375+
}
376+
367377
this.uploadedParts.sort((a, b) => a.PartNumber! - b.PartNumber!);
368378

369379
const uploadCompleteParams = {
@@ -427,6 +437,28 @@ export class Upload extends EventEmitter {
427437
});
428438
}
429439

440+
private __validateUploadPart(dataPart: RawDataPart): void {
441+
const actualPartSize = byteLength(dataPart.data);
442+
443+
if (actualPartSize === undefined) {
444+
throw new Error(
445+
`A dataPart was generated without a measurable data chunk size for part number ${dataPart.partNumber}`
446+
);
447+
}
448+
449+
// Skip validation for single-part uploads (PUT operations)
450+
if (dataPart.partNumber === 1 && dataPart.lastPart) {
451+
return;
452+
}
453+
454+
// Validate part size (last part may be smaller)
455+
if (!dataPart.lastPart && actualPartSize !== this.partSize) {
456+
throw new Error(
457+
`The byte size for part number ${dataPart.partNumber}, size ${actualPartSize} does not match expected size ${this.partSize}`
458+
);
459+
}
460+
}
461+
430462
private __validateInput(): void {
431463
if (!this.params) {
432464
throw new Error(`InputError: Upload requires params to be passed to upload.`);

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,43 @@ describe("@aws-sdk/lib-storage", () => {
139139
"S3Client AbortMultipartUploadCommand 204",
140140
]);
141141
});
142+
143+
it("should validate part size constraints", () => {
144+
const upload = new Upload({
145+
client,
146+
params: {
147+
Bucket,
148+
Key: `validation-test-${Date.now()}`,
149+
Body: Buffer.alloc(1024 * 1024 * 10),
150+
},
151+
});
152+
153+
const invalidPart = {
154+
partNumber: 2,
155+
data: Buffer.alloc(1024 * 1024 * 3), // 3MB - too small for non-final part
156+
lastPart: false,
157+
};
158+
159+
expect(() => {
160+
(upload as any).__validateUploadPart(invalidPart);
161+
}).toThrow(/The byte size for part number 2, size \d+ does not match expected size \d+/);
162+
});
163+
164+
it("should validate part count constraints", async () => {
165+
const upload = new Upload({
166+
client,
167+
params: {
168+
Bucket,
169+
Key: `validation-test-${Date.now()}`,
170+
Body: Buffer.alloc(1024 * 1024 * 10),
171+
},
172+
});
173+
174+
(upload as any).uploadedParts = [{ PartNumber: 1, ETag: "etag1" }];
175+
(upload as any).isMultiPart = true;
176+
177+
await expect(upload.done()).rejects.toThrow(/Expected \d+ part\(s\) but uploaded \d+ part\(s\)\./);
178+
});
142179
});
143180
}
144181
);

0 commit comments

Comments
 (0)