Skip to content

Commit 4e1215b

Browse files
MajorTalclaude
andcommitted
fix(blob): drop client-side x-amz-checksum-sha256 header on PUTs
S3 rejected the PUTs with 'headers not signed: x-amz-checksum-sha256' because the presigned URL (from the gateway) didn't include that header in its signed headers list. The gateway now signs URLs without ChecksumAlgorithm (see run402-private commit d1c0f774), so the client must NOT send the checksum header either. Client-asserted sha256 (declared at POST /uploads) remains the integrity attestation — stored in the blob row and echoed back as x-run402-sha256 at download time. Immutable URLs still work end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6e90d90 commit 4e1215b

2 files changed

Lines changed: 24 additions & 10 deletions

File tree

cli/lib/blob.mjs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,21 +202,28 @@ async function putOne(project, filePath, opts) {
202202
// this loop runs once.
203203
const etags = Array(initRes.part_count);
204204
for (const pn of Object.keys(state.parts_done || {})) {
205-
etags[parseInt(pn, 10) - 1] = state.parts_done[pn];
205+
const pd = state.parts_done[pn];
206+
// Legacy resume state stored just the etag string; new code stores
207+
// { etag, sha256 }. Normalize on load.
208+
etags[parseInt(pn, 10) - 1] = typeof pd === "string" ? { etag: pd, sha256: undefined } : pd;
206209
}
207210

211+
// Presigned URLs are signed WITHOUT ChecksumAlgorithm (see gateway
212+
// s3-presign.ts). The client-asserted sha256 declared at init is the
213+
// integrity attestation — no x-amz-checksum-sha256 header on PUTs, and
214+
// the gateway trusts the declared value at complete when S3 has none.
208215
const todo = initRes.parts.filter((p) => !(state.parts_done || {})[String(p.part_number)]);
209216
await withConcurrency(todo, opts.concurrency, async (part) => {
210217
const { etag } = await putPart(filePath, part);
211-
etags[part.part_number - 1] = etag;
212-
state.parts_done[String(part.part_number)] = etag;
218+
etags[part.part_number - 1] = { etag };
219+
state.parts_done[String(part.part_number)] = { etag };
213220
saveState(state);
214221
log(opts, { event: "part", upload_id: state.upload_id, part_number: part.part_number, etag });
215222
});
216223

217224
// Complete
218225
const body = initRes.mode === "multipart"
219-
? { parts: etags.map((e, i) => ({ part_number: i + 1, etag: e })) }
226+
? { parts: etags.map((e, i) => ({ part_number: i + 1, etag: e.etag })) }
220227
: {};
221228
const complete = await apiFetch(`${API}/storage/v1/uploads/${state.upload_id}/complete`, "POST", project, body);
222229
if (complete.status !== 200) die(`Complete failed: HTTP ${complete.status}: ${JSON.stringify(complete.body)}`);
@@ -242,7 +249,8 @@ async function putPart(filePath, part) {
242249

243250
const res = await fetch(part.url, { method: "PUT", body });
244251
if (!res.ok) {
245-
throw new Error(`Part ${part.part_number} PUT failed: ${res.status} ${res.statusText}`);
252+
const errBody = await res.text().catch(() => "");
253+
throw new Error(`Part ${part.part_number} PUT failed: ${res.status} ${res.statusText}${errBody ? " — " + errBody.slice(0, 200) : ""}`);
246254
}
247255
const etag = res.headers.get("etag") ?? "";
248256
return { etag };

src/tools/blob-put.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,23 +103,29 @@ export async function handleBlobPut(args: Args): Promise<{ content: Array<{ type
103103
part_count: number;
104104
};
105105

106-
// 2. PUT each part (bytes direct to S3 — NOT through the gateway)
107-
const etags: string[] = new Array(initBody.part_count);
106+
// 2. PUT each part (bytes direct to S3 — NOT through the gateway).
107+
// Presigned URLs sign without ChecksumAlgorithm (see gateway
108+
// s3-presign.ts); the client-asserted sha256 is the integrity
109+
// attestation. No x-amz-checksum-sha256 header is sent.
110+
const partEtags: Array<{ etag: string }> = new Array(initBody.part_count);
108111
for (const part of initBody.parts) {
109112
const partBuffer = await readPart(bodyBuffer, args.local_path, part.byte_start, part.byte_end);
110113
const putRes = await fetch(part.url, { method: "PUT", body: partBuffer as unknown as BodyInit });
111114
if (!putRes.ok) {
115+
const errBody = await putRes.text().catch(() => "");
112116
return {
113-
content: [{ type: "text", text: `Part ${part.part_number} PUT failed: ${putRes.status} ${putRes.statusText}` }],
117+
content: [{ type: "text", text: `Part ${part.part_number} PUT failed: ${putRes.status} ${putRes.statusText}${errBody ? " — " + errBody.slice(0, 200) : ""}` }],
114118
isError: true,
115119
};
116120
}
117-
etags[part.part_number - 1] = (putRes.headers.get("etag") ?? "").replace(/^"|"$/g, "");
121+
partEtags[part.part_number - 1] = {
122+
etag: (putRes.headers.get("etag") ?? "").replace(/^"|"$/g, ""),
123+
};
118124
}
119125

120126
// 3. Complete
121127
const completeBody = initBody.mode === "multipart"
122-
? { parts: etags.map((e, i) => ({ part_number: i + 1, etag: `"${e}"` })) }
128+
? { parts: partEtags.map((e, i) => ({ part_number: i + 1, etag: `"${e.etag}"` })) }
123129
: {};
124130
const complete = await apiRequest(`/storage/v1/uploads/${initBody.upload_id}/complete`, {
125131
method: "POST",

0 commit comments

Comments
 (0)