fix: reject nonexistent zoned cron at-times

This commit is contained in:
Peter Steinberger 2026-03-23 21:11:48 -07:00
parent 69a317995d
commit 0857447a5d
No known key found for this signature in database
4 changed files with 95 additions and 5 deletions

View File

@ -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"]);

View File

@ -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);

View File

@ -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,

View File

@ -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) => {