From 3f7dac15f3a1076ce42ab057cf967e2289e79aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 24 Jun 2021 01:08:22 +0300 Subject: [PATCH 1/3] Date-time should validate timezone --- src/formats.ts | 27 +++++++++++++++++++++++-- src/index.ts | 6 ++++-- tests/strictDate.spec.ts | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/strictDate.spec.ts diff --git a/src/formats.ts b/src/formats.ts index e5761a1..057ee23 100644 --- a/src/formats.ts +++ b/src/formats.ts @@ -106,6 +106,12 @@ export const fastFormats: DefinedFormats = { email: /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i, } +export const strictFormats: Partial = { + // date-time: http://tools.ietf.org/html/rfc3339#section-5.6 + time: fmtDef(strict_time, compareTime), + "date-time": fmtDef(strict_date_time, compareDateTime), +} + export const formatNames = Object.keys(fullFormats) as FormatName[] function isLeapYear(year: number): boolean { @@ -139,8 +145,11 @@ function compareDate(d1: string, d2: string): number | undefined { } const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i +const PLUS_MINUS = /^[+-]/ +const TIMEZONE = /^[Zz]$/ +const ISO_8601_TIME = /^[+-](?:[01][0-9]|2[0-4])(?::?[0-5][0-9])?$/ -function time(str: string, withTimeZone?: boolean): boolean { +function time(str: string, withTimeZone?: boolean, strict?: boolean): boolean { const matches: string[] | null = TIME.exec(str) if (!matches) return false @@ -151,10 +160,19 @@ function time(str: string, withTimeZone?: boolean): boolean { return ( ((hour <= 23 && minute <= 59 && second <= 59) || (hour === 23 && minute === 59 && second === 60)) && - (!withTimeZone || timeZone !== "") + (!withTimeZone || + (strict + ? TIMEZONE.test(timeZone) || + (PLUS_MINUS.test(timeZone) && time(timeZone.slice(1) + ":00")) || + ISO_8601_TIME.test(timeZone) + : timeZone !== "")) ) } +function strict_time(str: string, withTimeZone?: boolean): boolean { + return time(str, withTimeZone, true) +} + function compareTime(t1: string, t2: string): number | undefined { if (!(t1 && t2)) return undefined const a1 = TIME.exec(t1) @@ -174,6 +192,11 @@ function date_time(str: string): boolean { return dateTime.length === 2 && date(dateTime[0]) && time(dateTime[1], true) } +function strict_date_time(str: string): boolean { + const dateTime: string[] = str.split(DATE_TIME_SEPARATOR) + return dateTime.length === 2 && date(dateTime[0]) && strict_time(dateTime[1], true) +} + function compareDateTime(dt1: string, dt2: string): number | undefined { if (!(dt1 && dt2)) return undefined const [d1, t1] = dt1.split(DATE_TIME_SEPARATOR) diff --git a/src/index.ts b/src/index.ts index 8fd944a..dece7e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { formatNames, fastFormats, fullFormats, + strictFormats, } from "./formats" import formatLimit from "./limit" import type Ajv from "ajv" @@ -17,6 +18,7 @@ export interface FormatOptions { mode?: FormatMode formats?: FormatName[] keywords?: boolean + strictDate?: boolean } export type FormatsPluginOptions = FormatName[] | FormatOptions @@ -30,7 +32,7 @@ const fastName = new Name("fastFormats") const formatsPlugin: FormatsPlugin = ( ajv: Ajv, - opts: FormatsPluginOptions = {keywords: true} + opts: FormatsPluginOptions = {keywords: true, strictDate: false} ): Ajv => { if (Array.isArray(opts)) { addFormats(ajv, opts, fullFormats, fullName) @@ -39,7 +41,7 @@ const formatsPlugin: FormatsPlugin = ( const [formats, exportName] = opts.mode === "fast" ? [fastFormats, fastName] : [fullFormats, fullName] const list = opts.formats || formatNames - addFormats(ajv, list, formats, exportName) + addFormats(ajv, list, opts.strictDate ? {...formats, ...strictFormats} : formats, exportName) if (opts.keywords) formatLimit(ajv) return ajv } diff --git a/tests/strictDate.spec.ts b/tests/strictDate.spec.ts new file mode 100644 index 0000000..91e3091 --- /dev/null +++ b/tests/strictDate.spec.ts @@ -0,0 +1,43 @@ +import Ajv from "ajv" +import addFormats from "../dist" + +const ajv = new Ajv({$data: true, strictTypes: false, formats: {allowedUnknown: true}}) +addFormats(ajv, {strictDate: true}) + +describe("strictDate option", () => { + it("a valid date-time string with time offset", () => { + expect( + ajv.validate( + { + type: "string", + format: "date-time", + }, + "2020-06-19T12:13:14+05:00" + ) + ).toBe(true) + }) + + it("an invalid date-time string (no time offset)", () => { + expect( + ajv.validate( + { + type: "string", + format: "date-time", + }, + "2020-06-19T12:13:14" + ) + ).toBe(false) + }) + + it("an invalid date-time string (invalid time offset)", () => { + expect( + ajv.validate( + { + type: "string", + format: "date-time", + }, + "2020-06-19T12:13:14+26:00" + ) + ).toBe(false) + }) +}) From d1a9e36548298cee3577ed763fdb60412a1e71f1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 6 Nov 2021 21:25:45 +0000 Subject: [PATCH 2/3] simplify strictTime option --- src/formats.ts | 45 ++++++++----------- src/index.ts | 6 +-- ...{strictDate.spec.ts => strictTime.spec.ts} | 4 +- 3 files changed, 24 insertions(+), 31 deletions(-) rename tests/{strictDate.spec.ts => strictTime.spec.ts} (90%) diff --git a/src/formats.ts b/src/formats.ts index 594cddf..46f1192 100644 --- a/src/formats.ts +++ b/src/formats.ts @@ -149,33 +149,27 @@ function compareDate(d1: string, d2: string): number | undefined { return 0 } -const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i -const PLUS_MINUS = /^[+-]/ -const TIMEZONE = /^[Zz]$/ -const ISO_8601_TIME = /^[+-](?:[01][0-9]|2[0-4])(?::?[0-5][0-9])?$/ +const TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-]\d\d)(?::?(\d\d))?)?$/i -function time(str: string, withTimeZone?: boolean, strict?: boolean): boolean { +function time(str: string, withTimeZone?: boolean, strictTime?: boolean): boolean { const matches: string[] | null = TIME.exec(str) if (!matches) return false - - const hour: number = +matches[1] - const minute: number = +matches[2] - const second: number = +matches[3] - const timeZone: string = matches[5] + const hr: number = +matches[1] + const min: number = +matches[2] + const sec: number = +matches[3] + const tz: string | undefined = matches[4] + const tzH: number = +(matches[5] || 0) + const tzM: number = +(matches[6] || 0) return ( - ((hour <= 23 && minute <= 59 && second <= 59) || - (hour === 23 && minute === 59 && second === 60)) && - (!withTimeZone || - (strict - ? TIMEZONE.test(timeZone) || - (PLUS_MINUS.test(timeZone) && time(timeZone.slice(1) + ":00")) || - ISO_8601_TIME.test(timeZone) - : timeZone !== "")) + ((hr <= 23 && min <= 59 && sec < 60 && tzH <= 24 && tzM < 60) || + // leap second + (hr - tzH === 23 && min - tzM === 59 && sec < 61 && tzH <= 24 && tzM < 60)) && + (!withTimeZone || (tz !== "" && (!strictTime || !!tz))) ) } -function strict_time(str: string, withTimeZone?: boolean): boolean { - return time(str, withTimeZone, true) +function strict_time(str: string): boolean { + return time(str, true, true) } function compareTime(t1: string, t2: string): number | undefined { @@ -183,23 +177,22 @@ function compareTime(t1: string, t2: string): number | undefined { const a1 = TIME.exec(t1) const a2 = TIME.exec(t2) if (!(a1 && a2)) return undefined - t1 = a1[1] + a1[2] + a1[3] + (a1[4] || "") - t2 = a2[1] + a2[2] + a2[3] + (a2[4] || "") + t1 = a1[1] + a1[2] + a1[3] + t2 = a2[1] + a2[2] + a2[3] if (t1 > t2) return 1 if (t1 < t2) return -1 return 0 } const DATE_TIME_SEPARATOR = /t|\s/i -function date_time(str: string): boolean { +function date_time(str: string, strictTime?: boolean): boolean { // http://tools.ietf.org/html/rfc3339#section-5.6 const dateTime: string[] = str.split(DATE_TIME_SEPARATOR) - return dateTime.length === 2 && date(dateTime[0]) && time(dateTime[1], true) + return dateTime.length === 2 && date(dateTime[0]) && time(dateTime[1], true, strictTime) } function strict_date_time(str: string): boolean { - const dateTime: string[] = str.split(DATE_TIME_SEPARATOR) - return dateTime.length === 2 && date(dateTime[0]) && strict_time(dateTime[1], true) + return date_time(str, true) } function compareDateTime(dt1: string, dt2: string): number | undefined { diff --git a/src/index.ts b/src/index.ts index dece7e6..9550b15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export interface FormatOptions { mode?: FormatMode formats?: FormatName[] keywords?: boolean - strictDate?: boolean + strictTime?: boolean } export type FormatsPluginOptions = FormatName[] | FormatOptions @@ -32,7 +32,7 @@ const fastName = new Name("fastFormats") const formatsPlugin: FormatsPlugin = ( ajv: Ajv, - opts: FormatsPluginOptions = {keywords: true, strictDate: false} + opts: FormatsPluginOptions = {keywords: true, strictTime: false} ): Ajv => { if (Array.isArray(opts)) { addFormats(ajv, opts, fullFormats, fullName) @@ -41,7 +41,7 @@ const formatsPlugin: FormatsPlugin = ( const [formats, exportName] = opts.mode === "fast" ? [fastFormats, fastName] : [fullFormats, fullName] const list = opts.formats || formatNames - addFormats(ajv, list, opts.strictDate ? {...formats, ...strictFormats} : formats, exportName) + addFormats(ajv, list, opts.strictTime ? {...formats, ...strictFormats} : formats, exportName) if (opts.keywords) formatLimit(ajv) return ajv } diff --git a/tests/strictDate.spec.ts b/tests/strictTime.spec.ts similarity index 90% rename from tests/strictDate.spec.ts rename to tests/strictTime.spec.ts index 91e3091..4c7034a 100644 --- a/tests/strictDate.spec.ts +++ b/tests/strictTime.spec.ts @@ -2,9 +2,9 @@ import Ajv from "ajv" import addFormats from "../dist" const ajv = new Ajv({$data: true, strictTypes: false, formats: {allowedUnknown: true}}) -addFormats(ajv, {strictDate: true}) +addFormats(ajv, {mode: "full", strictTime: true}) -describe("strictDate option", () => { +describe("strictTime option", () => { it("a valid date-time string with time offset", () => { expect( ajv.validate( From 34df8dbcb2b6783e2c870cc79c5aef64d38c0191 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 6 Nov 2021 21:30:38 +0000 Subject: [PATCH 3/3] docs: strictTime option --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b4706d..4386e39 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,13 @@ addFormats(ajv, {mode: "fast"}) or ```javascript -addFormats(ajv, {mode: "fast", formats: ["date", "time"], keywords: true}) +addFormats(ajv, {mode: "fast", formats: ["date", "time"], keywords: true, strictTime: true}) ``` In `"fast"` mode the following formats are simplified: `"date"`, `"time"`, `"date-time"`, `"uri"`, `"uri-reference"`, `"email"`. For example `"date"`, `"time"` and `"date-time"` do not validate ranges in `"fast"` mode, only string structure, and other formats have simplified regular expressions. +With `strictTime: true` option timezone becomes required in `time` and `date-time` formats, and (it also implies `full` mode for these formats). + ## Tests ```bash