Skip to content

Commit f0f64e5

Browse files
committed
Merge branch 'codex/bugs-blob-hardening'
2 parents 40a47fe + a26b57b commit f0f64e5

5 files changed

Lines changed: 505 additions & 73 deletions

File tree

cli-argv.test.mjs

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77

88
import { describe, it, before, after, beforeEach } from "node:test";
99
import assert from "node:assert/strict";
10-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1111
import { join } from "node:path";
1212
import { tmpdir } from "node:os";
13+
import { createHash } from "node:crypto";
1314

1415
const tempDir = mkdtempSync(join(tmpdir(), "run402-argv-"));
1516
const API = "https://test-api.run402.com";
@@ -325,6 +326,41 @@ describe("contracts wei argv validation", () => {
325326

326327
describe("2026-05 CLI bug backlog argv validation", () => {
327328
const invalidCases = [
329+
{
330+
issue: "GH-319",
331+
name: "blob sign rejects TTL below signed URL minimum",
332+
module: "./cli/lib/blob.mjs",
333+
call: (run) => run("sign", ["reports/a.pdf", "--project", "prj_test123", "--ttl", "59"]),
334+
code: "BAD_FLAG",
335+
},
336+
{
337+
issue: "GH-318",
338+
name: "blob get rejects extra positional keys",
339+
module: "./cli/lib/blob.mjs",
340+
call: (run) => run("get", ["a.txt", "b.txt", "--output", join(tempDir, "blob-extra.txt"), "--project", "prj_test123"]),
341+
code: "BAD_USAGE",
342+
},
343+
{
344+
issue: "GH-318",
345+
name: "blob rm rejects extra positional keys",
346+
module: "./cli/lib/blob.mjs",
347+
call: (run) => run("rm", ["a.txt", "b.txt", "--project", "prj_test123"]),
348+
code: "BAD_USAGE",
349+
},
350+
{
351+
issue: "GH-318",
352+
name: "blob sign rejects extra positional keys",
353+
module: "./cli/lib/blob.mjs",
354+
call: (run) => run("sign", ["a.txt", "b.txt", "--project", "prj_test123"]),
355+
code: "BAD_USAGE",
356+
},
357+
{
358+
issue: "GH-318",
359+
name: "blob diagnose rejects extra positional URLs",
360+
module: "./cli/lib/blob.mjs",
361+
call: (run) => run("diagnose", ["https://app.run402.com/_blob/a.txt", "https://app.run402.com/_blob/b.txt", "--project", "prj_test123"]),
362+
code: "BAD_USAGE",
363+
},
328364
{
329365
issue: "GH-306",
330366
name: "blob put rejects multi-file uploads with a fixed --key",
@@ -833,6 +869,250 @@ describe("numeric flag validation", () => {
833869
assert.equal(initBody?.key, "assets/logo");
834870
});
835871

872+
it("blob put sends required object and part checksums by default (GH-308, GH-312, GH-314)", async () => {
873+
const { run } = await import("./cli/lib/blob.mjs");
874+
const file = join(tempDir, "checksum-upload.txt");
875+
writeFileSync(file, "hello world");
876+
let initBody = null;
877+
let completeBody = null;
878+
let putHeaders = null;
879+
const expectedSha = createHash("sha256").update("hello world").digest("hex");
880+
const expectedPartChecksum = createHash("sha256").update("hello world").digest("base64");
881+
const prevFetch = globalThis.fetch;
882+
globalThis.fetch = (input, init) => {
883+
const info = requestInfo(input, init);
884+
calls.push(info);
885+
if (info.path === "/storage/v1/uploads" && info.method === "POST") {
886+
initBody = JSON.parse(String(info.init.body));
887+
return Promise.resolve(json({
888+
upload_id: "upload_checksum",
889+
mode: "multipart",
890+
part_count: 1,
891+
parts: [{ part_number: 1, url: "https://s3.example.test/upload_checksum/p1", byte_start: 0, byte_end: 10 }],
892+
}, 201));
893+
}
894+
if (info.url === "https://s3.example.test/upload_checksum/p1" && info.method === "PUT") {
895+
putHeaders = info.init.headers ?? {};
896+
return Promise.resolve(new Response("", { status: 200, headers: { etag: "\"etag-1\"" } }));
897+
}
898+
if (info.path === "/storage/v1/uploads/upload_checksum/complete" && info.method === "POST") {
899+
completeBody = JSON.parse(String(info.init.body));
900+
return Promise.resolve(json({
901+
key: "checksum-upload.txt",
902+
size_bytes: 11,
903+
sha256: expectedSha,
904+
visibility: "public",
905+
content_type: "text/plain",
906+
url: "https://pr-test.run402.com/_blob/checksum-upload.txt",
907+
immutable_url: null,
908+
}));
909+
}
910+
return mockFetch(input, init);
911+
};
912+
captureStart();
913+
try {
914+
await run("put", [file, "--project", "prj_test123", "--no-resume"]);
915+
} finally {
916+
captureStop();
917+
globalThis.fetch = prevFetch;
918+
}
919+
920+
assert.equal(initBody?.sha256, expectedSha);
921+
assert.equal(initBody?.immutable, false);
922+
assert.equal(putHeaders?.["x-amz-checksum-sha256"], expectedPartChecksum);
923+
assert.deepEqual(completeBody?.parts, [
924+
{ part_number: 1, etag: "\"etag-1\"", sha256: expectedSha },
925+
]);
926+
});
927+
928+
it("blob put does not complete multipart uploads until every in-flight part settles (GH-315)", async () => {
929+
const { run } = await import("./cli/lib/blob.mjs");
930+
const file = join(tempDir, "concurrency-upload.txt");
931+
writeFileSync(file, "abcdefghi");
932+
let part2Resolved = false;
933+
let completeBeforePart2 = false;
934+
const prevFetch = globalThis.fetch;
935+
globalThis.fetch = (input, init) => {
936+
const info = requestInfo(input, init);
937+
calls.push(info);
938+
if (info.path === "/storage/v1/uploads" && info.method === "POST") {
939+
return Promise.resolve(json({
940+
upload_id: "upload_concurrency",
941+
mode: "multipart",
942+
part_count: 3,
943+
parts: [
944+
{ part_number: 1, url: "https://s3.example.test/upload_concurrency/p1", byte_start: 0, byte_end: 2 },
945+
{ part_number: 2, url: "https://s3.example.test/upload_concurrency/p2", byte_start: 3, byte_end: 5 },
946+
{ part_number: 3, url: "https://s3.example.test/upload_concurrency/p3", byte_start: 6, byte_end: 8 },
947+
],
948+
}, 201));
949+
}
950+
if (info.url === "https://s3.example.test/upload_concurrency/p2" && info.method === "PUT") {
951+
return new Promise((resolve) => {
952+
setTimeout(() => {
953+
part2Resolved = true;
954+
resolve(new Response("", { status: 200, headers: { etag: "\"etag-2\"" } }));
955+
}, 100);
956+
});
957+
}
958+
if (info.url.startsWith("https://s3.example.test/upload_concurrency/p") && info.method === "PUT") {
959+
const partNumber = info.url.endsWith("/p1") ? "1" : "3";
960+
return Promise.resolve(new Response("", { status: 200, headers: { etag: `"etag-${partNumber}"` } }));
961+
}
962+
if (info.path === "/storage/v1/uploads/upload_concurrency/complete" && info.method === "POST") {
963+
completeBeforePart2 = !part2Resolved;
964+
return Promise.resolve(json({
965+
key: "concurrency-upload.txt",
966+
size_bytes: 9,
967+
sha256: createHash("sha256").update("abcdefghi").digest("hex"),
968+
visibility: "public",
969+
content_type: "text/plain",
970+
url: "https://pr-test.run402.com/_blob/concurrency-upload.txt",
971+
immutable_url: null,
972+
}));
973+
}
974+
return mockFetch(input, init);
975+
};
976+
captureStart();
977+
try {
978+
await run("put", [file, "--project", "prj_test123", "--concurrency", "2", "--no-resume"]);
979+
} finally {
980+
captureStop();
981+
globalThis.fetch = prevFetch;
982+
}
983+
984+
assert.equal(completeBeforePart2, false, "complete must wait for all in-flight parts");
985+
});
986+
987+
it("blob resumable state is private, checksum-bearing, and does not persist presigned URLs (GH-316, GH-317)", async () => {
988+
const stateHome = mkdtempSync(join(tmpdir(), "run402-blob-home-"));
989+
const prevHome = process.env.HOME;
990+
process.env.HOME = stateHome;
991+
const { run } = await import("./cli/lib/blob.mjs?state-private");
992+
const file = join(tempDir, "state-upload.txt");
993+
writeFileSync(file, "hello state");
994+
const expectedSha = createHash("sha256").update("hello state").digest("hex");
995+
const prevFetch = globalThis.fetch;
996+
globalThis.fetch = (input, init) => {
997+
const info = requestInfo(input, init);
998+
calls.push(info);
999+
if (info.path === "/storage/v1/uploads" && info.method === "POST") {
1000+
return Promise.resolve(json({
1001+
upload_id: "upload_state",
1002+
mode: "multipart",
1003+
part_count: 1,
1004+
parts: [{ part_number: 1, url: "https://s3.example.test/upload_state/p1", byte_start: 0, byte_end: 10 }],
1005+
}, 201));
1006+
}
1007+
if (info.url === "https://s3.example.test/upload_state/p1" && info.method === "PUT") {
1008+
return Promise.resolve(new Response("denied", { status: 403, statusText: "Forbidden" }));
1009+
}
1010+
return mockFetch(input, init);
1011+
};
1012+
1013+
try {
1014+
const err = await expectExit1(() => run("put", [file, "--project", "prj_test123"]));
1015+
assert.match(err.message, /Part 1 PUT failed/);
1016+
1017+
const stateDir = join(stateHome, ".run402", "uploads");
1018+
const files = readdirSync(stateDir).filter((name) => name.endsWith(".json"));
1019+
assert.deepEqual(files, ["upload_state.json"]);
1020+
assert.equal(statSync(stateDir).mode & 0o777, 0o700);
1021+
const statePath = join(stateDir, "upload_state.json");
1022+
assert.equal(statSync(statePath).mode & 0o777, 0o600);
1023+
const raw = readFileSync(statePath, "utf8");
1024+
assert.equal(raw.includes("https://s3.example.test"), false, "state must not persist presigned URLs");
1025+
const state = JSON.parse(raw);
1026+
assert.equal(state.sha256, expectedSha);
1027+
assert.equal(state.file_size, 11);
1028+
assert.equal(typeof state.file_mtime_ms, "number");
1029+
} finally {
1030+
globalThis.fetch = prevFetch;
1031+
if (prevHome === undefined) delete process.env.HOME;
1032+
else process.env.HOME = prevHome;
1033+
rmSync(stateHome, { recursive: true, force: true });
1034+
}
1035+
});
1036+
1037+
it("blob put discards cached upload sessions when the local file changed (GH-316)", async () => {
1038+
const stateHome = mkdtempSync(join(tmpdir(), "run402-blob-home-"));
1039+
const prevHome = process.env.HOME;
1040+
process.env.HOME = stateHome;
1041+
const { run } = await import("./cli/lib/blob.mjs?state-fingerprint");
1042+
const stateDir = join(stateHome, ".run402", "uploads");
1043+
const file = join(tempDir, "state-changed.txt");
1044+
writeFileSync(file, "new file");
1045+
const absFile = join(tempDir, "state-changed.txt");
1046+
const staleState = {
1047+
upload_id: "upload_stale",
1048+
project_id: "prj_test123",
1049+
local_path: absFile,
1050+
key: "state-changed.txt",
1051+
mode: "multipart",
1052+
part_size_bytes: 3,
1053+
part_count: 1,
1054+
file_size: 999,
1055+
file_mtime_ms: 1,
1056+
parts_done: {},
1057+
sha256: "0".repeat(64),
1058+
};
1059+
rmSync(stateDir, { recursive: true, force: true });
1060+
writeFileSync(file, "new file");
1061+
const prevFetch = globalThis.fetch;
1062+
let fetchedStale = false;
1063+
let initUploadId = null;
1064+
globalThis.fetch = (input, init) => {
1065+
const info = requestInfo(input, init);
1066+
calls.push(info);
1067+
if (info.path === "/storage/v1/uploads/upload_stale" && info.method === "GET") {
1068+
fetchedStale = true;
1069+
return Promise.resolve(json({ upload_id: "upload_stale", status: "active" }));
1070+
}
1071+
if (info.path === "/storage/v1/uploads" && info.method === "POST") {
1072+
initUploadId = "upload_fresh";
1073+
return Promise.resolve(json({
1074+
upload_id: "upload_fresh",
1075+
mode: "single",
1076+
part_count: 1,
1077+
parts: [{ part_number: 1, url: "https://s3.example.test/upload_fresh/p1", byte_start: 0, byte_end: 7 }],
1078+
}, 201));
1079+
}
1080+
if (info.url === "https://s3.example.test/upload_fresh/p1" && info.method === "PUT") {
1081+
return Promise.resolve(new Response("", { status: 200, headers: { etag: "\"etag-fresh\"" } }));
1082+
}
1083+
if (info.path === "/storage/v1/uploads/upload_fresh/complete" && info.method === "POST") {
1084+
return Promise.resolve(json({
1085+
key: "state-changed.txt",
1086+
size_bytes: 8,
1087+
sha256: createHash("sha256").update("new file").digest("hex"),
1088+
visibility: "public",
1089+
content_type: "text/plain",
1090+
url: "https://pr-test.run402.com/_blob/state-changed.txt",
1091+
immutable_url: null,
1092+
}));
1093+
}
1094+
return mockFetch(input, init);
1095+
};
1096+
1097+
try {
1098+
rmSync(stateDir, { recursive: true, force: true });
1099+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
1100+
writeFileSync(join(stateDir, "upload_stale.json"), JSON.stringify(staleState, null, 2), { mode: 0o600 });
1101+
captureStart();
1102+
await run("put", [file, "--project", "prj_test123"]);
1103+
captureStop();
1104+
assert.equal(fetchedStale, false, "changed-file state should be discarded before polling stale session");
1105+
assert.equal(initUploadId, "upload_fresh");
1106+
assert.equal(existsSync(join(stateDir, "upload_stale.json")), false);
1107+
} finally {
1108+
captureStop();
1109+
globalThis.fetch = prevFetch;
1110+
if (prevHome === undefined) delete process.env.HOME;
1111+
else process.env.HOME = prevHome;
1112+
rmSync(stateHome, { recursive: true, force: true });
1113+
}
1114+
});
1115+
8361116
it("blob put surfaces upload-init gateway errors as structured JSON (GH-186)", async () => {
8371117
const { run } = await import("./cli/lib/blob.mjs");
8381118
const file = join(tempDir, "upload-init-fails.txt");

0 commit comments

Comments
 (0)