ata-validator 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to ata-validator are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning.
4
4
 
5
+ ## 0.17.4 - 2026-05-25
6
+
7
+ ### Fixed
8
+
9
+ - Standalone output now embeds user-supplied format functions. `toStandaloneModule` and `bundleCompact` referenced the `_uf_<name>` format helpers without declaring them, so the generated module threw `_uf_<name> is not defined` on the first validation, and their error path skipped the custom format entirely (so `validate` disagreed with `isValid`). Both now serialize the format functions via `Function#toString` and run them on the error path too, matching `bundleStandalone`.
10
+ - Compiled validators report per-property errors for schema-valued `additionalProperties`. The AOT error path previously emitted a single generic `validation failed`; it now validates each undeclared property against the subschema and reports the precise `/<key>` error, matching the runtime validator.
11
+ - `ata --version` (and `-V`) prints the CLI version instead of failing with `unknown command`.
12
+
13
+ ## 0.17.3 - 2026-05-25
14
+
15
+ ### Security
16
+
17
+ - User-supplied `pattern`, `patternProperties`, and `propertyNames` regexes now run through a linear-time matching engine, so a crafted schema or input can no longer trigger catastrophic backtracking (ReDoS). Patterns the engine cannot represent, such as those using backreferences, fall back to the native `RegExp`. The built-in `format` checks (`email`, `uri`, `uri-reference`, `hostname`, `ipv4`, `ipv6`, `date`, `date-time`, `time`, `duration`, `uuid`) were routed through the same engine and stay linear on adversarial input.
18
+
5
19
  ## 0.17.2 - 2026-05-24
6
20
 
7
21
  ### Fixed
package/bin/ata.js CHANGED
@@ -45,6 +45,7 @@ Build options:
45
45
  --dual Emit both .compiled.mjs (with source) and .compiled.min.mjs (without)
46
46
 
47
47
  -h, --help Show this message
48
+ -V, --version Print the ata-validator version
48
49
 
49
50
  Examples:
50
51
  ata compile schemas/user.json -o src/generated/user.validator.mjs
