diff --git a/src/index.ts b/src/index.ts index f8121c50..d989257d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -303,3 +303,7 @@ export { type ChatBotMessage, GPTVersion, } from './chatbot/chatbot.js'; + +export { isEmail } from './validation/isEmail.js'; +export { isFQDN } from './validation/isFQDN.js'; +export { isIP } from './validation/isIP.js'; diff --git a/src/validation/isByteLength.test.ts b/src/validation/isByteLength.test.ts new file mode 100644 index 00000000..d9b0fb3e --- /dev/null +++ b/src/validation/isByteLength.test.ts @@ -0,0 +1,40 @@ +import { describe } from 'vitest'; + +import { isByteLength } from './isByteLength.js'; +import { testFunc } from './testUtils.js'; + +describe('isByteLength', () => { + testFunc( + 'only min for "%s"', + isByteLength, + { min: 2 }, + { + valid: ['abc', 'de', 'abcd', 'gmail'], + invalid: ['', 'a'], + }, + ); + testFunc( + 'min and max for "%s"', + isByteLength, + { min: 2, max: 3 }, + { valid: ['abc', 'de', 'g'], invalid: ['', 'a', 'abcd', 'gm'] }, + ); + testFunc( + 'only max for "%s"', + isByteLength, + { max: 3 }, + { + valid: ['abc', 'de', 'g', 'a', ''], + invalid: ['abcd', 'gm'], + }, + ); + testFunc( + 'only max for "%s"', + isByteLength, + { max: 0 }, + { + valid: [''], + invalid: ['g', 'a'], + }, + ); +}); diff --git a/src/validation/isByteLength.ts b/src/validation/isByteLength.ts new file mode 100644 index 00000000..53a320c7 --- /dev/null +++ b/src/validation/isByteLength.ts @@ -0,0 +1,16 @@ +/** + * This code was adapted from the `validator.js` package + * https://github.com/validatorjs/validator.js/tree/master + */ + +export function isByteLength( + str: string, + options?: Partial<{ min: number; max: number }>, +) { + const min = options?.min ?? 0; + const len = encodeURI(str).split(/%..|./).length - 1; + if (typeof options?.max === 'undefined') { + return len >= min; + } + return len >= min && len <= options.max; +} diff --git a/src/validation/isEmail.test.ts b/src/validation/isEmail.test.ts new file mode 100644 index 00000000..5fcd4e03 --- /dev/null +++ b/src/validation/isEmail.test.ts @@ -0,0 +1,322 @@ +import { describe } from 'vitest'; + +import { isEmail } from './isEmail.js'; +import { testFunc } from './testUtils.js'; + +describe('isEmail', () => { + testFunc( + 'Default options', + isEmail, + {}, + { + valid: [ + 'foo@bar.com', + 'x@x.au', + 'foo@bar.com.au', + 'foo+bar@bar.com', + 'hans.m端ller@test.com', + 'hans@m端ller.com', + 'test|123@m端ller.com', + 'test123+ext@gmail.com', + 'some.name.midd.leNa.me.and.locality+extension@GoogleMail.com', + '"foobar"@example.com', + '" foo m端ller "@example.com', + '"foo\\@bar"@example.com', + `${'a'.repeat(64)}@${'a'.repeat(63)}.com`, + `${'a'.repeat(31)}@gmail.com`, + 'test@gmail.com', + 'test.1@gmail.com', + 'test@1337.com', + ], + invalid: [ + 'foobar@my_sarisari_store.typepad.com', + 'invalidemail@', + 'invalid.com', + '@invalid.com', + 'foo@bar.com.', + 'foo@_bar.com', + 'somename@gmail.com', + 'foo@bar.co.uk.', + 'z@co.c', + 'gmailgmailgmailgmailgmail@gmail.com', + `${'a'.repeat(64)}@${'a'.repeat(251)}.com`, + `${'a'.repeat(65)}@${'a'.repeat(250)}.com`, + `${'a'.repeat(64)}@${'a'.repeat(64)}.com`, + `${'a'.repeat(64)}@${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat(58)}.com`, + 'test1@invalid.co m', + 'test2@invalid.co m', + 'test3@invalid.co m', + 'test4@invalid.co m', + 'test5@invalid.co m', + 'test6@invalid.co m', + 'test7@invalid.co m', + 'test8@invalid.co m', + 'test9@invalid.co m', + 'test10@invalid.co m', + 'test11@invalid.co m', + 'test12@invalid.co m', + 'test13@invalid.co m', + 'multiple..dots@stillinvalid.com', + 'test123+invalid! sub_address@gmail.com', + 'gmail...ignores...dots...@gmail.com', + 'ends.with.dot.@gmail.com', + 'multiple..dots@gmail.com', + 'wrong()[]",:;<>@@gmail.com', + '"wrong()[]",:;<>@@gmail.com', + 'username@domain.com�', + 'username@domain.com©', + 'nbsp test@test.com', + 'nbsp_test@te st.com', + 'nbsp_test@test.co m', + '"foobar@gmail.com', + '"foo"bar@gmail.com', + 'foo"bar"@gmail.com', + ], + }, + ); + + testFunc( + 'Domain specific validation', + isEmail, + { domainSpecificValidation: true }, + { + valid: [ + 'foobar@gmail.com', + 'foo.bar@gmail.com', + 'foo.bar@googlemail.com', + `${'a'.repeat(30)}@gmail.com`, + ], + invalid: [ + `${'a'.repeat(31)}@gmail.com`, + 'test@gmail.com', + 'test.1@gmail.com', + '.foobar@gmail.com', + ], + }, + ); + + testFunc( + 'Allow underscores', + isEmail, + { allowUnderscores: true }, + { valid: ['foobar@my_sarisari_store.typepad.com'] }, + ); + + testFunc( + 'Allow UTF8 part', + isEmail, + { allowUtf8LocalPart: false }, + { + valid: [ + 'foo@bar.com', + 'x@x.au', + 'foo@bar.com.au', + 'foo+bar@bar.com', + 'hans@m端ller.com', + 'test|123@m端ller.com', + 'test123+ext@gmail.com', + 'some.name.midd.leNa.me+extension@GoogleMail.com', + '"foobar"@example.com', + '"foo\\@bar"@example.com', + '" foo bar "@example.com', + ], + invalid: [ + 'invalidemail@', + 'invalid.com', + '@invalid.com', + 'foo@bar.com.', + 'foo@bar.co.uk.', + 'somename@gmail.com', + 'hans.m端ller@test.com', + 'z@co.c', + 'tüst@invalid.com', + 'nbsp test@test.com', + ], + }, + ); + + testFunc( + 'Allow display name', + isEmail, + { allowDisplayName: true }, + { + valid: [ + 'foo@bar.com', + 'x@x.au', + 'foo@bar.com.au', + 'foo+bar@bar.com', + 'hans.m端ller@test.com', + 'hans@m端ller.com', + 'test|123@m端ller.com', + 'test123+ext@gmail.com', + 'some.name.midd.leNa.me+extension@GoogleMail.com', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + // eslint-disable-next-line quotes + "'Foo Bar, Esq'", + 'Some Name ', + 'Some Middle Name ', + 'Name ', + 'Name', + 'Some Name ', + 'Name🍓With🍑Emoji🚴‍♀️🏆', + '🍇🍗🍑', + '""', + '"\\"quotes\\""', + '"name;"', + '"name;" ', + ], + invalid: [ + 'invalidemail@', + 'invalid.com', + '@invalid.com', + 'foo@bar.com.', + 'foo@bar.co.uk.', + 'Some Name ', + 'Some Name ', + 'Some Name <@invalid.com>', + 'Some Name ', + 'Some Name ', + 'Some Name foo@bar.co.uk.>', + 'Some Name ', + 'Name foo@bar.co.uk', + 'Some Name ', + 'Some Name', + 'invisibleCharacter\u001F', + '', + '\\"quotes\\"', + '""quotes""', + 'name;', + ' ', + '" "', + ], + }, + ); + + testFunc( + 'Require display name', + isEmail, + { requireDisplayName: true }, + { + valid: [ + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Name ', + 'Some Middle Name ', + 'Name ', + 'Name', + ], + invalid: [ + 'some.name.midd.leNa.me+extension@GoogleMail.com', + 'foo@bar.com', + 'x@x.au', + 'foo@bar.com.au', + 'foo+bar@bar.com', + 'hans.m端ller@test.com', + 'hans@m端ller.com', + 'test|123@m端ller.com', + 'test123+ext@gmail.com', + 'invalidemail@', + 'invalid.com', + '@invalid.com', + 'foo@bar.com.', + 'foo@bar.co.uk.', + 'Some Name ', + 'Some Name ', + 'Some Name <@invalid.com>', + 'Some Name ', + 'Some Name ', + 'Some Name foo@bar.co.uk.>', + 'Some Name ', + 'Name foo@bar.co.uk', + ], + }, + ); + + testFunc( + 'Allow Ip Domain', + isEmail, + { allowIpDomain: true }, + { + valid: ['email@[123.123.123.123]', 'email@255.255.255.255'], + invalid: [ + 'email@0.0.0.256', + 'email@26.0.0.256', + 'email@[266.266.266.266]', + ], + }, + ); + + testFunc( + 'Blacklisted chars', + isEmail, + { blacklistedChars: 'abc"' }, + { + valid: ['emil@gmail.com'], + invalid: [ + 'email@gmail.com', + '"foobr"@example.com', + '" foo m端ller "@example.com', + '"foo@br"@example.com', + ], + }, + ); + + testFunc( + 'Ignore Max length allowed', + isEmail, + { ignoreMaxLength: true }, + + { + valid: [ + 'Deleted-user-id-19430-Team-5051deleted-user-id-19430-team-5051XXXXXX@example.com', + ], + }, + ); + + testFunc( + 'Ignore Max length disallowed', + isEmail, + { ignoreMaxLength: false }, + { + invalid: [ + 'Deleted-user-id-19430-Team-5051deleted-user-id-19430-team-5051XXXXXX@example.com', + 'Deleted-user-id-19430-Team-5051deleted-user-id-19430-team-5051XXXXXX@Deleted-user-id-19430-Team-5051deleted-user-id-19430-team-5051XXXXXX.com', + ], + }, + ); + + testFunc( + 'Blacklist host', + isEmail, + { hostBlacklist: ['gmail.com', 'foo.bar.com'] }, + { + valid: ['email@foo.gmail.com'], + invalid: ['foo+bar@gmail.com', 'email@foo.bar.com'], + }, + ); + + testFunc( + 'Whitelist host', + isEmail, + { hostWhitelist: ['gmail.com', 'foo.bar.com'] }, + { + valid: ['email@gmail.com', 'test@foo.bar.com'], + invalid: ['foo+bar@test.com', 'email@foo.com', 'email@bar.com'], + }, + ); +}); diff --git a/src/validation/isEmail.ts b/src/validation/isEmail.ts new file mode 100644 index 00000000..abd02272 --- /dev/null +++ b/src/validation/isEmail.ts @@ -0,0 +1,225 @@ +/** + * This code was adapted from the `validator.js` package + * https://github.com/validatorjs/validator.js/tree/master + */ +import { isByteLength } from './isByteLength.js'; +import { isFQDN } from './isFQDN.js'; +import { isIP } from './isIP.js'; +import { merge } from './utils.js'; + +type EmailOptions = { + allowDisplayName: boolean; + allowUnderscores: boolean; + requireDisplayName: boolean; + allowUtf8LocalPart: boolean; + requireTld: boolean; + blacklistedChars: string; + /** + * Email should not be longer then 254 chars. + * If you want to allow longer email, set this setting to `true` + * @default false + */ + ignoreMaxLength: boolean; + hostBlacklist: string[]; + hostWhitelist: string[]; + domainSpecificValidation: boolean; + allowIpDomain: boolean; +}; + +const DEFAULT_EMAIL_OPTIONS = { + allowDisplayName: false, + allowUnderscores: false, + requireDisplayName: false, + allowUtf8LocalPart: true, + requireTld: true, + blacklistedChars: '', + ignoreMaxLength: false, + hostBlacklist: [], + hostWhitelist: [], + domainSpecificValidation: false, + allowIpDomain: false, +} satisfies EmailOptions; + +const splitNameAddress = /^([^\x00-\x1F\x7F-\x9F]+)]/.test(display_name_without_quotes); + if (contains_illegal) { + // if contains illegal characters, + // must to be enclosed in double-quotes, otherwise it's not a valid display name + if (display_name_without_quotes === displayName) { + return false; + } + + // the quotes in display name must start with character symbol \ + const all_start_with_back_slash = + display_name_without_quotes.split('"').length === + display_name_without_quotes.split('\\"').length; + if (!all_start_with_back_slash) { + return false; + } + } + + return true; +} + +export function isEmail(str: string, userOptions: Partial) { + const options = merge(userOptions, DEFAULT_EMAIL_OPTIONS); + + if (options.requireDisplayName || options.allowDisplayName) { + const display_email = splitNameAddress.exec(str); + if (display_email) { + let display_name = display_email[1]; + + // Remove display name and angle brackets to get email address + // Can be done in the regex but will introduce a ReDOS (See #1597 for more info) + str = str.replace(display_name, '').replace(/(^<|>$)/g, ''); + + // sometimes need to trim the last space to get the display name + // because there may be a space between display name and email address + // eg. myname + // the display name is `myname` instead of `myname `, so need to trim the last space + if (display_name.endsWith(' ')) { + display_name = display_name.slice(0, -1); + } + + if (!validateDisplayName(display_name)) { + return false; + } + } else if (options.requireDisplayName) { + return false; + } + } + + if (!options.ignoreMaxLength && str.length > DEFAULT_MAX_EMAIL_LENGTH) { + return false; + } + + const parts = str.split('@'); + const domain = parts.pop(); + + // domain should be specified + if (!domain) { + return false; + } + + const lower_domain = domain.toLowerCase(); + + if (options.hostBlacklist.includes(lower_domain)) { + return false; + } + + if ( + options.hostWhitelist.length > 0 && + !options.hostWhitelist.includes(lower_domain) + ) { + return false; + } + + let user = parts.join('@'); + + if ( + options.domainSpecificValidation && + (lower_domain === 'gmail.com' || lower_domain === 'googlemail.com') + ) { + /* + Previously we removed dots for gmail addresses before validating. + This was removed because it allows `multiple..dots@gmail.com` + to be reported as valid, but it is not. + Gmail only normalizes single dots, removing them from here is pointless, + should be done in normalizeEmail + */ + user = user.toLowerCase(); + + // Removing sub-address from username before gmail validation + const username = user.split('+')[0]; + + // Dots are not included in gmail length restriction + if (!isByteLength(username.replace(/\./g, ''), { min: 6, max: 30 })) { + return false; + } + + const user_parts = username.split('.'); + for (const part of user_parts) { + if (!gmailUserPart.test(part)) { + return false; + } + } + } + + if ( + options.ignoreMaxLength === false && + (!isByteLength(user, { max: 64 }) || !isByteLength(domain, { max: 254 })) + ) { + return false; + } + + if ( + !isFQDN(domain, { + requireTld: options.requireTld, + ignoreMaxLength: options.ignoreMaxLength, + allowUnderscores: options.allowUnderscores, + }) + ) { + if (!options.allowIpDomain) { + return false; + } + + if (!isIP(domain)) { + if (!domain.startsWith('[') || !domain.endsWith(']')) { + return false; + } + + const noBracketDomain = domain.slice(1, -1); + + if (noBracketDomain.length === 0 || !isIP(noBracketDomain)) { + return false; + } + } + } + + if (options.blacklistedChars) { + if (user.search(new RegExp(`[${options.blacklistedChars}]+`, 'g')) !== -1) + return false; + } + + if (user.startsWith('"') && user.endsWith('"')) { + user = user.slice(1, user.length - 1); + return options.allowUtf8LocalPart + ? quotedEmailUserUtf8.test(user) + : quotedEmailUser.test(user); + } + + const pattern = options.allowUtf8LocalPart + ? emailUserUtf8Part + : emailUserPart; + + const user_parts = user.split('.'); + for (const part of user_parts) { + if (!pattern.test(part)) { + return false; + } + } + + return true; +} diff --git a/src/validation/isFQDN.test.ts b/src/validation/isFQDN.test.ts new file mode 100644 index 00000000..1d29f153 --- /dev/null +++ b/src/validation/isFQDN.test.ts @@ -0,0 +1,94 @@ +import { describe } from 'vitest'; + +import { isFQDN } from './isFQDN.js'; +import { testFunc } from './testUtils.js'; + +describe('isFQDN', () => { + testFunc( + 'Default Options', + isFQDN, + {}, + { + valid: [ + 'domain.com', + 'dom.plato', + 'a.domain.co', + 'foo--bar.com', + 'xn--froschgrn-x9a.com', + 'rebecca.blackfriday', + '1337.com', + ], + invalid: [ + 'abc', + '256.0.0.0', + '_.com', + '*.some.com', + 's!ome.com', + 'domain.com/', + '/more.com', + 'domain.com�', + 'domain.co\u00A0m', + 'domain.co\u1680m', + 'domain.co\u2006m', + 'domain.co\u2028m', + 'domain.co\u2029m', + 'domain.co\u202Fm', + 'domain.co\u205Fm', + 'domain.co\u3000m', + 'domain.com\uDC00', + 'domain.co\uEFFFm', + 'domain.co\uFDDAm', + 'domain.co\uFFF4m', + 'domain.com©', + 'example.0', + '192.168.0.9999', + '192.168.0', + 'domain.-com', + 'domain-.com', + ], + }, + ); + + testFunc( + 'With trailing dot', + isFQDN, + { allowTrailingDot: true }, + { valid: ['example.com.'] }, + ); + + testFunc( + 'Do not require tld', + isFQDN, + { requireTld: false }, + { invalid: ['example.0', '192.168.0', '192.168.0.9999'] }, + ); + + testFunc( + 'Do not require tld but allow numeric tld', + isFQDN, + { requireTld: false, allowNumericTld: true }, + { valid: ['example.0', '192.168.0', '192.168.0.9999'] }, + ); + + testFunc( + 'With wildcard option', + isFQDN, + { allowWildcard: true }, + { + valid: ['*.example.com', '*.shop.example.com'], + }, + ); + + testFunc( + 'Custom options', + isFQDN, + { + allowTrailingDot: true, + allowUnderscores: true, + allowNumericTld: true, + }, + { + valid: ['abc.efg.g1h.', 'as1s.sad3s.ssa2d.'], + }, + ); +}); diff --git a/src/validation/isFQDN.ts b/src/validation/isFQDN.ts new file mode 100644 index 00000000..5bb7828b --- /dev/null +++ b/src/validation/isFQDN.ts @@ -0,0 +1,116 @@ +/** + * This code was adapted from the `validator.js` package + * https://github.com/validatorjs/validator.js/tree/master + */ +import { merge } from './utils.js'; + +const DEFAULT_FQDN_OPTIONS = { + requireTld: true, + allowUnderscores: false, + allowTrailingDot: false, + allowNumericTld: false, + allowWildcard: false, + ignoreMaxLength: false, +} satisfies FQDNOptions; + +type FQDNOptions = { + /** + * Require presence of top level domain + * @default true + */ + requireTld: boolean; + /** + * Allow underscores in the domain name + * @default false + */ + allowUnderscores: boolean; + /** + * Allow trailing dot + * @default false + */ + allowTrailingDot: boolean; + /** + * Allow numeric top level domain + * @default false + */ + allowNumericTld: boolean; + /** + * Allow wildcard domain + * @default false + */ + allowWildcard: boolean; + /** + * Ignore max length for domain name + * @default false + */ + ignoreMaxLength: boolean; +}; + +export function isFQDN(str: string, userOptions: Partial) { + const options = merge(userOptions, DEFAULT_FQDN_OPTIONS); + + /* Remove the optional trailing dot before checking validity */ + if (options.allowTrailingDot && str.endsWith('.')) { + str = str.substring(0, str.length - 1); + } + + /* Remove the optional wildcard before checking validity */ + if (options.allowWildcard === true && str.startsWith('*.')) { + str = str.substring(2); + } + + const parts = str.split('.'); + const tld = parts[parts.length - 1]; + + if (options.requireTld) { + // disallow fqdns without tld + if (parts.length < 2) { + return false; + } + + if ( + !options.allowNumericTld && + !/^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i.test( + tld, + ) + ) { + return false; + } + + // disallow spaces + if (/\s/.test(tld)) { + return false; + } + } + + // reject numeric TLDs + if (!options.allowNumericTld && /^\d+$/.test(tld)) { + return false; + } + + return parts.every((part) => { + if (part.length > 63 && !options.ignoreMaxLength) { + return false; + } + + if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) { + return false; + } + + // disallow full-width chars + if (/[\uff01-\uff5e]/.test(part)) { + return false; + } + + // disallow parts starting or ending with hyphen + if (/(^-)|(-$)/.test(part)) { + return false; + } + + if (!options.allowUnderscores && /_/.test(part)) { + return false; + } + + return true; + }); +} diff --git a/src/validation/isIP.test.ts b/src/validation/isIP.test.ts new file mode 100644 index 00000000..ffd12623 --- /dev/null +++ b/src/validation/isIP.test.ts @@ -0,0 +1,151 @@ +import { describe } from 'vitest'; + +import { isIP } from './isIP.js'; +import { testFunc } from './testUtils.js'; + +describe('isIP', () => { + testFunc( + 'Default options', + isIP, + {}, + { + valid: [ + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '1.2.3.4', + '::1', + '2001:db8:0000:1:1:1:1:1', + '2001:db8:3:4::192.0.2.33', + '2001:41d0:2:a141::1', + '::ffff:127.0.0.1', + '::0000', + '0000::', + '1::', + '1111:1:1:1:1:1:1:1', + 'fe80::a6db:30ff:fe98:e946', + '::', + '::8', + '::ffff:127.0.0.1', + '::ffff:255.255.255.255', + '::ffff:0:255.255.255.255', + '::2:3:4:5:6:7:8', + '::255.255.255.255', + '0:0:0:0:0:ffff:127.0.0.1', + '1:2:3:4:5:6:7::', + '1:2:3:4:5:6::8', + '1::7:8', + '1:2:3:4:5::7:8', + '1:2:3:4:5::8', + '1::6:7:8', + '1:2:3:4::6:7:8', + '1:2:3:4::8', + '1::5:6:7:8', + '1:2:3::5:6:7:8', + '1:2:3::8', + '1::4:5:6:7:8', + '1:2::4:5:6:7:8', + '1:2::8', + '1::3:4:5:6:7:8', + '1::8', + 'fe80::7:8%eth0', + 'fe80::7:8%1', + '64:ff9b::192.0.2.33', + '0:0:0:0:0:0:10.0.0.1', + ], + invalid: [ + 'abc', + '256.0.0.0', + '0.0.0.256', + '26.0.0.256', + '0200.200.200.200', + '200.0200.200.200', + '200.200.0200.200', + '200.200.200.0200', + '::banana', + 'banana::', + '::1banana', + '::1::', + '1:', + ':1', + ':1:1:1::2', + '1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1', + '::11111', + '11111:1:1:1:1:1:1:1', + '2001:db8:0000:1:1:1:1::1', + '0:0:0:0:0:0:ffff:127.0.0.1', + '0:0:0:0:ffff:127.0.0.1', + ], + }, + ); + + testFunc( + 'Version 4', + isIP, + { version: '4' }, + { + valid: [ + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '1.2.3.4', + '255.0.0.1', + '0.0.1.1', + ], + invalid: [ + '::1', + '2001:db8:0000:1:1:1:1:1', + '::ffff:127.0.0.1', + '137.132.10.01', + '0.256.0.256', + '255.256.255.256', + ], + }, + ); + testFunc( + 'Version 6', + isIP, + { version: '6' }, + { + valid: [ + '::1', + '2001:db8:0000:1:1:1:1:1', + '::ffff:127.0.0.1', + 'fe80::1234%1', + 'ff08::9abc%10', + 'ff08::9abc%interface10', + 'ff02::5678%pvc1.3', + ], + invalid: [ + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '1.2.3.4', + '::ffff:287.0.0.1', + '%', + 'fe80::1234%', + 'fe80::1234%1%3%4', + 'fe80%fe80%', + ], + }, + ); + + testFunc( + 'Invalid version 10', + isIP, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + { version: '10' }, + { + valid: [], + invalid: [ + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '1.2.3.4', + '::1', + '2001:db8:0000:1:1:1:1:1', + ], + }, + ); +}); diff --git a/src/validation/isIP.ts b/src/validation/isIP.ts new file mode 100644 index 00000000..ec8ad10c --- /dev/null +++ b/src/validation/isIP.ts @@ -0,0 +1,68 @@ +/** + * This code was adapted from the `validator.js` package + * https://github.com/validatorjs/validator.js/tree/master + */ +/** +11.3. Examples + + The following addresses + + fe80::1234 (on the 1st link of the node) + ff02::5678 (on the 5th link of the node) + ff08::9abc (on the 10th organization of the node) + + would be represented as follows: + + fe80::1234%1 + ff02::5678%5 + ff08::9abc%10 + + (Here we assume a natural translation from a zone index to the + part, where the Nth zone of any scope is translated into + "N".) + + If we use interface names as , those addresses could also be + represented as follows: + + fe80::1234%ne0 + ff02::5678%pvc1.3 + ff08::9abc%interface10 + + where the interface "ne0" belongs to the 1st link, "pvc1.3" belongs + to the 5th link, and "interface10" belongs to the 10th organization. + * * */ +const IPv4SegmentFormat = + '(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])'; +const IPv4AddressFormat = `(${IPv4SegmentFormat}[.]){3}${IPv4SegmentFormat}`; +const IPv4AddressRegExp = new RegExp(`^${IPv4AddressFormat}$`); + +const IPv6SegmentFormat = '(?:[0-9a-fA-F]{1,4})'; +const IPv6AddressRegExp = new RegExp( + '^(' + + `(?:${IPv6SegmentFormat}:){7}(?:${IPv6SegmentFormat}|:)|` + + `(?:${IPv6SegmentFormat}:){6}(?:${IPv4AddressFormat}|:${IPv6SegmentFormat}|:)|` + + `(?:${IPv6SegmentFormat}:){5}(?::${IPv4AddressFormat}|(:${IPv6SegmentFormat}){1,2}|:)|` + + `(?:${IPv6SegmentFormat}:){4}(?:(:${IPv6SegmentFormat}){0,1}:${IPv4AddressFormat}|(:${IPv6SegmentFormat}){1,3}|:)|` + + `(?:${IPv6SegmentFormat}:){3}(?:(:${IPv6SegmentFormat}){0,2}:${IPv4AddressFormat}|(:${IPv6SegmentFormat}){1,4}|:)|` + + `(?:${IPv6SegmentFormat}:){2}(?:(:${IPv6SegmentFormat}){0,3}:${IPv4AddressFormat}|(:${IPv6SegmentFormat}){1,5}|:)|` + + `(?:${IPv6SegmentFormat}:){1}(?:(:${IPv6SegmentFormat}){0,4}:${IPv4AddressFormat}|(:${IPv6SegmentFormat}){1,6}|:)|` + + `(?::((?::${IPv6SegmentFormat}){0,5}:${IPv4AddressFormat}|(?::${IPv6SegmentFormat}){1,7}|:))` + + ')(%[0-9a-zA-Z-.:]{1,})?$', +); + +export function isIP( + str: string, + options?: Partial<{ version: '4' | '6' }>, +): boolean { + const version = options?.version; + if (!version) { + return isIP(str, { version: '4' }) || isIP(str, { version: '6' }); + } + if (version === '4') { + return IPv4AddressRegExp.test(str); + } + if (version === '6') { + return IPv6AddressRegExp.test(str); + } + return false; +} diff --git a/src/validation/testUtils.ts b/src/validation/testUtils.ts new file mode 100644 index 00000000..bf2c70e2 --- /dev/null +++ b/src/validation/testUtils.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +export function testFunc( + name: string, + func: (i: Input, o: Partial) => boolean, + options: Partial, + { + valid, + invalid, + }: { + valid?: Input[]; + invalid?: Input[]; + }, +) { + describe(name, () => { + if (valid?.length) { + describe('Valid', () => { + test.each(valid)('%s', (value) => { + expect(func(value, options)).toEqual(true); + }); + }); + } + if (invalid?.length) { + describe('Invalid', () => { + test.each(invalid)('%s', (value) => { + expect(func(value, options)).toEqual(false); + }); + }); + } + }); +}