Skip to content

Commit 522da25

Browse files
committed
fix(skills): bound upload expiry checks
1 parent d44621b commit 522da25

2 files changed

Lines changed: 78 additions & 5 deletions

File tree

src/skills/lifecycle/upload-store.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { MAX_DATE_TIMESTAMP_MS } from "../../shared/number-coercion.js";
67
import {
78
createSkillUploadStore,
89
MAX_ACTIVE_SKILL_UPLOADS,
@@ -272,6 +273,60 @@ describe("skill upload store", () => {
272273
);
273274
});
274275

276+
it("rejects new uploads when the clock cannot produce a valid expiry", async () => {
277+
const rootDir = await makeTempDir();
278+
const invalidClockStore = createSkillUploadStore({
279+
rootDir,
280+
now: () => Number.NaN,
281+
});
282+
await expectUploadError(
283+
invalidClockStore.begin({
284+
kind: "skill-archive",
285+
slug: "invalid-clock",
286+
sizeBytes: 1,
287+
}),
288+
"invalid upload expiry",
289+
);
290+
291+
const overflowStore = createSkillUploadStore({
292+
rootDir,
293+
now: () => MAX_DATE_TIMESTAMP_MS,
294+
});
295+
await expectUploadError(
296+
overflowStore.begin({
297+
kind: "skill-archive",
298+
slug: "overflow-clock",
299+
sizeBytes: 1,
300+
}),
301+
"invalid upload expiry",
302+
);
303+
});
304+
305+
it("does not count uploads with invalid stored expiry as active", async () => {
306+
const rootDir = await makeTempDir();
307+
const store = createSkillUploadStore({ rootDir });
308+
const begin = await store.begin({
309+
kind: "skill-archive",
310+
slug: "invalid-expiry",
311+
sizeBytes: 1,
312+
idempotencyKey: "invalid-expiry",
313+
});
314+
const metadataPath = path.join(rootDir, begin.uploadId, "metadata.json");
315+
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")) as Record<string, unknown>;
316+
metadata.expiresAt = null;
317+
await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
318+
319+
const repeated = await store.begin({
320+
kind: "skill-archive",
321+
slug: "invalid-expiry",
322+
sizeBytes: 1,
323+
idempotencyKey: "invalid-expiry",
324+
});
325+
326+
expect(repeated.uploadId).not.toBe(begin.uploadId);
327+
await expectMissingPath(path.join(rootDir, begin.uploadId));
328+
});
329+
275330
it("expires unfinished and committed uploads", async () => {
276331
let now = 1000;
277332
const rootDir = await makeTempDir();

src/skills/lifecycle/upload-store.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { resolveStateDir } from "../../config/paths.js";
66
import { DEFAULT_MAX_ARCHIVE_BYTES_ZIP } from "../../infra/archive.js";
77
import { formatErrorMessage } from "../../infra/errors.js";
88
import { createAsyncLock, readDurableJsonFile, writeJsonAtomic } from "../../infra/json-files.js";
9+
import {
10+
asDateTimestampMs,
11+
isFutureDateTimestampMs,
12+
resolveExpiresAtMsFromDurationMs,
13+
} from "../../shared/number-coercion.js";
914
import { validateRequestedSkillSlug } from "./archive-install.js";
1015

1116
export const SKILL_UPLOAD_TTL_MS = 60 * 60 * 1000;
@@ -209,10 +214,14 @@ async function assertNotExpired(
209214
record: SkillUploadRecord,
210215
now: number,
211216
): Promise<void> {
212-
if (record.expiresAt <= now) {
217+
const validNow = asDateTimestampMs(now);
218+
if (validNow !== undefined && !isFutureDateTimestampMs(record.expiresAt, { nowMs: validNow })) {
213219
await removeRecordFiles(rootDir, record);
214220
throw new SkillUploadRequestError("upload has expired");
215221
}
222+
if (validNow === undefined) {
223+
throw new SkillUploadRequestError("upload has expired");
224+
}
216225
}
217226

218227
async function computeFileSha256(filePath: string): Promise<string> {
@@ -286,7 +295,12 @@ async function cleanupExpiredUploads(
286295
}
287296
await withLock(`${rootDir}:upload:${uploadId}`, async () => {
288297
const record = await readRecordIfPresent(rootDir, uploadId).catch(() => null);
289-
if (record && record.expiresAt <= nowMs) {
298+
const validNow = asDateTimestampMs(nowMs);
299+
if (
300+
record &&
301+
validNow !== undefined &&
302+
!isFutureDateTimestampMs(record.expiresAt, { nowMs: validNow })
303+
) {
290304
await removeRecordFiles(rootDir, record);
291305
}
292306
});
@@ -297,7 +311,7 @@ async function countActiveUploads(rootDir: string, nowMs: number): Promise<numbe
297311
let count = 0;
298312
for (const uploadId of await listUploadIds(rootDir)) {
299313
const record = await readRecordIfPresent(rootDir, uploadId).catch(() => null);
300-
if (record && record.expiresAt > nowMs) {
314+
if (record && isFutureDateTimestampMs(record.expiresAt, { nowMs })) {
301315
count += 1;
302316
}
303317
}
@@ -395,7 +409,7 @@ export function createSkillUploadStore(options?: {
395409
`${rootDir}:upload:${existingUploadId}`,
396410
async () => {
397411
const record = await readRecordIfPresent(rootDir, existingUploadId);
398-
if (record && record.expiresAt > now()) {
412+
if (record && isFutureDateTimestampMs(record.expiresAt, { nowMs: now() })) {
399413
return {
400414
uploadId: record.uploadId,
401415
receivedBytes: record.receivedBytes,
@@ -424,6 +438,10 @@ export function createSkillUploadStore(options?: {
424438
const uploadDir = resolveUploadDir(rootDir, uploadId);
425439
const archivePath = resolveArchivePath(rootDir, uploadId);
426440
const createdAt = now();
441+
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: createdAt });
442+
if (expiresAt === undefined) {
443+
throw new SkillUploadRequestError("invalid upload expiry");
444+
}
427445
const record: SkillUploadRecord = {
428446
version: 1,
429447
kind: params.kind,
@@ -435,7 +453,7 @@ export function createSkillUploadStore(options?: {
435453
receivedBytes: 0,
436454
archivePath,
437455
createdAt,
438-
expiresAt: createdAt + ttlMs,
456+
expiresAt,
439457
committed: false,
440458
...(keyHash ? { idempotencyKeyHash: keyHash } : {}),
441459
};

0 commit comments

Comments
 (0)