67 lines
2.9 KiB
TypeScript
67 lines
2.9 KiB
TypeScript
// Strict wire format: full RFC3339 with an explicit offset or UTC marker.
|
|
// Date.parse is permissive, so shape validation must happen before any date
|
|
// formatting or malformed values can be normalized into a different instant.
|
|
const RFC3339_PATTERN =
|
|
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
|
|
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
|
|
/** Whether the year is a leap year in the proleptic Gregorian calendar. */
|
|
function isLeapYear(year: number): boolean {
|
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
}
|
|
|
|
/** Whether the day exists in the month, including leap-day validation. */
|
|
function validDayForMonth(year: number, month: number, day: number): boolean {
|
|
if (day < 1) return false;
|
|
const maxDay = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
|
|
return day <= maxDay;
|
|
}
|
|
|
|
/** Returns the UTC offset in seconds for a matched RFC3339 zone. */
|
|
function parseOffsetSeconds(
|
|
zone: string,
|
|
offsetHourText: string | undefined,
|
|
offsetMinuteText: string | undefined,
|
|
): number | null {
|
|
if (zone === "Z") return 0;
|
|
const hour = Number(offsetHourText);
|
|
const minute = Number(offsetMinuteText);
|
|
if (hour > 23 || minute > 59) return null;
|
|
return (zone.startsWith("-") ? -1 : 1) * (hour * 60 + minute) * 60;
|
|
}
|
|
|
|
/**
|
|
* Parses an RFC3339/RFC3339Nano timestamp into epoch nanoseconds.
|
|
*
|
|
* Missing, empty, malformed, and semantically invalid values return `null`.
|
|
* Calendar and time components are validated explicitly because Date.parse
|
|
* normalizes values such as February 30th instead of rejecting them.
|
|
*/
|
|
export function parseStrictRfc3339Timestamp(value: string | undefined): bigint | null {
|
|
if (!value) return null;
|
|
const match = RFC3339_PATTERN.exec(value);
|
|
if (!match) return null;
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
if (month < 1 || month > 12) return null;
|
|
if (!validDayForMonth(year, month, Number(match[3]))) return null;
|
|
if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) {
|
|
return null;
|
|
}
|
|
const offsetSeconds = parseOffsetSeconds(match[8], match[9], match[10]);
|
|
if (offsetSeconds === null) return null;
|
|
// RFC3339Nano caps the fraction at 9 digits; longer fractions would
|
|
// overflow into the seconds component when padded.
|
|
if (match[7] !== undefined && match[7].length > 9) return null;
|
|
|
|
// setUTCFullYear handles years 0-99 correctly; Date.UTC maps them to
|
|
// 1900-1999 and would therefore normalize an otherwise valid timestamp.
|
|
const utc = new Date(0);
|
|
utc.setUTCFullYear(year, month - 1, Number(match[3]));
|
|
utc.setUTCHours(Number(match[4]), Number(match[5]), Number(match[6]), 0);
|
|
const wholeSeconds = BigInt(Math.floor(utc.getTime() / 1000)) - BigInt(offsetSeconds);
|
|
const fractionNs = BigInt((match[7] ?? "").padEnd(9, "0"));
|
|
return wholeSeconds * BigInt(1_000_000_000) + fractionNs;
|
|
}
|