string.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { setResponseValueAndErrors } from "../errorMessages.js";
  2. let emojiRegex = undefined;
  3. /**
  4. * Generated from the regular expressions found here as of 2024-05-22:
  5. * https://github.com/colinhacks/zod/blob/master/src/types.ts.
  6. *
  7. * Expressions with /i flag have been changed accordingly.
  8. */
  9. export const zodPatterns = {
  10. /**
  11. * `c` was changed to `[cC]` to replicate /i flag
  12. */
  13. cuid: /^[cC][^\s-]{8,}$/,
  14. cuid2: /^[0-9a-z]+$/,
  15. ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/,
  16. /**
  17. * `a-z` was added to replicate /i flag
  18. */
  19. email: /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/,
  20. /**
  21. * Constructed a valid Unicode RegExp
  22. *
  23. * Lazily instantiate since this type of regex isn't supported
  24. * in all envs (e.g. React Native).
  25. *
  26. * See:
  27. * https://github.com/colinhacks/zod/issues/2433
  28. * Fix in Zod:
  29. * https://github.com/colinhacks/zod/commit/9340fd51e48576a75adc919bff65dbc4a5d4c99b
  30. */
  31. emoji: () => {
  32. if (emojiRegex === undefined) {
  33. emojiRegex = RegExp("^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", "u");
  34. }
  35. return emojiRegex;
  36. },
  37. /**
  38. * Unused
  39. */
  40. uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,
  41. /**
  42. * Unused
  43. */
  44. ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,
  45. ipv4Cidr: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,
  46. /**
  47. * Unused
  48. */
  49. ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/,
  50. ipv6Cidr: /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,
  51. base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,
  52. base64url: /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,
  53. nanoid: /^[a-zA-Z0-9_-]{21}$/,
  54. jwt: /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/,
  55. };
  56. export function parseStringDef(def, refs) {
  57. const res = {
  58. type: "string",
  59. };
  60. if (def.checks) {
  61. for (const check of def.checks) {
  62. switch (check.kind) {
  63. case "min":
  64. setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number"
  65. ? Math.max(res.minLength, check.value)
  66. : check.value, check.message, refs);
  67. break;
  68. case "max":
  69. setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number"
  70. ? Math.min(res.maxLength, check.value)
  71. : check.value, check.message, refs);
  72. break;
  73. case "email":
  74. switch (refs.emailStrategy) {
  75. case "format:email":
  76. addFormat(res, "email", check.message, refs);
  77. break;
  78. case "format:idn-email":
  79. addFormat(res, "idn-email", check.message, refs);
  80. break;
  81. case "pattern:zod":
  82. addPattern(res, zodPatterns.email, check.message, refs);
  83. break;
  84. }
  85. break;
  86. case "url":
  87. addFormat(res, "uri", check.message, refs);
  88. break;
  89. case "uuid":
  90. addFormat(res, "uuid", check.message, refs);
  91. break;
  92. case "regex":
  93. addPattern(res, check.regex, check.message, refs);
  94. break;
  95. case "cuid":
  96. addPattern(res, zodPatterns.cuid, check.message, refs);
  97. break;
  98. case "cuid2":
  99. addPattern(res, zodPatterns.cuid2, check.message, refs);
  100. break;
  101. case "startsWith":
  102. addPattern(res, RegExp(`^${escapeLiteralCheckValue(check.value, refs)}`), check.message, refs);
  103. break;
  104. case "endsWith":
  105. addPattern(res, RegExp(`${escapeLiteralCheckValue(check.value, refs)}$`), check.message, refs);
  106. break;
  107. case "datetime":
  108. addFormat(res, "date-time", check.message, refs);
  109. break;
  110. case "date":
  111. addFormat(res, "date", check.message, refs);
  112. break;
  113. case "time":
  114. addFormat(res, "time", check.message, refs);
  115. break;
  116. case "duration":
  117. addFormat(res, "duration", check.message, refs);
  118. break;
  119. case "length":
  120. setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number"
  121. ? Math.max(res.minLength, check.value)
  122. : check.value, check.message, refs);
  123. setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number"
  124. ? Math.min(res.maxLength, check.value)
  125. : check.value, check.message, refs);
  126. break;
  127. case "includes": {
  128. addPattern(res, RegExp(escapeLiteralCheckValue(check.value, refs)), check.message, refs);
  129. break;
  130. }
  131. case "ip": {
  132. if (check.version !== "v6") {
  133. addFormat(res, "ipv4", check.message, refs);
  134. }
  135. if (check.version !== "v4") {
  136. addFormat(res, "ipv6", check.message, refs);
  137. }
  138. break;
  139. }
  140. case "base64url":
  141. addPattern(res, zodPatterns.base64url, check.message, refs);
  142. break;
  143. case "jwt":
  144. addPattern(res, zodPatterns.jwt, check.message, refs);
  145. break;
  146. case "cidr": {
  147. if (check.version !== "v6") {
  148. addPattern(res, zodPatterns.ipv4Cidr, check.message, refs);
  149. }
  150. if (check.version !== "v4") {
  151. addPattern(res, zodPatterns.ipv6Cidr, check.message, refs);
  152. }
  153. break;
  154. }
  155. case "emoji":
  156. addPattern(res, zodPatterns.emoji(), check.message, refs);
  157. break;
  158. case "ulid": {
  159. addPattern(res, zodPatterns.ulid, check.message, refs);
  160. break;
  161. }
  162. case "base64": {
  163. switch (refs.base64Strategy) {
  164. case "format:binary": {
  165. addFormat(res, "binary", check.message, refs);
  166. break;
  167. }
  168. case "contentEncoding:base64": {
  169. setResponseValueAndErrors(res, "contentEncoding", "base64", check.message, refs);
  170. break;
  171. }
  172. case "pattern:zod": {
  173. addPattern(res, zodPatterns.base64, check.message, refs);
  174. break;
  175. }
  176. }
  177. break;
  178. }
  179. case "nanoid": {
  180. addPattern(res, zodPatterns.nanoid, check.message, refs);
  181. }
  182. case "toLowerCase":
  183. case "toUpperCase":
  184. case "trim":
  185. break;
  186. default:
  187. /* c8 ignore next */
  188. ((_) => { })(check);
  189. }
  190. }
  191. }
  192. return res;
  193. }
  194. function escapeLiteralCheckValue(literal, refs) {
  195. return refs.patternStrategy === "escape"
  196. ? escapeNonAlphaNumeric(literal)
  197. : literal;
  198. }
  199. const ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789");
  200. function escapeNonAlphaNumeric(source) {
  201. let result = "";
  202. for (let i = 0; i < source.length; i++) {
  203. if (!ALPHA_NUMERIC.has(source[i])) {
  204. result += "\\";
  205. }
  206. result += source[i];
  207. }
  208. return result;
  209. }
  210. // Adds a "format" keyword to the schema. If a format exists, both formats will be joined in an allOf-node, along with subsequent ones.
  211. function addFormat(schema, value, message, refs) {
  212. if (schema.format || schema.anyOf?.some((x) => x.format)) {
  213. if (!schema.anyOf) {
  214. schema.anyOf = [];
  215. }
  216. if (schema.format) {
  217. schema.anyOf.push({
  218. format: schema.format,
  219. ...(schema.errorMessage &&
  220. refs.errorMessages && {
  221. errorMessage: { format: schema.errorMessage.format },
  222. }),
  223. });
  224. delete schema.format;
  225. if (schema.errorMessage) {
  226. delete schema.errorMessage.format;
  227. if (Object.keys(schema.errorMessage).length === 0) {
  228. delete schema.errorMessage;
  229. }
  230. }
  231. }
  232. schema.anyOf.push({
  233. format: value,
  234. ...(message &&
  235. refs.errorMessages && { errorMessage: { format: message } }),
  236. });
  237. }
  238. else {
  239. setResponseValueAndErrors(schema, "format", value, message, refs);
  240. }
  241. }
  242. // Adds a "pattern" keyword to the schema. If a pattern exists, both patterns will be joined in an allOf-node, along with subsequent ones.
  243. function addPattern(schema, regex, message, refs) {
  244. if (schema.pattern || schema.allOf?.some((x) => x.pattern)) {
  245. if (!schema.allOf) {
  246. schema.allOf = [];
  247. }
  248. if (schema.pattern) {
  249. schema.allOf.push({
  250. pattern: schema.pattern,
  251. ...(schema.errorMessage &&
  252. refs.errorMessages && {
  253. errorMessage: { pattern: schema.errorMessage.pattern },
  254. }),
  255. });
  256. delete schema.pattern;
  257. if (schema.errorMessage) {
  258. delete schema.errorMessage.pattern;
  259. if (Object.keys(schema.errorMessage).length === 0) {
  260. delete schema.errorMessage;
  261. }
  262. }
  263. }
  264. schema.allOf.push({
  265. pattern: stringifyRegExpWithFlags(regex, refs),
  266. ...(message &&
  267. refs.errorMessages && { errorMessage: { pattern: message } }),
  268. });
  269. }
  270. else {
  271. setResponseValueAndErrors(schema, "pattern", stringifyRegExpWithFlags(regex, refs), message, refs);
  272. }
  273. }
  274. // Mutate z.string.regex() in a best attempt to accommodate for regex flags when applyRegexFlags is true
  275. function stringifyRegExpWithFlags(regex, refs) {
  276. if (!refs.applyRegexFlags || !regex.flags) {
  277. return regex.source;
  278. }
  279. // Currently handled flags
  280. const flags = {
  281. i: regex.flags.includes("i"),
  282. m: regex.flags.includes("m"),
  283. s: regex.flags.includes("s"), // `.` matches newlines
  284. };
  285. // The general principle here is to step through each character, one at a time, applying mutations as flags require. We keep track when the current character is escaped, and when it's inside a group /like [this]/ or (also) a range like /[a-z]/. The following is fairly brittle imperative code; edit at your peril!
  286. const source = flags.i ? regex.source.toLowerCase() : regex.source;
  287. let pattern = "";
  288. let isEscaped = false;
  289. let inCharGroup = false;
  290. let inCharRange = false;
  291. for (let i = 0; i < source.length; i++) {
  292. if (isEscaped) {
  293. pattern += source[i];
  294. isEscaped = false;
  295. continue;
  296. }
  297. if (flags.i) {
  298. if (inCharGroup) {
  299. if (source[i].match(/[a-z]/)) {
  300. if (inCharRange) {
  301. pattern += source[i];
  302. pattern += `${source[i - 2]}-${source[i]}`.toUpperCase();
  303. inCharRange = false;
  304. }
  305. else if (source[i + 1] === "-" && source[i + 2]?.match(/[a-z]/)) {
  306. pattern += source[i];
  307. inCharRange = true;
  308. }
  309. else {
  310. pattern += `${source[i]}${source[i].toUpperCase()}`;
  311. }
  312. continue;
  313. }
  314. }
  315. else if (source[i].match(/[a-z]/)) {
  316. pattern += `[${source[i]}${source[i].toUpperCase()}]`;
  317. continue;
  318. }
  319. }
  320. if (flags.m) {
  321. if (source[i] === "^") {
  322. pattern += `(^|(?<=[\r\n]))`;
  323. continue;
  324. }
  325. else if (source[i] === "$") {
  326. pattern += `($|(?=[\r\n]))`;
  327. continue;
  328. }
  329. }
  330. if (flags.s && source[i] === ".") {
  331. pattern += inCharGroup ? `${source[i]}\r\n` : `[${source[i]}\r\n]`;
  332. continue;
  333. }
  334. pattern += source[i];
  335. if (source[i] === "\\") {
  336. isEscaped = true;
  337. }
  338. else if (inCharGroup && source[i] === "]") {
  339. inCharGroup = false;
  340. }
  341. else if (!inCharGroup && source[i] === "[") {
  342. inCharGroup = true;
  343. }
  344. }
  345. try {
  346. new RegExp(pattern);
  347. }
  348. catch {
  349. console.warn(`Could not convert regex pattern at ${refs.currentPath.join("/")} to a flag-independent form! Falling back to the flag-ignorant source`);
  350. return regex.source;
  351. }
  352. return pattern;
  353. }