From 51a0bc1014afbfe24f26d24c3507112a4cdd89aa Mon Sep 17 00:00:00 2001 From: Michael Villari Date: Thu, 17 Sep 2026 13:03:37 -0400 Subject: [PATCH] Validate calendar dates in date and date-time formats --- src/parser/jsonParser.ts | 16 +++++++++++++++- src/test/parser.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 658ab8d..64c9828 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -31,6 +31,18 @@ const formats = { 'ipv6': { errorMessage: l10n.t('String is not an IPv6 address.'), pattern: /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i }, }; +// The date and date-time patterns check syntax; RFC 3339 section 5.7 also limits the day by month and year. +function isValidDate(year: number, month: number, day: number): boolean { + let daysInMonth = 31; + if (month === 2) { + const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + daysInMonth = isLeapYear ? 29 : 28; + } else if (month === 4 || month === 6 || month === 9 || month === 11) { + daysInMonth = 30; + } + return day <= daysInMonth; +} + export interface IProblem { location: IRange; severity?: DiagnosticSeverity; @@ -974,7 +986,9 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: case 'ipv4': case 'ipv6': const format = formats[schema.format]; - if (!node.value || !format.pattern.exec(node.value)) { + const match = format.pattern.exec(node.value); + const hasDate = schema.format === 'date' || schema.format === 'date-time'; + if (!match || (hasDate && !isValidDate(Number(match[1]), Number(match[2]), Number(match[3])))) { validationResult.problems.push({ location: { offset: node.offset, length: node.length }, message: schema.patternErrorMessage || schema.errorMessage || format.errorMessage diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts index 25ae6d4..ac483c5 100644 --- a/src/test/parser.test.ts +++ b/src/test/parser.test.ts @@ -953,6 +953,34 @@ suite('JSON Parser', () => { assert.strictEqual(semanticErrors![0].message, 'String is not a valid UUID.'); }); + for (const format of ['date', 'date-time']) { + test(`${format} calendar dates`, function () { + const schema = { type: 'string', format }; + const validDates = [ + '2023-01-31', '2023-02-28', '2023-03-31', '2023-04-30', + '2023-05-31', '2023-06-30', '2023-07-31', '2023-08-31', + '2023-09-30', '2023-10-31', '2023-11-30', '2023-12-31', + '2024-02-29', '2000-02-29', '0000-02-29', '0096-02-29', '0400-02-29' + ]; + const invalidDates = [ + '2023-02-29', '2023-02-30', '2024-02-30', '2024-02-31', + '2023-04-31', '2023-06-31', '2023-09-31', '2023-11-31', + '1900-02-29', '2100-02-29', '0100-02-29', '0001-02-29' + ]; + for (const date of validDates) { + const value = format === 'date' ? date : `${date}T23:30:00-02:00`; + assert.strictEqual(validate(JSON.stringify(value), schema)!.length, 0, value); + } + for (const date of invalidDates) { + const value = format === 'date' ? date : `${date}T00:30:00+02:00`; + const diagnostics = validate(JSON.stringify(value), schema)!; + assert.strictEqual(diagnostics.length, 1, value); + assert.strictEqual(diagnostics[0].message, `String is not a RFC3339 ${format}.`); + assert.deepStrictEqual(diagnostics[0].range, toRange(JSON.stringify(value), 0, value.length + 2)); + } + }); + } + test('Numbers', function () { const str = '{"one": 13.45e+1}';