@safeaccess/inline 0.1.1 → 0.1.3
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/.gitattributes +1 -1
- package/CHANGELOG.md +23 -5
- package/LICENSE +1 -1
- package/README.md +79 -21
- package/dist/accessors/abstract-accessor.d.ts +24 -10
- package/dist/accessors/abstract-accessor.js +21 -8
- package/dist/accessors/abstract-integration-accessor.d.ts +22 -0
- package/dist/accessors/abstract-integration-accessor.js +23 -0
- package/dist/accessors/formats/any-accessor.d.ts +10 -8
- package/dist/accessors/formats/any-accessor.js +9 -8
- package/dist/accessors/formats/array-accessor.d.ts +2 -0
- package/dist/accessors/formats/array-accessor.js +2 -0
- package/dist/accessors/formats/env-accessor.d.ts +2 -0
- package/dist/accessors/formats/env-accessor.js +2 -0
- package/dist/accessors/formats/ini-accessor.d.ts +2 -0
- package/dist/accessors/formats/ini-accessor.js +2 -0
- package/dist/accessors/formats/json-accessor.d.ts +2 -0
- package/dist/accessors/formats/json-accessor.js +2 -0
- package/dist/accessors/formats/ndjson-accessor.d.ts +2 -0
- package/dist/accessors/formats/ndjson-accessor.js +2 -0
- package/dist/accessors/formats/object-accessor.d.ts +2 -0
- package/dist/accessors/formats/object-accessor.js +2 -0
- package/dist/accessors/formats/xml-accessor.d.ts +2 -0
- package/dist/accessors/formats/xml-accessor.js +2 -0
- package/dist/accessors/formats/yaml-accessor.d.ts +3 -1
- package/dist/accessors/formats/yaml-accessor.js +4 -2
- package/dist/cache/simple-path-cache.d.ts +51 -0
- package/dist/cache/simple-path-cache.js +72 -0
- package/dist/contracts/accessors-interface.d.ts +2 -0
- package/dist/contracts/factory-accessors-interface.d.ts +2 -0
- package/dist/contracts/filter-evaluator-interface.d.ts +28 -0
- package/dist/contracts/filter-evaluator-interface.js +1 -0
- package/dist/contracts/parse-integration-interface.d.ts +2 -0
- package/dist/contracts/parser-interface.d.ts +92 -0
- package/dist/contracts/parser-interface.js +1 -0
- package/dist/contracts/path-cache-interface.d.ts +7 -6
- package/dist/contracts/readable-accessors-interface.d.ts +11 -6
- package/dist/contracts/security-guard-interface.d.ts +2 -0
- package/dist/contracts/security-parser-interface.d.ts +2 -0
- package/dist/contracts/validatable-parser-interface.d.ts +59 -0
- package/dist/contracts/validatable-parser-interface.js +1 -0
- package/dist/contracts/writable-accessors-interface.d.ts +5 -0
- package/dist/core/accessor-factory.d.ts +124 -0
- package/dist/core/accessor-factory.js +157 -0
- package/dist/core/dot-notation-parser.d.ts +34 -5
- package/dist/core/dot-notation-parser.js +51 -10
- package/dist/core/inline-builder-accessor.d.ts +82 -0
- package/dist/core/inline-builder-accessor.js +107 -0
- package/dist/exceptions/accessor-exception.d.ts +9 -0
- package/dist/exceptions/accessor-exception.js +9 -0
- package/dist/exceptions/invalid-format-exception.d.ts +5 -0
- package/dist/exceptions/invalid-format-exception.js +5 -0
- package/dist/exceptions/parser-exception.d.ts +4 -0
- package/dist/exceptions/parser-exception.js +4 -0
- package/dist/exceptions/path-not-found-exception.d.ts +4 -0
- package/dist/exceptions/path-not-found-exception.js +4 -0
- package/dist/exceptions/readonly-violation-exception.d.ts +4 -0
- package/dist/exceptions/readonly-violation-exception.js +4 -0
- package/dist/exceptions/security-exception.d.ts +6 -0
- package/dist/exceptions/security-exception.js +6 -0
- package/dist/exceptions/unsupported-type-exception.d.ts +4 -0
- package/dist/exceptions/unsupported-type-exception.js +4 -0
- package/dist/exceptions/yaml-parse-exception.d.ts +4 -0
- package/dist/exceptions/yaml-parse-exception.js +4 -0
- package/dist/index.js +2 -1
- package/dist/inline.d.ts +26 -56
- package/dist/inline.js +43 -111
- package/dist/parser/xml-parser.js +23 -10
- package/dist/parser/yaml-parser.d.ts +54 -7
- package/dist/parser/yaml-parser.js +268 -51
- package/dist/path-query/segment-filter-parser.d.ts +142 -0
- package/dist/path-query/segment-filter-parser.js +384 -0
- package/dist/path-query/segment-parser.d.ts +98 -0
- package/dist/path-query/segment-parser.js +283 -0
- package/dist/path-query/segment-path-resolver.d.ts +149 -0
- package/dist/path-query/segment-path-resolver.js +351 -0
- package/dist/path-query/segment-type.d.ts +85 -0
- package/dist/path-query/segment-type.js +35 -0
- package/dist/security/forbidden-keys.d.ts +2 -2
- package/dist/security/forbidden-keys.js +5 -5
- package/dist/security/security-guard.d.ts +4 -1
- package/dist/security/security-guard.js +7 -2
- package/dist/security/security-parser.d.ts +10 -1
- package/dist/security/security-parser.js +10 -1
- package/dist/type-format.d.ts +2 -0
- package/dist/type-format.js +2 -0
- package/package.json +11 -3
- package/src/accessors/abstract-accessor.ts +25 -19
- package/src/accessors/abstract-integration-accessor.ts +27 -0
- package/src/accessors/formats/any-accessor.ts +11 -11
- package/src/accessors/formats/array-accessor.ts +2 -0
- package/src/accessors/formats/env-accessor.ts +2 -0
- package/src/accessors/formats/ini-accessor.ts +2 -0
- package/src/accessors/formats/json-accessor.ts +2 -0
- package/src/accessors/formats/ndjson-accessor.ts +2 -0
- package/src/accessors/formats/object-accessor.ts +2 -0
- package/src/accessors/formats/xml-accessor.ts +2 -0
- package/src/accessors/formats/yaml-accessor.ts +4 -2
- package/src/cache/simple-path-cache.ts +77 -0
- package/src/contracts/accessors-interface.ts +2 -0
- package/src/contracts/factory-accessors-interface.ts +2 -0
- package/src/contracts/filter-evaluator-interface.ts +30 -0
- package/src/contracts/parse-integration-interface.ts +2 -0
- package/src/contracts/parser-interface.ts +114 -0
- package/src/contracts/path-cache-interface.ts +8 -6
- package/src/contracts/readable-accessors-interface.ts +11 -6
- package/src/contracts/security-guard-interface.ts +2 -0
- package/src/contracts/security-parser-interface.ts +2 -0
- package/src/contracts/validatable-parser-interface.ts +64 -0
- package/src/contracts/writable-accessors-interface.ts +5 -0
- package/src/core/accessor-factory.ts +173 -0
- package/src/core/dot-notation-parser.ts +74 -11
- package/src/core/inline-builder-accessor.ts +163 -0
- package/src/exceptions/accessor-exception.ts +9 -0
- package/src/exceptions/invalid-format-exception.ts +5 -0
- package/src/exceptions/parser-exception.ts +4 -0
- package/src/exceptions/path-not-found-exception.ts +4 -0
- package/src/exceptions/readonly-violation-exception.ts +4 -0
- package/src/exceptions/security-exception.ts +6 -0
- package/src/exceptions/unsupported-type-exception.ts +4 -0
- package/src/exceptions/yaml-parse-exception.ts +4 -0
- package/src/index.ts +3 -1
- package/src/inline.ts +46 -120
- package/src/parser/xml-parser.ts +31 -10
- package/src/parser/yaml-parser.ts +310 -45
- package/src/path-query/segment-filter-parser.ts +444 -0
- package/src/path-query/segment-parser.ts +321 -0
- package/src/path-query/segment-path-resolver.ts +521 -0
- package/src/path-query/segment-type.ts +82 -0
- package/src/security/forbidden-keys.ts +5 -5
- package/src/security/security-guard.ts +10 -2
- package/src/security/security-parser.ts +18 -3
- package/src/type-format.ts +2 -0
- package/stryker.config.json +8 -10
- package/tests/accessors/abstract-accessor.test.ts +217 -0
- package/tests/accessors/abstract-integration-accessor.test.ts +37 -0
- package/tests/accessors/formats/any-accessor.test.ts +57 -0
- package/tests/accessors/formats/array-accessor.test.ts +42 -0
- package/tests/accessors/formats/env-accessor.test.ts +103 -0
- package/tests/accessors/formats/ini-accessor.test.ts +186 -0
- package/tests/accessors/{json-accessor.test.ts → formats/json-accessor.test.ts} +6 -6
- package/tests/accessors/formats/ndjson-accessor.test.ts +49 -0
- package/tests/accessors/formats/object-accessor.test.ts +172 -0
- package/tests/accessors/formats/xml-accessor.test.ts +162 -0
- package/tests/accessors/formats/yaml-accessor.test.ts +36 -0
- package/tests/cache/simple-path-cache.test.ts +168 -0
- package/tests/core/accessor-factory.test.ts +157 -0
- package/tests/core/dot-notation-parser-edge-cases.test.ts +415 -0
- package/tests/core/dot-notation-parser.test.ts +0 -288
- package/tests/core/inline-builder-accessor.test.ts +114 -0
- package/tests/exceptions/accessor-exception.test.ts +28 -0
- package/tests/exceptions/invalid-format-exception.test.ts +31 -0
- package/tests/exceptions/path-not-found-exception.test.ts +33 -0
- package/tests/exceptions/readonly-violation-exception.test.ts +35 -0
- package/tests/exceptions/security-exception.test.ts +33 -0
- package/tests/exceptions/unsupported-type-exception.test.ts +33 -0
- package/tests/exceptions/yaml-parse-exception.test.ts +38 -0
- package/tests/mocks/fake-path-cache.ts +4 -3
- package/tests/parity-from.test.ts +118 -0
- package/tests/parity.test.ts +227 -10
- package/tests/parser/xml-parser-mutations.test.ts +579 -0
- package/tests/parser/xml-parser-scanner.test.ts +379 -0
- package/tests/parser/xml-parser.test.ts +17 -330
- package/tests/parser/yaml-parser-mutations.test.ts +750 -0
- package/tests/parser/yaml-parser.test.ts +844 -18
- package/tests/path-query/segment-filter-parser-mutations.test.ts +735 -0
- package/tests/path-query/segment-filter-parser.test.ts +1091 -0
- package/tests/path-query/segment-parser-mutations.test.ts +539 -0
- package/tests/path-query/segment-parser.test.ts +606 -0
- package/tests/path-query/segment-path-resolver-mutations.test.ts +626 -0
- package/tests/path-query/segment-path-resolver.test.ts +1009 -0
- package/tests/security/security-guard-advanced.test.ts +413 -0
- package/tests/security/security-guard-forbidden-keys.test.ts +87 -0
- package/tests/security/security-guard.test.ts +8 -479
- package/tests/security/security-parser.test.ts +18 -14
- package/vitest.config.ts +3 -3
- package/benchmarks/get.bench.ts +0 -26
- package/benchmarks/parse.bench.ts +0 -41
- package/tests/accessors/accessors.test.ts +0 -1017
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import type { FilterEvaluatorInterface } from '../contracts/filter-evaluator-interface.js';
|
|
2
|
+
import { SecurityException } from '../exceptions/security-exception.js';
|
|
3
|
+
import { SegmentType } from './segment-type.js';
|
|
4
|
+
import type { Segment, FilterExpression, ProjectionField } from './segment-type.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve typed path segments against nested data structures.
|
|
8
|
+
*
|
|
9
|
+
* Traverses data using segment arrays produced by {@link SegmentParser},
|
|
10
|
+
* dispatching to segment-type-specific handlers for key, wildcard,
|
|
11
|
+
* descent, filter, slice, multi-key/index, and projection operations.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*
|
|
15
|
+
* @see SegmentParser Produces the segment arrays this resolver consumes.
|
|
16
|
+
* @see SegmentType Enum governing which handler is dispatched.
|
|
17
|
+
* @see FilterEvaluatorInterface Delegate for filter predicate evaluation.
|
|
18
|
+
* @see DotNotationParser Invokes this resolver for path queries.
|
|
19
|
+
*/
|
|
20
|
+
export class SegmentPathResolver {
|
|
21
|
+
/**
|
|
22
|
+
* Create a resolver with a filter evaluator.
|
|
23
|
+
*
|
|
24
|
+
* @param segmentFilterParser - Delegate for filter evaluation.
|
|
25
|
+
*/
|
|
26
|
+
constructor(private readonly segmentFilterParser: FilterEvaluatorInterface) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a value by walking segments starting at the given index.
|
|
30
|
+
*
|
|
31
|
+
* @param current - Current data node.
|
|
32
|
+
* @param segments - Typed segment array from {@link SegmentParser}.
|
|
33
|
+
* @param index - Current segment index.
|
|
34
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
35
|
+
* @param maxDepth - Maximum recursion depth.
|
|
36
|
+
* @returns Resolved value or the default.
|
|
37
|
+
*
|
|
38
|
+
* @throws {SecurityException} When recursion depth exceeds the limit.
|
|
39
|
+
*/
|
|
40
|
+
resolve(
|
|
41
|
+
current: unknown,
|
|
42
|
+
segments: ReadonlyArray<Segment>,
|
|
43
|
+
index: number,
|
|
44
|
+
defaultValue: unknown,
|
|
45
|
+
maxDepth: number,
|
|
46
|
+
): unknown {
|
|
47
|
+
if (index > maxDepth) {
|
|
48
|
+
throw new SecurityException(`Recursion depth ${index} exceeds maximum of ${maxDepth}.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (index >= segments.length) {
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const segment = segments[index];
|
|
56
|
+
|
|
57
|
+
switch (segment.type) {
|
|
58
|
+
case SegmentType.Descent:
|
|
59
|
+
return this.segmentDescent(current, segments, index, defaultValue, maxDepth);
|
|
60
|
+
case SegmentType.DescentMulti:
|
|
61
|
+
return this.segmentDescentMulti(current, segments, index, defaultValue, maxDepth);
|
|
62
|
+
case SegmentType.Wildcard:
|
|
63
|
+
return this.segmentWildcard(current, segments, index, defaultValue, maxDepth);
|
|
64
|
+
case SegmentType.Filter:
|
|
65
|
+
return this.segmentFilter(current, segments, index, defaultValue, maxDepth);
|
|
66
|
+
case SegmentType.MultiKey:
|
|
67
|
+
return this.segmentMultiKey(current, segments, index, defaultValue, maxDepth);
|
|
68
|
+
case SegmentType.MultiIndex:
|
|
69
|
+
return this.segmentMultiIndex(current, segments, index, defaultValue, maxDepth);
|
|
70
|
+
case SegmentType.Slice:
|
|
71
|
+
return this.segmentSlice(current, segments, index, defaultValue, maxDepth);
|
|
72
|
+
case SegmentType.Projection:
|
|
73
|
+
return this.segmentProjection(current, segments, index, defaultValue, maxDepth);
|
|
74
|
+
default:
|
|
75
|
+
return this.segmentAny(current, segments, index, defaultValue, maxDepth);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a simple key/index segment.
|
|
81
|
+
*
|
|
82
|
+
* @param current - Current data node.
|
|
83
|
+
* @param segments - Typed segment array.
|
|
84
|
+
* @param index - Current segment index.
|
|
85
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
86
|
+
* @param maxDepth - Maximum recursion depth.
|
|
87
|
+
* @returns Resolved value or the default.
|
|
88
|
+
*/
|
|
89
|
+
private segmentAny(
|
|
90
|
+
current: unknown,
|
|
91
|
+
segments: ReadonlyArray<Segment>,
|
|
92
|
+
index: number,
|
|
93
|
+
defaultValue: unknown,
|
|
94
|
+
maxDepth: number,
|
|
95
|
+
): unknown {
|
|
96
|
+
const segment = segments[index];
|
|
97
|
+
const keyValue = 'value' in segment ? (segment as { value: string }).value : '';
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
typeof current === 'object' &&
|
|
101
|
+
current !== null &&
|
|
102
|
+
Object.prototype.hasOwnProperty.call(current, keyValue)
|
|
103
|
+
) {
|
|
104
|
+
return this.resolve(
|
|
105
|
+
(current as Record<string, unknown>)[keyValue],
|
|
106
|
+
segments,
|
|
107
|
+
index + 1,
|
|
108
|
+
defaultValue,
|
|
109
|
+
maxDepth,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return defaultValue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a recursive descent segment for a single key.
|
|
118
|
+
*
|
|
119
|
+
* @param current - Current data node.
|
|
120
|
+
* @param segments - Typed segment array.
|
|
121
|
+
* @param index - Current segment index.
|
|
122
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
123
|
+
* @param maxDepth - Maximum recursion depth.
|
|
124
|
+
* @returns Array of all values matching the descent key.
|
|
125
|
+
*/
|
|
126
|
+
private segmentDescent(
|
|
127
|
+
current: unknown,
|
|
128
|
+
segments: ReadonlyArray<Segment>,
|
|
129
|
+
index: number,
|
|
130
|
+
defaultValue: unknown,
|
|
131
|
+
maxDepth: number,
|
|
132
|
+
): unknown[] {
|
|
133
|
+
const results: unknown[] = [];
|
|
134
|
+
const segment = segments[index] as { type: SegmentType.Descent; key: string };
|
|
135
|
+
const descentKey = segment.key;
|
|
136
|
+
this.collectDescent(
|
|
137
|
+
current,
|
|
138
|
+
descentKey,
|
|
139
|
+
segments,
|
|
140
|
+
index + 1,
|
|
141
|
+
defaultValue,
|
|
142
|
+
results,
|
|
143
|
+
maxDepth,
|
|
144
|
+
);
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a recursive descent segment for multiple keys.
|
|
150
|
+
*
|
|
151
|
+
* @param current - Current data node.
|
|
152
|
+
* @param segments - Typed segment array.
|
|
153
|
+
* @param index - Current segment index.
|
|
154
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
155
|
+
* @param maxDepth - Maximum recursion depth.
|
|
156
|
+
* @returns Array of all values matching any of the descent keys.
|
|
157
|
+
*/
|
|
158
|
+
private segmentDescentMulti(
|
|
159
|
+
current: unknown,
|
|
160
|
+
segments: ReadonlyArray<Segment>,
|
|
161
|
+
index: number,
|
|
162
|
+
defaultValue: unknown,
|
|
163
|
+
maxDepth: number,
|
|
164
|
+
): unknown {
|
|
165
|
+
const segment = segments[index] as {
|
|
166
|
+
type: SegmentType.DescentMulti;
|
|
167
|
+
keys: ReadonlyArray<string>;
|
|
168
|
+
};
|
|
169
|
+
const descentKeys = segment.keys;
|
|
170
|
+
const results: unknown[] = [];
|
|
171
|
+
|
|
172
|
+
for (const dk of descentKeys) {
|
|
173
|
+
this.collectDescent(current, dk, segments, index + 1, defaultValue, results, maxDepth);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return results.length > 0 ? results : defaultValue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a wildcard segment, expanding all children.
|
|
181
|
+
*
|
|
182
|
+
* @param current - Current data node.
|
|
183
|
+
* @param segments - Typed segment array.
|
|
184
|
+
* @param index - Current segment index.
|
|
185
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
186
|
+
* @param maxDepth - Maximum recursion depth.
|
|
187
|
+
* @returns Array of resolved values for all children.
|
|
188
|
+
*/
|
|
189
|
+
private segmentWildcard(
|
|
190
|
+
current: unknown,
|
|
191
|
+
segments: ReadonlyArray<Segment>,
|
|
192
|
+
index: number,
|
|
193
|
+
defaultValue: unknown,
|
|
194
|
+
maxDepth: number,
|
|
195
|
+
): unknown {
|
|
196
|
+
if (typeof current !== 'object' || current === null) {
|
|
197
|
+
return defaultValue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const items = Array.isArray(current)
|
|
201
|
+
? current
|
|
202
|
+
: Object.values(current as Record<string, unknown>);
|
|
203
|
+
|
|
204
|
+
const nextIndex = index + 1;
|
|
205
|
+
|
|
206
|
+
return items.map((item) => this.resolve(item, segments, nextIndex, defaultValue, maxDepth));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a filter segment, applying predicates to array items.
|
|
211
|
+
*
|
|
212
|
+
* @param current - Current data node.
|
|
213
|
+
* @param segments - Typed segment array.
|
|
214
|
+
* @param index - Current segment index.
|
|
215
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
216
|
+
* @param maxDepth - Maximum recursion depth.
|
|
217
|
+
* @returns Array of items passing the filter predicate.
|
|
218
|
+
*/
|
|
219
|
+
private segmentFilter(
|
|
220
|
+
current: unknown,
|
|
221
|
+
segments: ReadonlyArray<Segment>,
|
|
222
|
+
index: number,
|
|
223
|
+
defaultValue: unknown,
|
|
224
|
+
maxDepth: number,
|
|
225
|
+
): unknown {
|
|
226
|
+
if (typeof current !== 'object' || current === null) {
|
|
227
|
+
return defaultValue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const segment = segments[index] as {
|
|
231
|
+
type: SegmentType.Filter;
|
|
232
|
+
expression: FilterExpression;
|
|
233
|
+
};
|
|
234
|
+
const filterExpr = segment.expression;
|
|
235
|
+
|
|
236
|
+
const items = Array.isArray(current)
|
|
237
|
+
? current
|
|
238
|
+
: Object.values(current as Record<string, unknown>);
|
|
239
|
+
|
|
240
|
+
const filtered: unknown[] = [];
|
|
241
|
+
for (const item of items) {
|
|
242
|
+
if (typeof item === 'object' && item !== null) {
|
|
243
|
+
if (
|
|
244
|
+
this.segmentFilterParser.evaluate(item as Record<string, unknown>, filterExpr)
|
|
245
|
+
) {
|
|
246
|
+
filtered.push(item);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nextIndex = index + 1;
|
|
252
|
+
|
|
253
|
+
return filtered.map((item) =>
|
|
254
|
+
this.resolve(item, segments, nextIndex, defaultValue, maxDepth),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve a multi-key segment, selecting values by multiple keys.
|
|
260
|
+
*
|
|
261
|
+
* @param current - Current data node.
|
|
262
|
+
* @param segments - Typed segment array.
|
|
263
|
+
* @param index - Current segment index.
|
|
264
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
265
|
+
* @param maxDepth - Maximum recursion depth.
|
|
266
|
+
* @returns Array of resolved values for each key.
|
|
267
|
+
*/
|
|
268
|
+
private segmentMultiKey(
|
|
269
|
+
current: unknown,
|
|
270
|
+
segments: ReadonlyArray<Segment>,
|
|
271
|
+
index: number,
|
|
272
|
+
defaultValue: unknown,
|
|
273
|
+
maxDepth: number,
|
|
274
|
+
): unknown {
|
|
275
|
+
if (typeof current !== 'object' || current === null) {
|
|
276
|
+
return defaultValue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const nextIndex = index + 1;
|
|
280
|
+
const segment = segments[index] as {
|
|
281
|
+
type: SegmentType.MultiKey;
|
|
282
|
+
keys: ReadonlyArray<string>;
|
|
283
|
+
};
|
|
284
|
+
const multiKeys = segment.keys;
|
|
285
|
+
const obj = current as Record<string, unknown>;
|
|
286
|
+
|
|
287
|
+
return multiKeys.map((k) => {
|
|
288
|
+
const val = Object.prototype.hasOwnProperty.call(obj, k) ? obj[k] : defaultValue;
|
|
289
|
+
return this.resolve(val, segments, nextIndex, defaultValue, maxDepth);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a multi-index segment, selecting values by multiple indices.
|
|
295
|
+
*
|
|
296
|
+
* @param current - Current data node.
|
|
297
|
+
* @param segments - Typed segment array.
|
|
298
|
+
* @param index - Current segment index.
|
|
299
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
300
|
+
* @param maxDepth - Maximum recursion depth.
|
|
301
|
+
* @returns Array of resolved values for each index.
|
|
302
|
+
*/
|
|
303
|
+
private segmentMultiIndex(
|
|
304
|
+
current: unknown,
|
|
305
|
+
segments: ReadonlyArray<Segment>,
|
|
306
|
+
index: number,
|
|
307
|
+
defaultValue: unknown,
|
|
308
|
+
maxDepth: number,
|
|
309
|
+
): unknown {
|
|
310
|
+
if (typeof current !== 'object' || current === null) {
|
|
311
|
+
return defaultValue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const nextIndex = index + 1;
|
|
315
|
+
const segment = segments[index] as {
|
|
316
|
+
type: SegmentType.MultiIndex;
|
|
317
|
+
indices: ReadonlyArray<number>;
|
|
318
|
+
};
|
|
319
|
+
const indices = segment.indices;
|
|
320
|
+
const items = Array.isArray(current)
|
|
321
|
+
? current
|
|
322
|
+
: Object.values(current as Record<string, unknown>);
|
|
323
|
+
const len = items.length;
|
|
324
|
+
|
|
325
|
+
return indices.map((idx) => {
|
|
326
|
+
const resolved = idx < 0 ? (items[len + idx] ?? null) : (items[idx] ?? null);
|
|
327
|
+
if (resolved === null) {
|
|
328
|
+
return defaultValue;
|
|
329
|
+
}
|
|
330
|
+
return this.resolve(resolved, segments, nextIndex, defaultValue, maxDepth);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolve a slice segment on an array (start:end:step).
|
|
336
|
+
*
|
|
337
|
+
* @param current - Current data node.
|
|
338
|
+
* @param segments - Typed segment array.
|
|
339
|
+
* @param index - Current segment index.
|
|
340
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
341
|
+
* @param maxDepth - Maximum recursion depth.
|
|
342
|
+
* @returns Array of resolved values matching the slice range.
|
|
343
|
+
*/
|
|
344
|
+
private segmentSlice(
|
|
345
|
+
current: unknown,
|
|
346
|
+
segments: ReadonlyArray<Segment>,
|
|
347
|
+
index: number,
|
|
348
|
+
defaultValue: unknown,
|
|
349
|
+
maxDepth: number,
|
|
350
|
+
): unknown {
|
|
351
|
+
if (typeof current !== 'object' || current === null) {
|
|
352
|
+
return defaultValue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const items = Array.isArray(current)
|
|
356
|
+
? current
|
|
357
|
+
: Object.values(current as Record<string, unknown>);
|
|
358
|
+
const len = items.length;
|
|
359
|
+
const segment = segments[index] as {
|
|
360
|
+
type: SegmentType.Slice;
|
|
361
|
+
start: number | null;
|
|
362
|
+
end: number | null;
|
|
363
|
+
step: number | null;
|
|
364
|
+
};
|
|
365
|
+
const step = segment.step ?? 1;
|
|
366
|
+
let start = segment.start ?? (step > 0 ? 0 : len - 1);
|
|
367
|
+
let end = segment.end ?? (step > 0 ? len : -len - 1);
|
|
368
|
+
|
|
369
|
+
if (start < 0) {
|
|
370
|
+
start = Math.max(len + start, 0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (end < 0) {
|
|
374
|
+
end = len + end;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (start >= len) {
|
|
378
|
+
start = len;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (end > len) {
|
|
382
|
+
end = len;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const sliced: unknown[] = [];
|
|
386
|
+
if (step > 0) {
|
|
387
|
+
for (let si = start; si < end; si += step) {
|
|
388
|
+
sliced.push(items[si]);
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
for (let si = start; si > end; si += step) {
|
|
392
|
+
sliced.push(items[si]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const nextSliceIndex = index + 1;
|
|
397
|
+
|
|
398
|
+
return sliced.map((item) =>
|
|
399
|
+
this.resolve(item, segments, nextSliceIndex, defaultValue, maxDepth),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resolve a projection segment, selecting specific fields from items.
|
|
405
|
+
*
|
|
406
|
+
* @param current - Current data node.
|
|
407
|
+
* @param segments - Typed segment array.
|
|
408
|
+
* @param index - Current segment index.
|
|
409
|
+
* @param defaultValue - Fallback value when resolution fails.
|
|
410
|
+
* @param maxDepth - Maximum recursion depth.
|
|
411
|
+
* @returns Projected item(s) with only the specified fields.
|
|
412
|
+
*/
|
|
413
|
+
private segmentProjection(
|
|
414
|
+
current: unknown,
|
|
415
|
+
segments: ReadonlyArray<Segment>,
|
|
416
|
+
index: number,
|
|
417
|
+
defaultValue: unknown,
|
|
418
|
+
maxDepth: number,
|
|
419
|
+
): unknown {
|
|
420
|
+
const segment = segments[index] as {
|
|
421
|
+
type: SegmentType.Projection;
|
|
422
|
+
fields: ReadonlyArray<ProjectionField>;
|
|
423
|
+
};
|
|
424
|
+
const fields = segment.fields;
|
|
425
|
+
|
|
426
|
+
const projectItem = (item: unknown): Record<string, unknown> => {
|
|
427
|
+
if (typeof item !== 'object' || item === null) {
|
|
428
|
+
const result: Record<string, unknown> = {};
|
|
429
|
+
for (const field of fields) {
|
|
430
|
+
result[field.alias] = null;
|
|
431
|
+
}
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const obj = item as Record<string, unknown>;
|
|
436
|
+
const result: Record<string, unknown> = {};
|
|
437
|
+
for (const field of fields) {
|
|
438
|
+
result[field.alias] = Object.prototype.hasOwnProperty.call(obj, field.source)
|
|
439
|
+
? obj[field.source]
|
|
440
|
+
: null;
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const nextProjectionIndex = index + 1;
|
|
446
|
+
|
|
447
|
+
if (Array.isArray(current)) {
|
|
448
|
+
const projected = current.map(projectItem);
|
|
449
|
+
return projected.map((item) =>
|
|
450
|
+
this.resolve(item, segments, nextProjectionIndex, defaultValue, maxDepth),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (typeof current === 'object' && current !== null) {
|
|
455
|
+
const result = projectItem(current);
|
|
456
|
+
return this.resolve(result, segments, nextProjectionIndex, defaultValue, maxDepth);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return defaultValue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Recursively collect values matching a descent key from nested data.
|
|
464
|
+
*
|
|
465
|
+
* @param current - Current data node.
|
|
466
|
+
* @param key - Key to search for recursively.
|
|
467
|
+
* @param segments - Typed segment array.
|
|
468
|
+
* @param nextIndex - Next segment index after the descent.
|
|
469
|
+
* @param defaultValue - Fallback value.
|
|
470
|
+
* @param results - Collector array (mutated in place).
|
|
471
|
+
* @param maxDepth - Maximum recursion depth.
|
|
472
|
+
*/
|
|
473
|
+
private collectDescent(
|
|
474
|
+
current: unknown,
|
|
475
|
+
key: string,
|
|
476
|
+
segments: ReadonlyArray<Segment>,
|
|
477
|
+
nextIndex: number,
|
|
478
|
+
defaultValue: unknown,
|
|
479
|
+
results: unknown[],
|
|
480
|
+
maxDepth: number,
|
|
481
|
+
): void {
|
|
482
|
+
if (typeof current !== 'object' || current === null) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const obj = current as Record<string, unknown>;
|
|
487
|
+
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
489
|
+
if (nextIndex >= segments.length) {
|
|
490
|
+
results.push(obj[key]);
|
|
491
|
+
} else {
|
|
492
|
+
const resolved = this.resolve(
|
|
493
|
+
obj[key],
|
|
494
|
+
segments,
|
|
495
|
+
nextIndex,
|
|
496
|
+
defaultValue,
|
|
497
|
+
maxDepth,
|
|
498
|
+
);
|
|
499
|
+
if (Array.isArray(resolved)) {
|
|
500
|
+
results.push(...resolved);
|
|
501
|
+
} else {
|
|
502
|
+
results.push(resolved);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const child of Object.values(obj)) {
|
|
508
|
+
if (typeof child === 'object' && child !== null) {
|
|
509
|
+
this.collectDescent(
|
|
510
|
+
child,
|
|
511
|
+
key,
|
|
512
|
+
segments,
|
|
513
|
+
nextIndex,
|
|
514
|
+
defaultValue,
|
|
515
|
+
results,
|
|
516
|
+
maxDepth,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enumerate all segment types produced by the path parser.
|
|
3
|
+
*
|
|
4
|
+
* Each case represents a distinct addressing mode within a dot-notation
|
|
5
|
+
* path expression. The {@link SegmentPathResolver} dispatches resolution
|
|
6
|
+
* logic based on the segment type.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*
|
|
10
|
+
* @see SegmentParser Parses path strings into typed segment arrays.
|
|
11
|
+
* @see SegmentPathResolver Resolves data values from typed segments.
|
|
12
|
+
*/
|
|
13
|
+
export enum SegmentType {
|
|
14
|
+
/** Simple key or index access (e.g. `foo`, `0`). */
|
|
15
|
+
Key = 'key',
|
|
16
|
+
|
|
17
|
+
/** Numeric index access (e.g. `[0]`). */
|
|
18
|
+
Index = 'index',
|
|
19
|
+
|
|
20
|
+
/** Wildcard expansion over all children (e.g. `*`, `[*]`). */
|
|
21
|
+
Wildcard = 'wildcard',
|
|
22
|
+
|
|
23
|
+
/** Recursive descent into a single key (e.g. `..name`). */
|
|
24
|
+
Descent = 'descent',
|
|
25
|
+
|
|
26
|
+
/** Recursive descent into multiple keys (e.g. `..["a","b"]`). */
|
|
27
|
+
DescentMulti = 'descent-multi',
|
|
28
|
+
|
|
29
|
+
/** Multi-index selection (e.g. `[0,1,2]`). */
|
|
30
|
+
MultiIndex = 'multi-index',
|
|
31
|
+
|
|
32
|
+
/** Multi-key selection (e.g. `['a','b']`). */
|
|
33
|
+
MultiKey = 'multi-key',
|
|
34
|
+
|
|
35
|
+
/** Filter predicate expression (e.g. `[?age>18]`). */
|
|
36
|
+
Filter = 'filter',
|
|
37
|
+
|
|
38
|
+
/** Array slice notation (e.g. `[0:5]`, `[::2]`). */
|
|
39
|
+
Slice = 'slice',
|
|
40
|
+
|
|
41
|
+
/** Field projection (e.g. `.{name, age}`). */
|
|
42
|
+
Projection = 'projection',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parsed filter expression structure. */
|
|
46
|
+
export interface FilterExpression {
|
|
47
|
+
readonly conditions: ReadonlyArray<FilterCondition>;
|
|
48
|
+
readonly logicals: ReadonlyArray<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A single parsed filter condition. */
|
|
52
|
+
export interface FilterCondition {
|
|
53
|
+
readonly field: string;
|
|
54
|
+
readonly operator: string;
|
|
55
|
+
readonly value: boolean | null | number | string;
|
|
56
|
+
readonly func?: string;
|
|
57
|
+
readonly funcArgs?: ReadonlyArray<string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Projection field mapping. */
|
|
61
|
+
export interface ProjectionField {
|
|
62
|
+
readonly alias: string;
|
|
63
|
+
readonly source: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Discriminated union of all possible segment shapes. */
|
|
67
|
+
export type Segment =
|
|
68
|
+
| { readonly type: SegmentType.Key; readonly value: string }
|
|
69
|
+
| { readonly type: SegmentType.Index; readonly value: string }
|
|
70
|
+
| { readonly type: SegmentType.Wildcard }
|
|
71
|
+
| { readonly type: SegmentType.Descent; readonly key: string }
|
|
72
|
+
| { readonly type: SegmentType.DescentMulti; readonly keys: ReadonlyArray<string> }
|
|
73
|
+
| { readonly type: SegmentType.MultiIndex; readonly indices: ReadonlyArray<number> }
|
|
74
|
+
| { readonly type: SegmentType.MultiKey; readonly keys: ReadonlyArray<string> }
|
|
75
|
+
| { readonly type: SegmentType.Filter; readonly expression: FilterExpression }
|
|
76
|
+
| {
|
|
77
|
+
readonly type: SegmentType.Slice;
|
|
78
|
+
readonly start: number | null;
|
|
79
|
+
readonly end: number | null;
|
|
80
|
+
readonly step: number | null;
|
|
81
|
+
}
|
|
82
|
+
| { readonly type: SegmentType.Projection; readonly fields: ReadonlyArray<ProjectionField> };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the same word prefix (e.g. `node_modules`) are not blocked.
|
|
6
6
|
*
|
|
7
7
|
* PHP-specific wrappers (`phar://`, `php://`, `expect://`, `glob://`, `zlib://`,
|
|
8
|
-
* `ogg://`, `rar://`, `zip://`, `ssh2.tunnel://`) are intentionally absent
|
|
8
|
+
* `ogg://`, `rar://`, `zip://`, `ssh2.tunnel://`) are intentionally absent - they
|
|
9
9
|
* have no meaning in a JavaScript/Node.js runtime.
|
|
10
10
|
*
|
|
11
11
|
* `data:` uses a single-colon delimiter (matching the browser RFC 2397 format
|
|
@@ -38,7 +38,7 @@ export const STREAM_WRAPPER_PREFIXES: readonly string[] = [
|
|
|
38
38
|
* – `hasOwnProperty` shadow (overriding it can bypass guard checks)
|
|
39
39
|
* – JS-relevant stream wrapper / protocol scheme strings as exact-match defence-in-depth
|
|
40
40
|
*
|
|
41
|
-
* PHP magic methods and PHP superglobals are deliberately absent
|
|
41
|
+
* PHP magic methods and PHP superglobals are deliberately absent - they are not
|
|
42
42
|
* meaningful in a JavaScript runtime and belong in the PHP package's SecurityGuard only.
|
|
43
43
|
*
|
|
44
44
|
* @internal
|
|
@@ -53,13 +53,13 @@ export const DEFAULT_FORBIDDEN_KEYS: ReadonlySet<string> = new Set([
|
|
|
53
53
|
'__definesetter__',
|
|
54
54
|
'__lookupgetter__',
|
|
55
55
|
'__lookupsetter__',
|
|
56
|
-
// Object.prototype shadow key
|
|
56
|
+
// Object.prototype shadow key - overriding it can break hasOwnProperty-based guards
|
|
57
57
|
'hasOwnProperty',
|
|
58
|
-
// Node.js module-scope path globals
|
|
58
|
+
// Node.js module-scope path globals - should never appear as data keys to
|
|
59
59
|
// prevent path-injection risks in code that reads them via dynamic property access
|
|
60
60
|
'__dirname',
|
|
61
61
|
'__filename',
|
|
62
|
-
// Stream wrapper and protocol exact entries
|
|
62
|
+
// Stream wrapper and protocol exact entries - also caught by STREAM_WRAPPER_PREFIXES prefix matching.
|
|
63
63
|
// The Set entries below are intentional defence-in-depth: they allow O(1) exact-key
|
|
64
64
|
// lookup before the O(n) prefix loop runs.
|
|
65
65
|
'file://',
|
|
@@ -18,6 +18,8 @@ import { DEFAULT_FORBIDDEN_KEYS, STREAM_WRAPPER_PREFIXES } from './forbidden-key
|
|
|
18
18
|
* Stream wrapper URIs are matched by prefix so that fully-formed URIs such as
|
|
19
19
|
* `javascript:alert(1)` are also blocked, not only the bare scheme string.
|
|
20
20
|
*
|
|
21
|
+
* @api
|
|
22
|
+
*
|
|
21
23
|
* @example
|
|
22
24
|
* const guard = new SecurityGuard();
|
|
23
25
|
* guard.assertSafeKey('name'); // OK
|
|
@@ -26,6 +28,8 @@ import { DEFAULT_FORBIDDEN_KEYS, STREAM_WRAPPER_PREFIXES } from './forbidden-key
|
|
|
26
28
|
export class SecurityGuard implements SecurityGuardInterface {
|
|
27
29
|
readonly maxDepth: number;
|
|
28
30
|
|
|
31
|
+
readonly extraForbiddenKeys: ReadonlyArray<string>;
|
|
32
|
+
|
|
29
33
|
private readonly forbiddenKeysMap: ReadonlySet<string>;
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -34,8 +38,12 @@ export class SecurityGuard implements SecurityGuardInterface {
|
|
|
34
38
|
* @param maxDepth - Maximum recursion depth for recursive key scanning.
|
|
35
39
|
* @param extraForbiddenKeys - Additional keys to forbid beyond defaults.
|
|
36
40
|
*/
|
|
37
|
-
constructor(
|
|
41
|
+
constructor(
|
|
42
|
+
maxDepth: number = 512,
|
|
43
|
+
/* Stryker disable next-line ArrayDeclaration -- equivalent: default [] produces identical behavior; no extra keys added to Set */ extraForbiddenKeys: string[] = [],
|
|
44
|
+
) {
|
|
38
45
|
this.maxDepth = Number.isFinite(maxDepth) ? maxDepth : 512;
|
|
46
|
+
this.extraForbiddenKeys = [...extraForbiddenKeys];
|
|
39
47
|
|
|
40
48
|
/* Stryker disable next-line ConditionalExpression -- equivalent: if (false) still produces the same forbiddenKeysMap for empty arrays since Set(DEFAULT)=DEFAULT */
|
|
41
49
|
if (extraForbiddenKeys.length === 0) {
|
|
@@ -64,7 +72,7 @@ export class SecurityGuard implements SecurityGuardInterface {
|
|
|
64
72
|
* guard.isForbiddenKey('name'); // false
|
|
65
73
|
*/
|
|
66
74
|
isForbiddenKey(key: string): boolean {
|
|
67
|
-
// Normalise __* keys to lowercase
|
|
75
|
+
// Normalise __* keys to lowercase - catches case variants such as __PROTO__.
|
|
68
76
|
const lookupKey = key.startsWith('__') ? key.toLowerCase() : key;
|
|
69
77
|
|
|
70
78
|
if (this.forbiddenKeysMap.has(lookupKey)) {
|