@json-schema-engine/core 0.0.1
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/coverage.d.ts +30 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +81 -0
- package/dist/coverage.js.map +1 -0
- package/dist/cursor.d.ts +16 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +28 -0
- package/dist/cursor.js.map +1 -0
- package/dist/dialect.d.ts +316 -0
- package/dist/dialect.d.ts.map +1 -0
- package/dist/dialect.js +135 -0
- package/dist/dialect.js.map +1 -0
- package/dist/engine.d.ts +203 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +493 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +370 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +49 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +128 -0
- package/dist/json.js.map +1 -0
- package/dist/keywords/applicator.d.ts +57 -0
- package/dist/keywords/applicator.d.ts.map +1 -0
- package/dist/keywords/applicator.js +789 -0
- package/dist/keywords/applicator.js.map +1 -0
- package/dist/keywords/core.d.ts +42 -0
- package/dist/keywords/core.d.ts.map +1 -0
- package/dist/keywords/core.js +139 -0
- package/dist/keywords/core.js.map +1 -0
- package/dist/keywords/format.d.ts +43 -0
- package/dist/keywords/format.d.ts.map +1 -0
- package/dist/keywords/format.js +101 -0
- package/dist/keywords/format.js.map +1 -0
- package/dist/keywords/metaschemas2019.d.ts +3 -0
- package/dist/keywords/metaschemas2019.d.ts.map +1 -0
- package/dist/keywords/metaschemas2019.js +406 -0
- package/dist/keywords/metaschemas2019.js.map +1 -0
- package/dist/keywords/metaschemas2020.d.ts +3 -0
- package/dist/keywords/metaschemas2020.d.ts.map +1 -0
- package/dist/keywords/metaschemas2020.js +441 -0
- package/dist/keywords/metaschemas2020.js.map +1 -0
- package/dist/keywords/metaschemas6.d.ts +3 -0
- package/dist/keywords/metaschemas6.d.ts.map +1 -0
- package/dist/keywords/metaschemas6.js +221 -0
- package/dist/keywords/metaschemas6.js.map +1 -0
- package/dist/keywords/metaschemas7.d.ts +3 -0
- package/dist/keywords/metaschemas7.d.ts.map +1 -0
- package/dist/keywords/metaschemas7.js +248 -0
- package/dist/keywords/metaschemas7.js.map +1 -0
- package/dist/keywords/unevaluated.d.ts +14 -0
- package/dist/keywords/unevaluated.d.ts.map +1 -0
- package/dist/keywords/unevaluated.js +296 -0
- package/dist/keywords/unevaluated.js.map +1 -0
- package/dist/keywords/validation.d.ts +21 -0
- package/dist/keywords/validation.d.ts.map +1 -0
- package/dist/keywords/validation.js +332 -0
- package/dist/keywords/validation.js.map +1 -0
- package/dist/keywords/vocab2019.d.ts +36 -0
- package/dist/keywords/vocab2019.d.ts.map +1 -0
- package/dist/keywords/vocab2019.js +585 -0
- package/dist/keywords/vocab2019.js.map +1 -0
- package/dist/keywords/vocab2020.d.ts +20 -0
- package/dist/keywords/vocab2020.d.ts.map +1 -0
- package/dist/keywords/vocab2020.js +55 -0
- package/dist/keywords/vocab2020.js.map +1 -0
- package/dist/keywords/vocab7.d.ts +46 -0
- package/dist/keywords/vocab7.d.ts.map +1 -0
- package/dist/keywords/vocab7.js +305 -0
- package/dist/keywords/vocab7.js.map +1 -0
- package/dist/loader.d.ts +41 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +13 -0
- package/dist/loader.js.map +1 -0
- package/dist/lowering.d.ts +371 -0
- package/dist/lowering.d.ts.map +1 -0
- package/dist/lowering.js +59 -0
- package/dist/lowering.js.map +1 -0
- package/dist/output.d.ts +251 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +357 -0
- package/dist/output.js.map +1 -0
- package/dist/records.d.ts +71 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +150 -0
- package/dist/records.js.map +1 -0
- package/dist/ref.d.ts +10 -0
- package/dist/ref.d.ts.map +1 -0
- package/dist/ref.js +5 -0
- package/dist/ref.js.map +1 -0
- package/dist/regex.d.ts +45 -0
- package/dist/regex.d.ts.map +1 -0
- package/dist/regex.js +163 -0
- package/dist/regex.js.map +1 -0
- package/dist/registry.d.ts +141 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +407 -0
- package/dist/registry.js.map +1 -0
- package/dist/result.d.ts +109 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +112 -0
- package/dist/result.js.map +1 -0
- package/dist/uri.d.ts +18 -0
- package/dist/uri.d.ts.map +1 -0
- package/dist/uri.js +27 -0
- package/dist/uri.js.map +1 -0
- package/dist/walk.d.ts +25 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +65 -0
- package/dist/walk.js.map +1 -0
- package/package.json +32 -0
- package/src/coverage.ts +88 -0
- package/src/cursor.ts +42 -0
- package/src/dialect.ts +428 -0
- package/src/engine.ts +741 -0
- package/src/index.ts +699 -0
- package/src/json.ts +137 -0
- package/src/keywords/applicator.ts +895 -0
- package/src/keywords/core.ts +168 -0
- package/src/keywords/format.ts +143 -0
- package/src/keywords/metaschemas2019.ts +410 -0
- package/src/keywords/metaschemas2020.ts +443 -0
- package/src/keywords/metaschemas6.ts +226 -0
- package/src/keywords/metaschemas7.ts +253 -0
- package/src/keywords/unevaluated.ts +329 -0
- package/src/keywords/validation.ts +627 -0
- package/src/keywords/vocab2019.ts +676 -0
- package/src/keywords/vocab2020.ts +82 -0
- package/src/keywords/vocab7.ts +393 -0
- package/src/loader.ts +60 -0
- package/src/lowering.ts +414 -0
- package/src/output.ts +651 -0
- package/src/records.ts +226 -0
- package/src/ref.ts +14 -0
- package/src/regex.ts +171 -0
- package/src/registry.ts +481 -0
- package/src/result.ts +252 -0
- package/src/uri.ts +35 -0
- package/src/walk.ts +88 -0
package/src/records.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Interpreter-side rendering: the engine's evaluation records (path-node
|
|
2
|
+
// chains, schema refs, cursors) become the string-located units of the flat
|
|
3
|
+
// surface here, and its trace becomes the located tree the document
|
|
4
|
+
// renderers in output.ts consume — so those renderers stay free of engine
|
|
5
|
+
// internals and serve any producer of a RenderInput.
|
|
6
|
+
|
|
7
|
+
import { escapeSegment } from "./json.js";
|
|
8
|
+
import { instancePointer } from "./cursor.js";
|
|
9
|
+
import {
|
|
10
|
+
AnnotationRecord,
|
|
11
|
+
ErrorRecord,
|
|
12
|
+
KeywordTrace,
|
|
13
|
+
PathNode,
|
|
14
|
+
TraceNode,
|
|
15
|
+
materializePath,
|
|
16
|
+
} from "./engine.js";
|
|
17
|
+
import {
|
|
18
|
+
type AnnotationSelection,
|
|
19
|
+
type AnnotationUnit,
|
|
20
|
+
type ErrorUnit,
|
|
21
|
+
type RenderInput,
|
|
22
|
+
type RenderNode,
|
|
23
|
+
NO_INDEXES,
|
|
24
|
+
makeRecordPredicate,
|
|
25
|
+
} from "./output.js";
|
|
26
|
+
|
|
27
|
+
/** The two fields of a schema reference that locate it; `node` is not needed. */
|
|
28
|
+
export interface LocationRef {
|
|
29
|
+
baseUri: string;
|
|
30
|
+
pointer: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keywordSuffix = (name: string | null): string =>
|
|
34
|
+
name === null ? "" : "/" + escapeSegment(name);
|
|
35
|
+
|
|
36
|
+
const evaluationPathOf = (
|
|
37
|
+
pathNode: PathNode | null,
|
|
38
|
+
keywordName: string | null,
|
|
39
|
+
): string => materializePath(pathNode) + keywordSuffix(keywordName);
|
|
40
|
+
|
|
41
|
+
/** Canonical schema location of a schema object, or of one of its keywords. */
|
|
42
|
+
export const schemaLocationOf = (
|
|
43
|
+
ref: LocationRef,
|
|
44
|
+
keywordName: string | null,
|
|
45
|
+
): string => `${ref.baseUri}#${ref.pointer}${keywordSuffix(keywordName)}`;
|
|
46
|
+
|
|
47
|
+
/** Renders one error record into its native unit. */
|
|
48
|
+
export function renderError(
|
|
49
|
+
record: ErrorRecord,
|
|
50
|
+
includeParams = false,
|
|
51
|
+
): ErrorUnit {
|
|
52
|
+
const unit: ErrorUnit = {
|
|
53
|
+
evaluationPath: evaluationPathOf(record.pathNode, record.keywordName),
|
|
54
|
+
schemaLocation: schemaLocationOf(record.schemaRef, record.keywordName),
|
|
55
|
+
inputLocation: instancePointer(record.cursor),
|
|
56
|
+
error: record.message,
|
|
57
|
+
};
|
|
58
|
+
if (includeParams) {
|
|
59
|
+
if (record.keywordName !== null) unit.keyword = record.keywordName;
|
|
60
|
+
if (record.vocabularyUri !== null) unit.vocabulary = record.vocabularyUri;
|
|
61
|
+
unit.params = record.params ?? {};
|
|
62
|
+
}
|
|
63
|
+
return unit;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Renders one annotation record into its native unit. */
|
|
67
|
+
export function renderAnnotation(record: AnnotationRecord): AnnotationUnit {
|
|
68
|
+
return {
|
|
69
|
+
keyword: record.keywordName,
|
|
70
|
+
...(record.vocabularyUri === null
|
|
71
|
+
? {}
|
|
72
|
+
: { vocabulary: record.vocabularyUri }),
|
|
73
|
+
evaluationPath: evaluationPathOf(record.pathNode, record.keywordName),
|
|
74
|
+
schemaLocation: schemaLocationOf(record.schemaRef, record.keywordName),
|
|
75
|
+
inputLocation: instancePointer(record.cursor),
|
|
76
|
+
annotation: record.value,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Selects and renders annotation records in one pass: each candidate is
|
|
82
|
+
* rendered once and `keep` sees that unit. The survivors' records stay
|
|
83
|
+
* positionally paired with their units so {@link toRenderInput} can index
|
|
84
|
+
* them.
|
|
85
|
+
*/
|
|
86
|
+
export function renderSelected(
|
|
87
|
+
records: readonly AnnotationRecord[],
|
|
88
|
+
selection: boolean | AnnotationSelection,
|
|
89
|
+
): { records: AnnotationRecord[]; units: AnnotationUnit[] } {
|
|
90
|
+
const byLists = makeRecordPredicate(selection);
|
|
91
|
+
const keep = typeof selection === "object" ? selection.keep : undefined;
|
|
92
|
+
const kept: AnnotationRecord[] = [];
|
|
93
|
+
const units: AnnotationUnit[] = [];
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
if (!byLists(record.behaviorId, record.keywordName, record.vocabularyUri)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const unit = renderAnnotation(record);
|
|
99
|
+
if (keep !== undefined && !keep(unit)) continue;
|
|
100
|
+
kept.push(record);
|
|
101
|
+
units.push(unit);
|
|
102
|
+
}
|
|
103
|
+
return { records: kept, units };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The record arrays the flat surface was rendered from, positionally paired with {@link UnitSets}. */
|
|
107
|
+
export interface RecordSets {
|
|
108
|
+
errors: readonly ErrorRecord[];
|
|
109
|
+
droppedErrors: readonly ErrorRecord[];
|
|
110
|
+
annotations: readonly AnnotationRecord[];
|
|
111
|
+
droppedAnnotations: readonly AnnotationRecord[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The flat surface's unit arrays, positionally paired with {@link RecordSets}. */
|
|
115
|
+
export interface UnitSets {
|
|
116
|
+
errors: readonly ErrorUnit[];
|
|
117
|
+
droppedErrors: readonly ErrorUnit[];
|
|
118
|
+
annotations: readonly AnnotationUnit[];
|
|
119
|
+
droppedAnnotations: readonly AnnotationUnit[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Records attach to applications by path-node identity: every application
|
|
123
|
+
// mints its own PathNode, so identity is finer than the evaluation-path
|
|
124
|
+
// string (repeated applications of one keyword share the string). A custom
|
|
125
|
+
// keyword applying with no segment of its own shares its parent's node and
|
|
126
|
+
// therefore its parent's record group.
|
|
127
|
+
function indexByPath(
|
|
128
|
+
records: readonly { pathNode: PathNode | null }[],
|
|
129
|
+
): Map<PathNode | null, number[]> {
|
|
130
|
+
const at = new Map<PathNode | null, number[]>();
|
|
131
|
+
records.forEach((r, i) => {
|
|
132
|
+
const list = at.get(r.pathNode);
|
|
133
|
+
if (list) list.push(i);
|
|
134
|
+
else at.set(r.pathNode, [i]);
|
|
135
|
+
});
|
|
136
|
+
return at;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Adapts the interpreter's trace into the located tree, materializing every
|
|
141
|
+
* location once per application; the index lists point into the unit
|
|
142
|
+
* arrays paired with `records`.
|
|
143
|
+
*/
|
|
144
|
+
export function toRenderNode(root: TraceNode, records: RecordSets): RenderNode {
|
|
145
|
+
const errorsAt = indexByPath(records.errors);
|
|
146
|
+
const droppedErrorsAt = indexByPath(records.droppedErrors);
|
|
147
|
+
const annotationsAt = indexByPath(records.annotations);
|
|
148
|
+
const droppedAnnotationsAt = indexByPath(records.droppedAnnotations);
|
|
149
|
+
const toNode = (node: TraceNode): RenderNode => ({
|
|
150
|
+
evaluationPath: materializePath(node.pathNode),
|
|
151
|
+
schemaLocation: schemaLocationOf(node.schemaRef, null),
|
|
152
|
+
inputLocation: instancePointer(node.cursor),
|
|
153
|
+
valid: node.valid,
|
|
154
|
+
keywords: node.keywords,
|
|
155
|
+
errors: errorsAt.get(node.pathNode) ?? NO_INDEXES,
|
|
156
|
+
droppedErrors: droppedErrorsAt.get(node.pathNode) ?? NO_INDEXES,
|
|
157
|
+
annotations: annotationsAt.get(node.pathNode) ?? NO_INDEXES,
|
|
158
|
+
droppedAnnotations: droppedAnnotationsAt.get(node.pathNode) ?? NO_INDEXES,
|
|
159
|
+
children: node.children.map(toNode),
|
|
160
|
+
});
|
|
161
|
+
return toNode(root);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A {@link RenderNode} still being filled by its producer: the index lists
|
|
166
|
+
* grow as records are attributed, `valid` is settled when the application
|
|
167
|
+
* ends. Assignable to `RenderNode` once complete.
|
|
168
|
+
*/
|
|
169
|
+
export interface MutableRenderNode extends RenderNode {
|
|
170
|
+
valid: boolean;
|
|
171
|
+
keywords: KeywordTrace[];
|
|
172
|
+
errors: number[];
|
|
173
|
+
droppedErrors: number[];
|
|
174
|
+
annotations: number[];
|
|
175
|
+
droppedAnnotations: number[];
|
|
176
|
+
children: MutableRenderNode[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Converts a traced fragment into producer-side nodes. The evaluation path
|
|
181
|
+
* comes from the fragment's path chain (a compiled caller seeds it with its
|
|
182
|
+
* own prefix), the input location gets the caller's prefix, and `at` maps
|
|
183
|
+
* each path node to the nodes sharing it so the caller can attribute the
|
|
184
|
+
* fragment's records to their owners the way the interpreter's adapter does.
|
|
185
|
+
*/
|
|
186
|
+
export function traceToRenderNodes(
|
|
187
|
+
root: TraceNode,
|
|
188
|
+
inputPrefix: string,
|
|
189
|
+
): { root: MutableRenderNode; at: Map<PathNode | null, MutableRenderNode[]> } {
|
|
190
|
+
const at = new Map<PathNode | null, MutableRenderNode[]>();
|
|
191
|
+
const toNode = (node: TraceNode): MutableRenderNode => {
|
|
192
|
+
const out: MutableRenderNode = {
|
|
193
|
+
evaluationPath: materializePath(node.pathNode),
|
|
194
|
+
schemaLocation: schemaLocationOf(node.schemaRef, null),
|
|
195
|
+
inputLocation: inputPrefix + instancePointer(node.cursor),
|
|
196
|
+
valid: node.valid,
|
|
197
|
+
keywords: node.keywords,
|
|
198
|
+
errors: [],
|
|
199
|
+
droppedErrors: [],
|
|
200
|
+
annotations: [],
|
|
201
|
+
droppedAnnotations: [],
|
|
202
|
+
children: [],
|
|
203
|
+
};
|
|
204
|
+
const sharing = at.get(node.pathNode);
|
|
205
|
+
if (sharing) sharing.push(out);
|
|
206
|
+
else at.set(node.pathNode, [out]);
|
|
207
|
+
for (const child of node.children) out.children.push(toNode(child));
|
|
208
|
+
return out;
|
|
209
|
+
};
|
|
210
|
+
return { root: toNode(root), at };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The located tree with its paired unit arrays: the renderers' input. */
|
|
214
|
+
export function toRenderInput(
|
|
215
|
+
root: TraceNode,
|
|
216
|
+
records: RecordSets,
|
|
217
|
+
units: UnitSets,
|
|
218
|
+
): RenderInput {
|
|
219
|
+
return {
|
|
220
|
+
errors: units.errors,
|
|
221
|
+
droppedErrors: units.droppedErrors,
|
|
222
|
+
annotations: units.annotations,
|
|
223
|
+
droppedAnnotations: units.droppedAnnotations,
|
|
224
|
+
root: toRenderNode(root, records),
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/ref.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// A schema position: the node plus its canonical location (lexical base URI
|
|
2
|
+
// and JSON Pointer from that base's root schema). Kept dependency-free; both
|
|
3
|
+
// the dialect layer and the registry build on it.
|
|
4
|
+
|
|
5
|
+
import { JsonValue } from "./json.js";
|
|
6
|
+
|
|
7
|
+
/** A schema node plus its canonical location. */
|
|
8
|
+
export interface SchemaRef {
|
|
9
|
+
node: JsonValue;
|
|
10
|
+
/** Canonical base URI, no fragment. */
|
|
11
|
+
baseUri: string;
|
|
12
|
+
/** JSON Pointer from the base's root schema. */
|
|
13
|
+
pointer: string;
|
|
14
|
+
}
|
package/src/regex.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Regular-expression handling for `pattern`/`patternProperties`: a pluggable
|
|
2
|
+
// compile hook, a per-engine compiled-pattern cache, and a conservative
|
|
3
|
+
// static detector for exponential-backtracking (ReDoS) shapes.
|
|
4
|
+
//
|
|
5
|
+
// `pattern`/`patternProperties` compile untrusted regex from the schema and
|
|
6
|
+
// run it against untrusted instance strings. The native RegExp engine is
|
|
7
|
+
// vulnerable to catastrophic backtracking; callers who evaluate untrusted
|
|
8
|
+
// schemas can inject a linear-time engine (e.g. RE2) through `regexEngine`,
|
|
9
|
+
// and/or reject unsafe patterns at registration with `rejectUnsafeRegex`
|
|
10
|
+
// (backed by {@link detectUnsafeRegex}). See docs/guide/security.md.
|
|
11
|
+
|
|
12
|
+
import { schemaRegExp } from "./json.js";
|
|
13
|
+
|
|
14
|
+
/** A compiled pattern: only membership testing is needed. */
|
|
15
|
+
export interface CompiledRegex {
|
|
16
|
+
test(input: string): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compiles a JSON Schema `pattern` string into a {@link CompiledRegex}. */
|
|
20
|
+
export interface RegexEngine {
|
|
21
|
+
compile(pattern: string): CompiledRegex;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The built-in engine: ECMA-262 semantics via the native `RegExp` (unicode
|
|
26
|
+
* mode where valid). Fast, but subject to catastrophic backtracking on
|
|
27
|
+
* adversarial patterns — see {@link RegexEngine} for the linear-time opt-out.
|
|
28
|
+
*/
|
|
29
|
+
export const defaultRegexEngine: RegexEngine = {
|
|
30
|
+
compile: (pattern) => schemaRegExp(pattern),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compiles patterns through a {@link RegexEngine}, caching by pattern string.
|
|
35
|
+
* One cache lives per {@link Engine}, so a pattern that appears in many
|
|
36
|
+
* schemas — or is tested against many instances — compiles once even when the
|
|
37
|
+
* engine's own compilation is expensive.
|
|
38
|
+
*/
|
|
39
|
+
export class RegexCache {
|
|
40
|
+
private cache = new Map<string, CompiledRegex>();
|
|
41
|
+
|
|
42
|
+
constructor(private engine: RegexEngine = defaultRegexEngine) {}
|
|
43
|
+
|
|
44
|
+
compile(pattern: string): CompiledRegex {
|
|
45
|
+
let compiled = this.cache.get(pattern);
|
|
46
|
+
if (compiled === undefined) {
|
|
47
|
+
compiled = this.engine.compile(pattern);
|
|
48
|
+
this.cache.set(pattern, compiled);
|
|
49
|
+
}
|
|
50
|
+
return compiled;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A pattern was rejected by {@link EngineOptions.rejectUnsafeRegex}. */
|
|
55
|
+
export class UnsafeRegexError extends Error {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Flags regular expressions whose structure admits exponential-time
|
|
59
|
+
* backtracking (ReDoS). The test is star height: a repetition applied to a
|
|
60
|
+
* subexpression that itself contains an unbounded repetition — `(a+)+`,
|
|
61
|
+
* `(a*)*`, `(.*)+` — is the classic exponential shape.
|
|
62
|
+
*
|
|
63
|
+
* This is a conservative heuristic, not a proof. It targets nested unbounded
|
|
64
|
+
* quantifiers; it does not catch every dangerous pattern (e.g. overlapping
|
|
65
|
+
* alternation such as `(a|a)*`), and a linear-time engine remains the only
|
|
66
|
+
* hard guarantee. Safe by default when the pattern cannot be parsed.
|
|
67
|
+
*/
|
|
68
|
+
export function detectUnsafeRegex(pattern: string): {
|
|
69
|
+
safe: boolean;
|
|
70
|
+
reason?: string;
|
|
71
|
+
} {
|
|
72
|
+
let i = 0;
|
|
73
|
+
|
|
74
|
+
// Parses a sequence (alternation of concatenations) up to a closing `)` or
|
|
75
|
+
// end of input, returning its star height.
|
|
76
|
+
const parseSeq = (): number => {
|
|
77
|
+
let maxHeight = 0;
|
|
78
|
+
while (i < pattern.length) {
|
|
79
|
+
const c = pattern[i];
|
|
80
|
+
if (c === "|") {
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === ")") break;
|
|
85
|
+
|
|
86
|
+
let atomHeight = 0;
|
|
87
|
+
if (c === "\\") {
|
|
88
|
+
i += 2; // escaped atom
|
|
89
|
+
} else if (c === "[") {
|
|
90
|
+
skipCharClass();
|
|
91
|
+
} else if (c === "(") {
|
|
92
|
+
i++;
|
|
93
|
+
skipGroupPrefix();
|
|
94
|
+
atomHeight = parseSeq();
|
|
95
|
+
if (pattern[i] === ")") i++;
|
|
96
|
+
} else {
|
|
97
|
+
i++; // literal
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const height = atomHeight + (consumeQuantifier() ? 1 : 0);
|
|
101
|
+
if (height > maxHeight) maxHeight = height;
|
|
102
|
+
}
|
|
103
|
+
return maxHeight;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Consumes a trailing quantifier if present; returns whether it was
|
|
107
|
+
// unbounded (`*`, `+`, `{n,}`). Bounded quantifiers (`?`, `{n}`, `{n,m}`)
|
|
108
|
+
// do not raise star height.
|
|
109
|
+
const consumeQuantifier = (): boolean => {
|
|
110
|
+
let unbounded = false;
|
|
111
|
+
const q = pattern[i];
|
|
112
|
+
if (q === "*" || q === "+") {
|
|
113
|
+
unbounded = true;
|
|
114
|
+
i++;
|
|
115
|
+
} else if (q === "?") {
|
|
116
|
+
i++;
|
|
117
|
+
} else if (q === "{") {
|
|
118
|
+
const close = pattern.indexOf("}", i);
|
|
119
|
+
if (close !== -1) {
|
|
120
|
+
const body = pattern.slice(i + 1, close);
|
|
121
|
+
i = close + 1;
|
|
122
|
+
unbounded = /^\s*\d+\s*,\s*$/.test(body);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Lazy/possessive suffix does not change star height.
|
|
126
|
+
if (pattern[i] === "?" || pattern[i] === "+") i++;
|
|
127
|
+
return unbounded;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const skipCharClass = (): void => {
|
|
131
|
+
i++; // opening [
|
|
132
|
+
if (pattern[i] === "^") i++;
|
|
133
|
+
if (pattern[i] === "]") i++; // a leading ] is literal
|
|
134
|
+
while (i < pattern.length && pattern[i] !== "]") {
|
|
135
|
+
if (pattern[i] === "\\") i++;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
i++; // closing ]
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Skips `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>` prefixes.
|
|
142
|
+
const skipGroupPrefix = (): void => {
|
|
143
|
+
if (pattern[i] !== "?") return;
|
|
144
|
+
i++;
|
|
145
|
+
if (
|
|
146
|
+
pattern[i] === "<" &&
|
|
147
|
+
pattern[i + 1] !== "=" &&
|
|
148
|
+
pattern[i + 1] !== "!"
|
|
149
|
+
) {
|
|
150
|
+
const close = pattern.indexOf(">", i);
|
|
151
|
+
if (close !== -1) i = close + 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// ?:, ?=, ?!, ?<=, ?<!
|
|
155
|
+
i++;
|
|
156
|
+
if (pattern[i] === "=" || pattern[i] === "!") i++;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let height: number;
|
|
160
|
+
try {
|
|
161
|
+
height = parseSeq();
|
|
162
|
+
} catch {
|
|
163
|
+
return { safe: true };
|
|
164
|
+
}
|
|
165
|
+
return height >= 2
|
|
166
|
+
? {
|
|
167
|
+
safe: false,
|
|
168
|
+
reason: `nested unbounded quantifier (star height ${height})`,
|
|
169
|
+
}
|
|
170
|
+
: { safe: true };
|
|
171
|
+
}
|