@@ -386,6 +387,10 @@ function main() {
386
387
 
387
388
  const cmd = argv[0];
388
389
  if (cmd === '-h' || cmd === '--help' || cmd === 'help') { usage(); return; }
390
+ if (cmd === '-V' || cmd === '--version' || cmd === 'version') {
391
+ process.stdout.write(require('../package.json').version + '\n');
392
+ return;
393
+ }
389
394
 
390
395
  const rest = argv.slice(1);
391
396
  let args;
package/index.js CHANGED
@@ -299,6 +299,27 @@ const _CP_LEN_SOURCE = `function _cpLen(s) {
299
299
  return len;
300
300
  }`;
301
301
 
302
+ // The linear-time regex engine, inlined verbatim into standalone output so a
303
+ // compiled module that uses safe `pattern` matchers has no runtime dependency on
304
+ // ata-validator. Read from the source file (one copy, no drift) with the strict
305
+ // directive and CommonJS exports stripped, plus the `__ataSafeRe` alias the
306
+ // emitted code calls. The engine has no eval/new Function, so the embed is
307
+ // CSP-safe.
308
+ const SAFE_REGEX_EMBED = (() => {
309
+ const raw = require("fs").readFileSync(require("path").join(__dirname, "lib", "safe-regex.js"), "utf8");
310
+ const body = raw
311
+ .replace(/^'use strict'\s*\n/, "")
312
+ .replace(/\nmodule\.exports[^\n]*\n?/, "\n");
313
+ return body.trimEnd() + "\nconst __ataSafeRe = compileSafe;";
314
+ })();
315
+
316
+ // Returns the engine embed when any supplied compiled function references the
317
+ // safe matcher (jsFn._usesSafeRe), else an empty string so non-pattern modules
318
+ // pay zero bytes.
319
+ function safeRePrelude(...fns) {
320
+ return fns.some((f) => f && f._usesSafeRe) ? SAFE_REGEX_EMBED + "\n" : "";
321
+ }
322
+
302
323
  // Above this size, simdjson On Demand (selective field access) beats JSON.parse
303
324
  // (which must materialize the full JS object tree). Buffer.from + NAPI ~2x faster.
304
325
  const SIMDJSON_THRESHOLD = 8192;
@@ -1152,7 +1173,7 @@ class Validator {
1152
1173
  return `// Auto-generated by ata-validator — do not edit
1153
1174
  'use strict';
1154
1175
  ${_CP_LEN_SOURCE}
1155
- ${preambleSrc}
1176
+ ${safeRePrelude(jsFn, jsErrFn)}${preambleSrc}
1156
1177
  const boolFn = function(d) {
1157
1178
  ${src}
1158
1179
  };
@@ -1188,11 +1209,12 @@ module.exports = { boolFn, hybridFactory, errFn };
1188
1209
  const src = jsFn._source;
1189
1210
 
1190
1211
  let errCore = '';
1212
+ let jsErrFn = null;
1191
1213
  if (!abortEarly) {
1192
- const jsErrFn = compileToJSCodegenWithErrors(
1214
+ jsErrFn = compileToJSCodegenWithErrors(
1193
1215
  typeof this._schemaObj === 'object' ? this._schemaObj : {},
1194
1216
  null,
1195
- undefined,
1217
+ this._userFormats,
1196
1218
  (source && sourceMap && schemaFile) ? { sourceMap, schemaFile } : null,
1197
1219
  );
1198
1220
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
@@ -1220,7 +1242,9 @@ module.exports = { boolFn, hybridFactory, errFn };
1220
1242
  lines.push(`const ${name} = ${JSON.stringify(val)};`);
1221
1243
  continue;
1222
1244
  }
1223
- if (val instanceof RegExp) {
1245
+ if (val && val.__ataSafe) {
1246
+ lines.push(`const ${name} = __ataSafeRe(${JSON.stringify(val.source)});`);
1247
+ } else if (val instanceof RegExp) {
1224
1248
  const flags = val.flags;
1225
1249
  lines.push(`const ${name} = new RegExp(${JSON.stringify(val.source)}${flags ? ', ' + JSON.stringify(flags) : ''});`);
1226
1250
  } else if (val instanceof Set) {
@@ -1242,6 +1266,16 @@ module.exports = { boolFn, hybridFactory, errFn };
1242
1266
  // them at module scope too, or _fn references undefined names (e.g. _af1_b0).
1243
1267
  const preambleDecls = jsFn._preambleSource ? jsFn._preambleSource + '\n' : '';
1244
1268
 
1269
+ // User-supplied format functions are referenced as _uf_<name> by both the
1270
+ // boolean (_fn) and error (errFn) bodies. Embed them via Function#toString
1271
+ // so the standalone module stays self-contained.
1272
+ let formatDecls = '';
1273
+ if (jsFn._formatClosures && jsFn._formatClosures.length > 0) {
1274
+ formatDecls = jsFn._formatClosures
1275
+ .map(({ name, fn }) => `const ${name} = ${fn.toString()};`)
1276
+ .join('\n') + '\n';
1277
+ }
1278
+
1245
1279
  const validBody = errCore
1246
1280
  ? 'return _fn(data) ? VALID : { valid: false, errors: errFn(data, true).errors }'
1247
1281
  : 'return _fn(data) ? VALID : ABORT';
@@ -1254,7 +1288,7 @@ module.exports = { boolFn, hybridFactory, errFn };
1254
1288
  // Schema is embedded; runtime has zero dependency on ata-validator.
1255
1289
  'use strict';
1256
1290
  ${_CP_LEN_SOURCE}
1257
- ${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
1291
+ ${safeRePrelude(jsFn, jsErrFn)}${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
1258
1292
  const ABORT = Object.freeze({
1259
1293
  valid: false,
1260
1294
  errors: Object.freeze([Object.freeze({
@@ -1264,7 +1298,7 @@ const ABORT = Object.freeze({
1264
1298
  path: '',
1265
1299
  })]),
1266
1300
  });
1267
- ${closureDecls}${preambleDecls}const _fn = function(d) {
1301
+ ${closureDecls}${preambleDecls}${formatDecls}const _fn = function(d) {
1268
1302
  ${src}
1269
1303
  };
1270
1304
  ${errCore}function isValid(data) { return _fn(data); }
@@ -1490,6 +1524,7 @@ Validator.bundleStandalone = function (schemas, opts) {
1490
1524
  const bundleOpts = haveIds ? { ...(opts || {}), schemas } : (opts || {});
1491
1525
  const format = (opts && opts.format) || 'cjs';
1492
1526
  const R = "Object.freeze({valid:true,errors:Object.freeze([])})";
1527
+ let bundleUsesSafeRe = false;
1493
1528
  const fns = schemas.map((schema) => {
1494
1529
  const v = new Validator(schema, bundleOpts);
1495
1530
  v._ensureCompiled();
@@ -1500,6 +1535,7 @@ Validator.bundleStandalone = function (schemas, opts) {
1500
1535
  v._schemaMap,
1501
1536
  v._userFormats,
1502
1537
  );
1538
+ if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
1503
1539
  const errBody =
1504
1540
  jsErrFn && jsErrFn._errSource
1505
1541
  ? jsErrFn._errSource
@@ -1525,10 +1561,11 @@ Validator.bundleStandalone = function (schemas, opts) {
1525
1561
  return `(function(R){${preamble}var E=function(d){var _all=true;${errBody}};return function(d){${jsFn._hybridSource}}})(R)`;
1526
1562
  });
1527
1563
  const arr = `[${fns.join(",")}]`;
1564
+ const safeEmbed = bundleUsesSafeRe ? SAFE_REGEX_EMBED + "\n" : "";
1528
1565
  if (format === 'esm') {
1529
- return `// Auto-generated by ata-validator — do not edit\nconst R=${R};\nconst validators=${arr};\nexport default validators;\nexport { validators };\n`;
1566
+ return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}const R=${R};\nconst validators=${arr};\nexport default validators;\nexport { validators };\n`;
1530
1567
  }
1531
- return `'use strict';\nvar R=${R};\nmodule.exports=[${fns.join(",")}];\n`;
1568
+ return `'use strict';\n${safeEmbed}var R=${R};\nmodule.exports=[${fns.join(",")}];\n`;
1532
1569
  };
1533
1570
 
1534
1571
  // Compact bundle: deduplicated code. Shared template functions + per-schema params.
@@ -1538,6 +1575,7 @@ Validator.bundleCompact = function (schemas, opts) {
1538
1575
  const haveIds = schemas.some((s) => s && typeof s === 'object' && s.$id);
1539
1576
  const bundleOpts = haveIds ? { ...(opts || {}), schemas } : (opts || {});
1540
1577
  const format = (opts && opts.format) || 'cjs';
1578
+ let bundleUsesSafeRe = false;
1541
1579
  // Analyze schemas and group by structure
1542
1580
  const entries = schemas.map((schema) => {
1543
1581
  const v = new Validator(schema, bundleOpts);
@@ -1547,7 +1585,9 @@ Validator.bundleCompact = function (schemas, opts) {
1547
1585
  const jsErrFn = compileToJSCodegenWithErrors(
1548
1586
  typeof schema === "string" ? JSON.parse(schema) : schema,
1549
1587
  v._schemaMap,
1588
+ v._userFormats,
1550
1589
  );
1590
+ if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
1551
1591
  // Hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) must travel with the
1552
1592
  // hybrid body or it references undefined names. Prepending keeps dedup honest:
1553
1593
  // schemas with different branch sets no longer collide on body alone.
@@ -1557,6 +1597,7 @@ Validator.bundleCompact = function (schemas, opts) {
1557
1597
  return {
1558
1598
  hybrid,
1559
1599
  err: jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : null,
1600
+ fmt: jsFn._formatClosures || null,
1560
1601
  };
1561
1602
  });
1562
1603
 
@@ -1591,9 +1632,23 @@ Validator.bundleCompact = function (schemas, opts) {
1591
1632
  let out = isEsm
1592
1633
  ? "// Auto-generated by ata-validator — do not edit\n"
1593
1634
  : "'use strict';\n";
1635
+ if (bundleUsesSafeRe) out += SAFE_REGEX_EMBED + "\n";
1594
1636
  const declKW = isEsm ? "const" : "var";
1595
1637
  out += `${declKW} R=Object.freeze({valid:true,errors:Object.freeze([])});\n`;
1596
1638
 
1639
+ // User format functions are referenced as _uf_<name> by the hybrid and error
1640
+ // bodies. Collect them across all schemas (deduped by name) and embed via
1641
+ // Function#toString so the bundle stays self-contained.
1642
+ const fmtSeen = new Set();
1643
+ for (const e of entries) {
1644
+ if (!e || !e.fmt) continue;
1645
+ for (const { name, fn } of e.fmt) {
1646
+ if (fmtSeen.has(name)) continue;
1647
+ fmtSeen.add(name);
1648
+ out += `${declKW} ${name}=${fn.toString()};\n`;
1649
+ }
1650
+ }
1651
+
1597
1652
  // Shared hybrid factories
1598
1653
  out += `${declKW} H=[\n`;
1599
1654
  out += bodies
@@ -1,6 +1,15 @@
1
1
  'use strict'
2
2
 
3
3
  const { codeFor } = require('./error-codes')
4
+ const { compileSafe, patternIsSafe } = require('./safe-regex')
5
+
6
+ // Closure value for a patternProperties/propertyNames regex: the linear-time
7
+ // safe matcher when the pattern is in the supported subset (and flag the ctx so
8
+ // standalone embeds the engine), else a plain RegExp. Both expose `.test`.
9
+ function safeReClosure (ctx, src) {
10
+ if (patternIsSafe(src)) { ctx.usesSafeRe = true; return compileSafe(src) }
11
+ return new RegExp(src)
12
+ }
4
13
 
5
14
  // Compile a JSON Schema into a pure JS validator function.
6
15
  // Closure-based validator — no new Function() or eval().
@@ -328,7 +337,7 @@ function compileToJS(schema, defs, schemaMap) {
328
337
  }
329
338
  if (schema.pattern) {
330
339
  try {
331
- const re = new RegExp(schema.pattern)
340
+ const re = patternIsSafe(schema.pattern) ? compileSafe(schema.pattern) : new RegExp(schema.pattern)
332
341
  checks.push((d) => typeof d !== 'string' || re.test(d))
333
342
  } catch {
334
343
  return null
@@ -962,6 +971,12 @@ function compileToJSCodegen(schema, schemaMap, userFormats) {
962
971
 
963
972
  // Pre-create regex objects once
964
973
  for (const code of ctx.helperCode) {
974
+ const safeMatch = code.match(/^const (_re\d+)=__ataSafeRe\((.+)\)$/)
975
+ if (safeMatch) {
976
+ closureNames.push(safeMatch[1])
977
+ closureValues.push(compileSafe(JSON.parse(safeMatch[2])))
978
+ continue
979
+ }
965
980
  const match = code.match(/^const (_re\d+)=new RegExp\((.+)\)$/)
966
981
  if (match) {
967
982
  closureNames.push(match[1])
@@ -1005,6 +1020,7 @@ function compileToJSCodegen(schema, schemaMap, userFormats) {
1005
1020
  boolFn._source = helperStr + body
1006
1021
  boolFn._preambleSource = preambleStr
1007
1022
  boolFn._hybridSource = helperStr + hybridBody
1023
+ boolFn._usesSafeRe = !!ctx.usesSafeRe
1008
1024
  // Custom-format closure entries that the bundle output needs to recreate.
1009
1025
  // Stored as { name, fn } so consumers can serialize via Function#toString.
1010
1026
  if (ctx.userFormats) {
@@ -1454,7 +1470,12 @@ function genCode(schema, v, lines, ctx, knownType) {
1454
1470
  if (!ctx.regExpMap.has(pattern)) {
1455
1471
  const ri = ctx.varCounter++
1456
1472
  ctx.regExpMap.set(pattern, ri)
1457
- ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
1473
+ if (patternIsSafe(schema.pattern)) {
1474
+ ctx.helperCode.push(`const _re${ri}=__ataSafeRe(${pattern})`);
1475
+ ctx.usesSafeRe = true
1476
+ } else {
1477
+ ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
1478
+ }
1458
1479
  }
1459
1480
  const ri = ctx.regExpMap.get(pattern);
1460
1481
  lines.push(isStr ? `if(!_re${ri}.test(${v}))return false` : `if(typeof ${v}==='string'&&!_re${ri}.test(${v}))return false`)
@@ -1552,7 +1573,7 @@ function genCode(schema, v, lines, ctx, knownType) {
1552
1573
  } else {
1553
1574
  const ri = ctx.varCounter++
1554
1575
  ctx.closureVars.push(`_re${ri}`)
1555
- ctx.closureVals.push(new RegExp(pat))
1576
+ ctx.closureVals.push(safeReClosure(ctx, pat))
1556
1577
  matchers.push({ check: `_re${ri}.test(${kVar})` })
1557
1578
  }
1558
1579
  }
@@ -1587,7 +1608,7 @@ function genCode(schema, v, lines, ctx, knownType) {
1587
1608
  } else {
1588
1609
  const ri = ctx.varCounter++
1589
1610
  ctx.closureVars.push(`_re${ri}`)
1590
- ctx.closureVals.push(new RegExp(pn.pattern))
1611
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
1591
1612
  lines.push(`if(!_re${ri}.test(${kVar}))return false`)
1592
1613
  }
1593
1614
  }
@@ -1630,7 +1651,7 @@ function genCode(schema, v, lines, ctx, knownType) {
1630
1651
  } else {
1631
1652
  const ri = ctx.varCounter++
1632
1653
  ctx.closureVars.push(`_re${ri}`)
1633
- ctx.closureVals.push(new RegExp(pn.pattern))
1654
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
1634
1655
  lines.push(`if(!_re${ri}.test(${kVar}))return false`)
1635
1656
  }
1636
1657
  }
@@ -1674,7 +1695,7 @@ function genCode(schema, v, lines, ctx, knownType) {
1674
1695
  } else {
1675
1696
  const ri = ctx.varCounter++
1676
1697
  ctx.closureVars.push(`_re${ri}`)
1677
- ctx.closureVals.push(new RegExp(pn.pattern))
1698
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
1678
1699
  lines.push(`if(!_re${ri}.test(_k${ki}))return false`)
1679
1700
  }
1680
1701
  }
@@ -2102,7 +2123,7 @@ function genCode(schema, v, lines, ctx, knownType) {
2102
2123
  for (const pat of allPatterns) {
2103
2124
  const ri = ctx.varCounter++
2104
2125
  ctx.closureVars.push(`_ure${ri}`)
2105
- ctx.closureVals.push(new RegExp(pat))
2126
+ ctx.closureVals.push(safeReClosure(ctx, pat))
2106
2127
  reVars.push(`_ure${ri}`)
2107
2128
  }
2108
2129
  if (schema.if && !schema.then && !schema.else) {
@@ -2119,7 +2140,7 @@ function genCode(schema, v, lines, ctx, knownType) {
2119
2140
  for (const pat of ifPatterns) {
2120
2141
  const ri = ctx.varCounter++
2121
2142
  ctx.closureVars.push(`_ure${ri}`)
2122
- ctx.closureVals.push(new RegExp(pat))
2143
+ ctx.closureVals.push(safeReClosure(ctx, pat))
2123
2144
  ifReVars.push(`_ure${ri}`)
2124
2145
  }
2125
2146
  const rootReVars = []
@@ -2127,7 +2148,7 @@ function genCode(schema, v, lines, ctx, knownType) {
2127
2148
  for (const pat of Object.keys(schema.patternProperties)) {
2128
2149
  const ri = ctx.varCounter++
2129
2150
  ctx.closureVars.push(`_ure${ri}`)
2130
- ctx.closureVals.push(new RegExp(pat))
2151
+ ctx.closureVals.push(safeReClosure(ctx, pat))
2131
2152
  rootReVars.push(`_ure${ri}`)
2132
2153
  }
2133
2154
  }
@@ -2537,9 +2558,6 @@ function compileToJSCodegenWithErrors(schema, schemaMap, userFormats, sourceOpts
2537
2558
  if (s.includes('unevaluatedProperties') || s.includes('unevaluatedItems')) return null
2538
2559
  // Bail on self-referencing schemas — error codegen doesn't support recursion
2539
2560
  if (s.includes('"$ref":"#"')) return null
2540
- // Bail on additionalProperties as a schema — genCodeE does not emit the
2541
- // per-key sub-schema loop. Let the closure path handle detailed errors.
2542
- if (hasAdditionalPropertiesSchema(schema)) return null
2543
2561
  }
2544
2562
  if (typeof schema === 'boolean') {
2545
2563
  return schema
@@ -2621,8 +2639,17 @@ function compileToJSCodegenWithErrors(schema, schemaMap, userFormats, sourceOpts
2621
2639
  `\n return{valid:_e.length===0,errors:_e}`
2622
2640
  }
2623
2641
  try {
2624
- const fn = new Function('d', '_all', body)
2642
+ let fn
2643
+ if (ctx.usesSafeRe) {
2644
+ // Inlined helperCode references __ataSafeRe; bind the safe-regex factory.
2645
+ // Standalone keeps the inline _errSource — the embed defines __ataSafeRe.
2646
+ const built = new Function('__ataSafeRe', 'd', '_all', body)
2647
+ fn = (d, _all) => built(compileSafe, d, _all)
2648
+ } else {
2649
+ fn = new Function('d', '_all', body)
2650
+ }
2625
2651
  fn._errSource = body
2652
+ fn._usesSafeRe = !!ctx.usesSafeRe
2626
2653
  return fn
2627
2654
  } catch {
2628
2655
  return null
@@ -2856,7 +2883,12 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2856
2883
  if (!ctx.regExpMap.has(pattern)) {
2857
2884
  const ri = ctx.varCounter++
2858
2885
  ctx.regExpMap.set(pattern, ri)
2859
- ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`)
2886
+ if (patternIsSafe(schema.pattern)) {
2887
+ ctx.helperCode.push(`const _re${ri}=__ataSafeRe(${pattern})`)
2888
+ ctx.usesSafeRe = true
2889
+ } else {
2890
+ ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`)
2891
+ }
2860
2892
  }
2861
2893
  const ri = ctx.regExpMap.get(pattern);
2862
2894
  const c = isStr ? `!_re${ri}.test(${v})` : `typeof ${v}==='string'&&!_re${ri}.test(${v})`
@@ -2961,7 +2993,12 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2961
2993
  if (!ctx.regExpMap.has(pattern)) {
2962
2994
  const ri = ctx.varCounter++
2963
2995
  ctx.regExpMap.set(pattern, ri)
2964
- ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
2996
+ if (patternIsSafe(pat)) {
2997
+ ctx.helperCode.push(`const _re${ri}=__ataSafeRe(${pattern})`);
2998
+ ctx.usesSafeRe = true
2999
+ } else {
3000
+ ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
3001
+ }
2965
3002
  }
2966
3003
  const ri = ctx.regExpMap.get(pattern);
2967
3004
  const ki = ctx.varCounter++
@@ -2972,6 +3009,21 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2972
3009
  }
2973
3010
  }
2974
3011
 
3012
+ // additionalProperties as a schema — validate each property not declared in
3013
+ // `properties` against the subschema, with a dynamic /<key> path. codegenSafe
3014
+ // guarantees no patternProperties or composition here, so "additional" is
3015
+ // simply "not declared".
3016
+ if (typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null) {
3017
+ const ki = ctx.varCounter++
3018
+ const known = Object.keys(schema.properties || {})
3019
+ const guard = known.length ? `const _ak${ki}=new Set([${known.map((k) => JSON.stringify(k)).join(',')}]);` : ''
3020
+ const keep = known.length ? `if(!_ak${ki}.has(_k${ki})){` : `{`
3021
+ lines.push(`if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${guard}for(const _k${ki} in ${v}){${keep}`)
3022
+ const p = pathExpr ? `${pathExpr}+'/'+_k${ki}` : `'/'+_k${ki}`
3023
+ genCodeE(schema.additionalProperties, `${v}[_k${ki}]`, p, lines, ctx, schemaPrefix + '/additionalProperties')
3024
+ lines.push(`}}}`)
3025
+ }
3026
+
2975
3027
  // dependentSchemas
2976
3028
  if (schema.dependentSchemas) {
2977
3029
  for (const [key, depSchema] of Object.entries(schema.dependentSchemas)) {
@@ -2997,7 +3049,12 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2997
3049
  if (!ctx.regExpMap.has(pattern)) {
2998
3050
  const ri = ctx.varCounter++
2999
3051
  ctx.regExpMap.set(pattern, ri)
3000
- ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
3052
+ if (patternIsSafe(pn.pattern)) {
3053
+ ctx.helperCode.push(`const _re${ri}=__ataSafeRe(${pattern})`);
3054
+ ctx.usesSafeRe = true
3055
+ } else {
3056
+ ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`);
3057
+ }
3001
3058
  }
