diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index ad18036e7dd..c82d0b404d8 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -696,6 +696,23 @@ describe("cron cli", () => { expect(params.schedule.at).toBe("2026-03-29T00:30:00.000Z"); }); + it("rejects nonexistent DST gap wall-clock times on cron add", async () => { + await expectCronCommandExit([ + "cron", + "add", + "--name", + "tz-at-gap-test", + "--at", + "2026-03-29T02:30:00", + "--tz", + "Europe/Oslo", + "--session", + "isolated", + "--message", + "test", + ]); + }); + it("sets explicit stagger for cron edit", async () => { await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 1a4004f2173..b24e0b2e971 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -109,10 +109,7 @@ export function parseAt(input: string, tz?: string): string | null { // If a timezone is provided and the input looks like an offset-less ISO datetime, // resolve it in the given IANA timezone so users get the time they expect. if (tz && isOffsetlessIsoDateTime(raw)) { - const resolved = parseOffsetlessIsoDateTimeInTimeZone(raw, tz); - if (resolved) { - return resolved; - } + return parseOffsetlessIsoDateTimeInTimeZone(raw, tz); } const absolute = parseAbsoluteTimeMs(raw); diff --git a/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts index 85b331057e8..6136e063f2f 100644 --- a/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts +++ b/src/infra/format-time/parse-offsetless-zoned-datetime.test.ts @@ -23,6 +23,10 @@ describe("parseOffsetlessIsoDateTimeInTimeZone", () => { ); }); + it("returns null for nonexistent DST gap wall-clock times", () => { + expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T02:30:00", "Europe/Oslo")).toBe(null); + }); + it("returns null for invalid input", () => { expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe( null, diff --git a/src/infra/format-time/parse-offsetless-zoned-datetime.ts b/src/infra/format-time/parse-offsetless-zoned-datetime.ts index 148cd025dab..47b84756323 100644 --- a/src/infra/format-time/parse-offsetless-zoned-datetime.ts +++ b/src/infra/format-time/parse-offsetless-zoned-datetime.ts @@ -1,10 +1,26 @@ const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/; +const OFFSETLESS_ISO_DATETIME_PARTS_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/; + +type OffsetlessIsoDateTimeParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; + millisecond: number; +}; export function isOffsetlessIsoDateTime(raw: string): boolean { return OFFSETLESS_ISO_DATETIME_RE.test(raw); } export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null { + const expectedParts = parseOffsetlessIsoDateTimeParts(raw); + if (!expectedParts) { + return null; + } if (!isOffsetlessIsoDateTime(raw)) { return null; } @@ -21,12 +37,67 @@ export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: stri const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone); const candidateMs = naiveMs - firstOffsetMs; const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone); - return new Date(naiveMs - finalOffsetMs).toISOString(); + const resolvedMs = naiveMs - finalOffsetMs; + if (!matchesOffsetlessIsoDateTimeParts(resolvedMs, timeZone, expectedParts)) { + return null; + } + return new Date(resolvedMs).toISOString(); } catch { return null; } } +function parseOffsetlessIsoDateTimeParts(raw: string): OffsetlessIsoDateTimeParts | null { + const match = OFFSETLESS_ISO_DATETIME_PARTS_RE.exec(raw); + if (!match) { + return null; + } + const fractionalMs = (match[7] ?? "").padEnd(3, "0").slice(0, 3); + return { + year: Number.parseInt(match[1] ?? "0", 10), + month: Number.parseInt(match[2] ?? "0", 10), + day: Number.parseInt(match[3] ?? "0", 10), + hour: Number.parseInt(match[4] ?? "0", 10), + minute: Number.parseInt(match[5] ?? "0", 10), + second: Number.parseInt(match[6] ?? "0", 10), + millisecond: Number.parseInt(fractionalMs || "0", 10), + }; +} + +function matchesOffsetlessIsoDateTimeParts( + utcMs: number, + timeZone: string, + expected: OffsetlessIsoDateTimeParts, +): boolean { + const utcDate = new Date(utcMs); + if (utcDate.getUTCMilliseconds() !== expected.millisecond) { + return false; + } + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + hourCycle: "h23", + }).formatToParts(utcDate); + const getNumericPart = (type: string) => { + const part = parts.find((candidate) => candidate.type === type); + return Number.parseInt(part?.value ?? "0", 10); + }; + return ( + getNumericPart("year") === expected.year && + getNumericPart("month") === expected.month && + getNumericPart("day") === expected.day && + getNumericPart("hour") === expected.hour && + getNumericPart("minute") === expected.minute && + getNumericPart("second") === expected.second + ); +} + function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number { const utcDate = new Date(utcMs); const parts = new Intl.DateTimeFormat("en-US", { @@ -38,6 +109,7 @@ function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number { minute: "2-digit", second: "2-digit", hour12: false, + hourCycle: "h23", }).formatToParts(utcDate); const getNumericPart = (type: string) => {