3002
3059
  const ri = ctx.regExpMap.get(pattern);
3003
3060
  lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
@@ -3419,7 +3476,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
3419
3476
  const ri = ctx.varCounter++
3420
3477
  const reVar = `_re${ri}`
3421
3478
  ctx.closureVars.push(reVar)
3422
- ctx.closureVals.push(new RegExp(schema.pattern))
3479
+ ctx.closureVals.push(patternIsSafe(schema.pattern) ? compileSafe(schema.pattern) : new RegExp(schema.pattern))
3423
3480
  const c = isStr ? `!${reVar}.test(${v})` : `typeof ${v}==='string'&&!${reVar}.test(${v})`
3424
3481
  lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, `'must match pattern "${schema.pattern}"'`)}}`)
3425
3482
  }
@@ -3544,7 +3601,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
3544
3601
  } else {
3545
3602
  const ri = ctx.varCounter++
3546
3603
  ctx.closureVars.push(`_re${ri}`)
3547
- ctx.closureVals.push(new RegExp(pat))
3604
+ ctx.closureVals.push(safeReClosure(ctx, pat))
3548
3605
  matchers.push({ check: `_re${ri}.test(_k${pi})` })
3549
3606
  }
3550
3607
  }
@@ -3588,7 +3645,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
3588
3645
  } else {
3589
3646
  const ri = ctx.varCounter++
3590
3647
  ctx.closureVars.push(`_re${ri}`)
3591
- ctx.closureVals.push(new RegExp(pn.pattern))
3648
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
3592
3649
  lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
3593
3650
  }
3594
3651
  }
@@ -3620,7 +3677,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
3620
3677
  } else {
3621
3678
  const ri = ctx.varCounter++
3622
3679
  ctx.closureVars.push(`_re${ri}`)
3623
- ctx.closureVals.push(new RegExp(pn.pattern))
3680
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
3624
3681
  lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
3625
3682
  }
3626
3683
  }
@@ -3662,7 +3719,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
3662
3719
  if (pn.pattern) {
3663
3720
  const ri = ctx.varCounter++
3664
3721
  ctx.closureVars.push(`_re${ri}`)
3665
- ctx.closureVals.push(new RegExp(pn.pattern))
3722
+ ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
3666
3723
  lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
3667
3724
  }
3668
3725
  if (pn.const !== undefined) {
@@ -0,0 +1,300 @@
1
+ 'use strict'
2
+
3
+ // Linear-time regex engine for JSON Schema `pattern`, used in place of JS RegExp
4
+ // so an adversarial input cannot trigger catastrophic backtracking (ReDoS).
5
+ //
6
+ // It is a Pike VM: the pattern compiles to a small instruction program, and the
7
+ // VM simulates all NFA threads in lockstep over the input, deduping by program
8
+ // counter. Runtime is O(input * program), with no backtracking.
9
+ //
10
+ // Supported (the RE2 subset, which is what ata's native path also accepts):
11
+ // literals, ., character classes, \d \w \s \D \W \S, anchors ^ $, quantifiers
12
+ // * + ? {n} {n,} {n,m} (greedy or lazy, same language for a boolean test),
13
+ // groups ( ) (?: ), alternation |. Backreferences and lookaround are not
14
+ // supported by linear engines; compileSafe throws on them so the caller can
15
+ // decide (ata's codegen rejects such schemas rather than risk a hang).
16
+
17
+ const WS = [[9, 13], [32, 32], [160, 160]]
18
+ const DIGIT = [[48, 57]]
19
+ const WORD = [[48, 57], [65, 90], [97, 122], [95, 95]]
20
+
21
+ function parse (src) {
22
+ let i = 0
23
+ const len = src.length
24
+ const peek = () => src[i]
25
+ const eof = () => i >= len
26
+
27
+ function parseAlt () {
28
+ const opts = [parseConcat()]
29
+ while (!eof() && peek() === '|') { i++; opts.push(parseConcat()) }
30
+ return opts.length === 1 ? opts[0] : { t: 'alt', opts }
31
+ }
32
+
33
+ function parseConcat () {
34
+ const parts = []
35
+ while (!eof() && peek() !== '|' && peek() !== ')') parts.push(parseRepeat())
36
+ if (parts.length === 0) return { t: 'empty' }
37
+ return parts.length === 1 ? parts[0] : { t: 'concat', parts }
38
+ }
39
+
40
+ function parseRepeat () {
41
+ let node = parseAtom()
42
+ while (!eof()) {
43
+ const ch = peek()
44
+ if (ch === '*') { i++; node = { t: 'star', child: node } }
45
+ else if (ch === '+') { i++; node = { t: 'plus', child: node } }
46
+ else if (ch === '?') { i++; node = { t: 'quest', child: node } }
47
+ else if (ch === '{') {
48
+ const saved = i
49
+ const q = tryQuantifier()
50
+ if (!q) { i = saved; break }
51
+ node = { t: 'repeat', child: node, min: q.min, max: q.max }
52
+ } else break
53
+ // a trailing ? makes the quantifier lazy; same language for a boolean test
54
+ if (!eof() && peek() === '?') i++
55
+ }
56
+ return node
57
+ }
58
+
59
+ function tryQuantifier () {
60
+ // assumes current char is '{'
61
+ i++
62
+ let min = ''
63
+ while (!eof() && /[0-9]/.test(peek())) { min += peek(); i++ }
64
+ if (min === '') return null
65
+ let max
66
+ if (peek() === '}') { i++; return { min: +min, max: +min } }
67
+ if (peek() === ',') {
68
+ i++
69
+ let m = ''
70
+ while (!eof() && /[0-9]/.test(peek())) { m += peek(); i++ }
71
+ if (peek() !== '}') return null
72
+ i++
73
+ max = m === '' ? Infinity : +m
74
+ return { min: +min, max }
75
+ }
76
+ return null
77
+ }
78
+
79
+ function parseAtom () {
80
+ const ch = peek()
81
+ if (ch === '(') {
82
+ i++
83
+ if (src[i] === '?') {
84
+ if (src[i + 1] === ':') { i += 2 }
85
+ else throw new Error('unsupported group (lookaround/named) in pattern')
86
+ }
87
+ const child = parseAlt()
88
+ if (peek() !== ')') throw new Error('unbalanced ( in pattern')
89
+ i++
90
+ return { t: 'group', child }
91
+ }
92
+ if (ch === '[') return parseClass()
93
+ if (ch === '.') { i++; return { t: 'any' } }
94
+ if (ch === '^') { i++; return { t: 'bol' } }
95
+ if (ch === '$') { i++; return { t: 'eol' } }
96
+ if (ch === '\\') return parseEscape(false)
97
+ if (ch === ')' || ch === '|') return { t: 'empty' }
98
+ i++
99
+ return { t: 'char', c: ch.charCodeAt(0) }
100
+ }
101
+
102
+ function parseClass () {
103
+ i++ // [
104
+ let neg = false
105
+ if (peek() === '^') { neg = true; i++ }
106
+ const ranges = []
107
+ while (!eof() && peek() !== ']') {
108
+ let lo
109
+ if (peek() === '\\') {
110
+ const esc = parseEscape(true)
111
+ if (esc.t === 'classpart') { for (const r of esc.ranges) ranges.push(r); continue }
112
+ lo = esc.c
113
+ } else { lo = peek().charCodeAt(0); i++ }
114
+ if (peek() === '-' && src[i + 1] !== ']' && i + 1 < len) {
115
+ i++ // -
116
+ let hi
117
+ if (peek() === '\\') { const e = parseEscape(true); hi = e.c } else { hi = peek().charCodeAt(0); i++ }
118
+ ranges.push([lo, hi])
119
+ } else {
120
+ ranges.push([lo, lo])
121
+ }
122
+ }
123
+ if (peek() !== ']') throw new Error('unbalanced [ in pattern')
124
+ i++
125
+ return { t: 'class', neg, ranges }
126
+ }
127
+
128
+ function parseEscape (inClass) {
129
+ i++ // backslash
130
+ if (eof()) throw new Error('trailing backslash in pattern')
131
+ const ch = peek(); i++
132
+ switch (ch) {
133
+ case 'd': return inClass ? { t: 'classpart', ranges: DIGIT } : { t: 'class', neg: false, ranges: DIGIT }
134
+ case 'w': return inClass ? { t: 'classpart', ranges: WORD } : { t: 'class', neg: false, ranges: WORD }
135
+ case 's': return inClass ? { t: 'classpart', ranges: WS } : { t: 'class', neg: false, ranges: WS }
136
+ case 'D': if (inClass) throw new Error('\\D inside a class is not supported'); return { t: 'class', neg: true, ranges: DIGIT }
137
+ case 'W': if (inClass) throw new Error('\\W inside a class is not supported'); return { t: 'class', neg: true, ranges: WORD }
138
+ case 'S': if (inClass) throw new Error('\\S inside a class is not supported'); return { t: 'class', neg: true, ranges: WS }
139
+ case 'n': return { t: 'char', c: 10 }
140
+ case 'r': return { t: 'char', c: 13 }
141
+ case 't': return { t: 'char', c: 9 }
142
+ case 'f': return { t: 'char', c: 12 }
143
+ case 'v': return { t: 'char', c: 11 }
144
+ case '0': return { t: 'char', c: 0 }
145
+ case 'x': { const h = src.slice(i, i + 2); i += 2; return { t: 'char', c: parseInt(h, 16) } }
146
+ case 'u': { const h = src.slice(i, i + 4); i += 4; return { t: 'char', c: parseInt(h, 16) } }
147
+ case 'b': if (inClass) return { t: 'char', c: 8 }; throw new Error('\\b word boundary is not supported')
148
+ default:
149
+ if (/[1-9]/.test(ch)) throw new Error('backreferences are not supported in pattern')
150
+ return { t: 'char', c: ch.charCodeAt(0) }
151
+ }
152
+ }
153
+
154
+ const ast = parseAlt()
155
+ if (!eof()) throw new Error('unexpected "' + peek() + '" in pattern')
156
+ return ast
157
+ }
158
+
159
+ function compileProg (ast) {
160
+ const prog = []
161
+ const emit = (op, extra) => { const idx = prog.length; prog.push(Object.assign({ op }, extra)); return idx }
162
+
163
+ function rec (n) {
164
+ switch (n.t) {
165
+ case 'empty': break
166
+ case 'char': emit('char', { c: n.c }); break
167
+ case 'any': emit('any'); break
168
+ case 'class': emit('class', { neg: n.neg, ranges: n.ranges }); break
169
+ case 'bol': emit('bol'); break
170
+ case 'eol': emit('eol'); break
171
+ case 'group': rec(n.child); break
172
+ case 'concat': for (const p of n.parts) rec(p); break
173
+ case 'alt': {
174
+ const jmps = []
175
+ for (let k = 0; k < n.opts.length; k++) {
176
+ if (k < n.opts.length - 1) {
177
+ const sp = emit('split', { x: 0, y: 0 })
178
+ prog[sp].x = prog.length
179
+ rec(n.opts[k])
180
+ jmps.push(emit('jmp', { x: 0 }))
181
+ prog[sp].y = prog.length
182
+ } else {
183
+ rec(n.opts[k])
184
+ }
185
+ }
186
+ for (const j of jmps) prog[j].x = prog.length
187
+ break
188
+ }
189
+ case 'star': {
190
+ const sp = emit('split', { x: 0, y: 0 })
191
+ prog[sp].x = prog.length
192
+ rec(n.child)
193
+ emit('jmp', { x: sp })
194
+ prog[sp].y = prog.length
195
+ break
196
+ }
197
+ case 'plus': {
198
+ const start = prog.length
199
+ rec(n.child)
200
+ const sp = emit('split', { x: start, y: 0 })
201
+ prog[sp].y = prog.length
202
+ break
203
+ }
204
+ case 'quest': {
205
+ const sp = emit('split', { x: 0, y: 0 })
206
+ prog[sp].x = prog.length
207
+ rec(n.child)
208
+ prog[sp].y = prog.length
209
+ break
210
+ }
211
+ case 'repeat': {
212
+ for (let k = 0; k < n.min; k++) rec(n.child)
213
+ if (n.max === Infinity) {
214
+ if (n.min === 0) rec({ t: 'star', child: n.child })
215
+ else rec({ t: 'star', child: n.child })
216
+ } else {
217
+ for (let k = 0; k < n.max - n.min; k++) rec({ t: 'quest', child: n.child })
218
+ }
219
+ break
220
+ }
221
+ }
222
+ }
223
+
224
+ rec(ast)
225
+ emit('match')
226
+ return prog
227
+ }
228
+
229
+ function matchClass (instr, c) {
230
+ let inside = false
231
+ const r = instr.ranges
232
+ for (let k = 0; k < r.length; k++) { if (c >= r[k][0] && c <= r[k][1]) { inside = true; break } }
233
+ return instr.neg ? !inside : inside
234
+ }
235
+
236
+ function makeRunner (prog) {
237
+ const n = prog.length
238
+ const lastGen = new Int32Array(n).fill(-1)
239
+ let gen = 0
240
+ const stack = []
241
+
242
+ function addThread (list, pc, pos, len) {
243
+ stack.length = 0
244
+ stack.push(pc)
245
+ while (stack.length) {
246
+ const p = stack.pop()
247
+ if (lastGen[p] === gen) continue
248
+ lastGen[p] = gen
249
+ const I = prog[p]
250
+ switch (I.op) {
251
+ case 'jmp': stack.push(I.x); break
252
+ case 'split': stack.push(I.y); stack.push(I.x); break
253
+ case 'bol': if (pos === 0) stack.push(p + 1); break
254
+ case 'eol': if (pos === len) stack.push(p + 1); break
255
+ default: list.push(p)
256
+ }
257
+ }
258
+ }
259
+
260
+ return function test (s) {
261
+ const len = s.length
262
+ let clist = []
263
+ let nlist = []
264
+ gen++
265
+ addThread(clist, 0, 0, len)
266
+ for (let pos = 0; pos <= len; pos++) {
267
+ const c = pos < len ? s.charCodeAt(pos) : -1
268
+ gen++
269
+ nlist.length = 0
270
+ for (let k = 0; k < clist.length; k++) {
271
+ const pc = clist[k]
272
+ const I = prog[pc]
273
+ if (I.op === 'match') return true
274
+ else if (I.op === 'char') { if (c === I.c) addThread(nlist, pc + 1, pos + 1, len) }
275
+ else if (I.op === 'any') { if (c !== -1 && c !== 10) addThread(nlist, pc + 1, pos + 1, len) }
276
+ else if (I.op === 'class') { if (c !== -1 && matchClass(I, c)) addThread(nlist, pc + 1, pos + 1, len) }
277
+ }
278
+ if (pos < len) addThread(nlist, 0, pos + 1, len)
279
+ const tmp = clist; clist = nlist; nlist = tmp
280
+ }
281
+ return false
282
+ }
283
+ }
284
+
285
+ function compileSafe (pattern) {
286
+ const prog = compileProg(parse(pattern))
287
+ const runner = makeRunner(prog)
288
+ // `__ataSafe` brands the result so the standalone serializer can tell a safe
289
+ // matcher apart from a RegExp and emit `__ataSafeRe(source)` instead.
290
+ return { test: runner, source: pattern, __ataSafe: true }
291
+ }
292
+
293
+ // True when the linear engine can represent `src`. Used by the codegen to decide
294
+ // between the safe matcher and a JS RegExp fallback for patterns outside the
295
+ // supported (RE2) subset (backreferences, lookaround, etc.).
296
+ function patternIsSafe (src) {
297
+ try { compileSafe(src); return true } catch { return false }
298
+ }
299
+
300
+ module.exports = { compileSafe, patternIsSafe }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "description": "JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile to per-schema ESM modules with zero validator dependency. Generic Validator<T> for TypeBox/Zod/Valibot composition. Optional runtime API. Standard Schema V1 compatible.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -42,7 +42,7 @@
42
42
  "rebuild": "cmake-js rebuild --target ata",
43
43
  "prebuild": "pkg-prebuilds-copy --baseDir build/Release --source ata.node --name=ata --strip --napi_version=10",
44
44
  "prebuild-all": "npm run prebuild -- --arch x64 && npm run prebuild -- --arch arm64",
45
- "test": "node test.js && node tests/test_no_native.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_rich_errors_optout.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node benchmark/bench_aot_size.mjs",
45
+ "test": "node test.js && node tests/test_no_native.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_additional_props_errors.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_rich_errors_optout.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
46
46
  "bench:size": "node benchmark/bench_aot_size.mjs",
47
47
  "test:suite": "node tests/run_suite.js",
48
48
  "test:compat": "node tests/test_compat.js",