anyvali 0.3.1 → 0.3.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 +67 -44
- package/README.md +370 -370
- package/dist/parse/coerce.d.ts.map +1 -1
- package/dist/parse/coerce.js +14 -0
- package/dist/parse/coerce.js.map +1 -1
- package/dist/schemas/number.d.ts.map +1 -1
- package/dist/schemas/number.js +15 -0
- package/dist/schemas/number.js.map +1 -1
- package/dist/schemas/optional.d.ts.map +1 -1
- package/dist/schemas/optional.js +4 -3
- package/dist/schemas/optional.js.map +1 -1
- package/package.json +40 -40
- package/sdk/js/CHANGELOG.md +13 -13
- package/src/format/validators.ts +71 -71
- package/src/index.ts +285 -285
- package/src/infer.ts +12 -12
- package/src/interchange/importer.ts +285 -285
- package/src/issue-codes.ts +19 -19
- package/src/parse/coerce.ts +15 -0
- package/src/schemas/base.ts +322 -322
- package/src/schemas/intersection.ts +81 -81
- package/src/schemas/number.ts +17 -0
- package/src/schemas/object.ts +203 -203
- package/src/schemas/optional.ts +4 -3
- package/src/schemas/record.ts +55 -55
- package/src/schemas/string.ts +192 -192
- package/src/schemas/union.ts +53 -53
- package/src/types.ts +239 -239
- package/tests/unit/collections.test.ts +99 -99
- package/tests/unit/date-format.test.ts +18 -18
- package/tests/unit/default-mutation.test.ts +32 -32
- package/tests/unit/defaults.test.ts +70 -1
- package/tests/unit/inference.test.ts +306 -306
- package/tests/unit/interchange.test.ts +191 -191
- package/tests/unit/object.test.ts +208 -208
- package/tests/unit/security-recursion.test.ts +105 -105
- package/tests/unit/security.test.ts +1067 -945
- package/tests/unit/shared-ref-falsepos.test.ts +33 -33
- package/tests/unit/string-pattern-redos.test.ts +46 -46
- package/tests/unit/string.test.ts +147 -147
|
@@ -1,208 +1,208 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { object, string, int, optional, bool, RefSchema } from "../../src/index.js";
|
|
3
|
-
|
|
4
|
-
describe("ObjectSchema", () => {
|
|
5
|
-
it("accepts valid objects", () => {
|
|
6
|
-
const s = object({
|
|
7
|
-
name: string(),
|
|
8
|
-
age: int(),
|
|
9
|
-
});
|
|
10
|
-
const result = s.parse({ name: "Alice", age: 30 });
|
|
11
|
-
expect(result).toEqual({ name: "Alice", age: 30 });
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("rejects missing required fields", () => {
|
|
15
|
-
const s = object({
|
|
16
|
-
name: string(),
|
|
17
|
-
age: int(),
|
|
18
|
-
});
|
|
19
|
-
const result = s.safeParse({ name: "Alice" });
|
|
20
|
-
expect(result.success).toBe(false);
|
|
21
|
-
if (!result.success) {
|
|
22
|
-
expect(result.issues[0].code).toBe("required");
|
|
23
|
-
expect(result.issues[0].path).toEqual(["age"]);
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("strips unknown keys by default", () => {
|
|
28
|
-
const s = object({ name: string() });
|
|
29
|
-
const result = s.parse({ name: "Alice", extra: true });
|
|
30
|
-
expect(result).toEqual({ name: "Alice" });
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("rejects unknown keys when configured", () => {
|
|
34
|
-
const s = object({ name: string() }).unknownKeys("reject");
|
|
35
|
-
const result = s.safeParse({ name: "Alice", extra: true });
|
|
36
|
-
expect(result.success).toBe(false);
|
|
37
|
-
if (!result.success) {
|
|
38
|
-
expect(result.issues[0].code).toBe("unknown_key");
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("allows unknown keys when configured", () => {
|
|
43
|
-
const s = object({ name: string() }).unknownKeys("allow");
|
|
44
|
-
const result = s.parse({ name: "Alice", extra: true });
|
|
45
|
-
expect(result).toEqual({ name: "Alice", extra: true });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("handles optional fields", () => {
|
|
49
|
-
const s = object({
|
|
50
|
-
name: string(),
|
|
51
|
-
nickname: optional(string()),
|
|
52
|
-
});
|
|
53
|
-
const result = s.parse({ name: "Alice" });
|
|
54
|
-
expect(result).toEqual({ name: "Alice" });
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("keeps object modifiers immutable", () => {
|
|
58
|
-
const base = object({ name: string() });
|
|
59
|
-
const rejected = base.unknownKeys("reject");
|
|
60
|
-
|
|
61
|
-
expect(base.parse({ name: "Alice", extra: true })).toEqual({
|
|
62
|
-
name: "Alice",
|
|
63
|
-
});
|
|
64
|
-
expect(rejected.safeParse({ name: "Alice", extra: true }).success).toBe(
|
|
65
|
-
false
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("handles defaults on fields", () => {
|
|
70
|
-
const s = object({
|
|
71
|
-
name: string(),
|
|
72
|
-
role: string().default("user"),
|
|
73
|
-
});
|
|
74
|
-
const result = s.parse({ name: "Alice" });
|
|
75
|
-
expect(result).toEqual({ name: "Alice", role: "user" });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("does not overwrite present values with defaults", () => {
|
|
79
|
-
const s = object({
|
|
80
|
-
role: string().default("user"),
|
|
81
|
-
});
|
|
82
|
-
const result = s.parse({ role: "admin" });
|
|
83
|
-
expect(result).toEqual({ role: "admin" });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("validates nested objects", () => {
|
|
87
|
-
const s = object({
|
|
88
|
-
user: object({
|
|
89
|
-
name: string(),
|
|
90
|
-
}),
|
|
91
|
-
});
|
|
92
|
-
const result = s.safeParse({ user: { name: 42 } });
|
|
93
|
-
expect(result.success).toBe(false);
|
|
94
|
-
if (!result.success) {
|
|
95
|
-
expect(result.issues[0].path).toEqual(["user", "name"]);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("collects multiple nested issues with precise paths", () => {
|
|
100
|
-
const s = object({
|
|
101
|
-
user: object({
|
|
102
|
-
name: string().minLength(2),
|
|
103
|
-
age: int().min(18),
|
|
104
|
-
}),
|
|
105
|
-
active: bool(),
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const result = s.safeParse({
|
|
109
|
-
user: { name: "", age: 12 },
|
|
110
|
-
active: "yes",
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
expect(result.success).toBe(false);
|
|
114
|
-
if (!result.success) {
|
|
115
|
-
expect(result.issues.map((issue) => issue.path)).toEqual(
|
|
116
|
-
expect.arrayContaining([
|
|
117
|
-
["user", "name"],
|
|
118
|
-
["user", "age"],
|
|
119
|
-
["active"],
|
|
120
|
-
])
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("treats inherited properties as absent", () => {
|
|
126
|
-
const base = { name: "Alice" };
|
|
127
|
-
const input = Object.create(base) as Record<string, unknown>;
|
|
128
|
-
input.age = 30;
|
|
129
|
-
|
|
130
|
-
const s = object({
|
|
131
|
-
name: string(),
|
|
132
|
-
age: int(),
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const result = s.safeParse(input);
|
|
136
|
-
expect(result.success).toBe(false);
|
|
137
|
-
if (!result.success) {
|
|
138
|
-
expect(result.issues).toEqual(
|
|
139
|
-
expect.arrayContaining([
|
|
140
|
-
expect.objectContaining({
|
|
141
|
-
code: "required",
|
|
142
|
-
path: ["name"],
|
|
143
|
-
}),
|
|
144
|
-
])
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("returns a detached parsed object", () => {
|
|
150
|
-
const s = object({
|
|
151
|
-
user: object({
|
|
152
|
-
name: string(),
|
|
153
|
-
}),
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const input = { user: { name: "Alice" } };
|
|
157
|
-
const result = s.parse(input);
|
|
158
|
-
|
|
159
|
-
expect(result).toEqual(input);
|
|
160
|
-
expect(result).not.toBe(input);
|
|
161
|
-
expect(result.user).not.toBe(input.user);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("preserves __proto__ as data when unknown keys are allowed", () => {
|
|
165
|
-
const s = object({ name: string() }).unknownKeys("allow");
|
|
166
|
-
const input = JSON.parse(
|
|
167
|
-
'{"name":"Alice","__proto__":{"polluted":"yes"}}'
|
|
168
|
-
) as Record<string, unknown>;
|
|
169
|
-
|
|
170
|
-
const result = s.parse(input) as Record<string, unknown>;
|
|
171
|
-
|
|
172
|
-
expect(result.name).toBe("Alice");
|
|
173
|
-
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
|
174
|
-
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(true);
|
|
175
|
-
expect(
|
|
176
|
-
Object.getOwnPropertyDescriptor(result, "__proto__")?.value
|
|
177
|
-
).toEqual({ polluted: "yes" });
|
|
178
|
-
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("fails closed on cyclic input for recursive schemas", () => {
|
|
182
|
-
let node!: ReturnType<typeof object>;
|
|
183
|
-
const next = optional(new RefSchema("#/definitions/Node", () => node));
|
|
184
|
-
node = object({
|
|
185
|
-
value: string(),
|
|
186
|
-
next,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const input: Record<string, unknown> = { value: "root" };
|
|
190
|
-
input.next = input;
|
|
191
|
-
|
|
192
|
-
let result:
|
|
193
|
-
| ReturnType<typeof node.safeParse>
|
|
194
|
-
| undefined;
|
|
195
|
-
|
|
196
|
-
expect(() => {
|
|
197
|
-
result = node.safeParse(input);
|
|
198
|
-
}).not.toThrow();
|
|
199
|
-
expect(result?.success).toBe(false);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("rejects non-objects", () => {
|
|
203
|
-
const s = object({ name: string() });
|
|
204
|
-
expect(s.safeParse("string").success).toBe(false);
|
|
205
|
-
expect(s.safeParse(null).success).toBe(false);
|
|
206
|
-
expect(s.safeParse([]).success).toBe(false);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { object, string, int, optional, bool, RefSchema } from "../../src/index.js";
|
|
3
|
+
|
|
4
|
+
describe("ObjectSchema", () => {
|
|
5
|
+
it("accepts valid objects", () => {
|
|
6
|
+
const s = object({
|
|
7
|
+
name: string(),
|
|
8
|
+
age: int(),
|
|
9
|
+
});
|
|
10
|
+
const result = s.parse({ name: "Alice", age: 30 });
|
|
11
|
+
expect(result).toEqual({ name: "Alice", age: 30 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rejects missing required fields", () => {
|
|
15
|
+
const s = object({
|
|
16
|
+
name: string(),
|
|
17
|
+
age: int(),
|
|
18
|
+
});
|
|
19
|
+
const result = s.safeParse({ name: "Alice" });
|
|
20
|
+
expect(result.success).toBe(false);
|
|
21
|
+
if (!result.success) {
|
|
22
|
+
expect(result.issues[0].code).toBe("required");
|
|
23
|
+
expect(result.issues[0].path).toEqual(["age"]);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("strips unknown keys by default", () => {
|
|
28
|
+
const s = object({ name: string() });
|
|
29
|
+
const result = s.parse({ name: "Alice", extra: true });
|
|
30
|
+
expect(result).toEqual({ name: "Alice" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects unknown keys when configured", () => {
|
|
34
|
+
const s = object({ name: string() }).unknownKeys("reject");
|
|
35
|
+
const result = s.safeParse({ name: "Alice", extra: true });
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
expect(result.issues[0].code).toBe("unknown_key");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("allows unknown keys when configured", () => {
|
|
43
|
+
const s = object({ name: string() }).unknownKeys("allow");
|
|
44
|
+
const result = s.parse({ name: "Alice", extra: true });
|
|
45
|
+
expect(result).toEqual({ name: "Alice", extra: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles optional fields", () => {
|
|
49
|
+
const s = object({
|
|
50
|
+
name: string(),
|
|
51
|
+
nickname: optional(string()),
|
|
52
|
+
});
|
|
53
|
+
const result = s.parse({ name: "Alice" });
|
|
54
|
+
expect(result).toEqual({ name: "Alice" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps object modifiers immutable", () => {
|
|
58
|
+
const base = object({ name: string() });
|
|
59
|
+
const rejected = base.unknownKeys("reject");
|
|
60
|
+
|
|
61
|
+
expect(base.parse({ name: "Alice", extra: true })).toEqual({
|
|
62
|
+
name: "Alice",
|
|
63
|
+
});
|
|
64
|
+
expect(rejected.safeParse({ name: "Alice", extra: true }).success).toBe(
|
|
65
|
+
false
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles defaults on fields", () => {
|
|
70
|
+
const s = object({
|
|
71
|
+
name: string(),
|
|
72
|
+
role: string().default("user"),
|
|
73
|
+
});
|
|
74
|
+
const result = s.parse({ name: "Alice" });
|
|
75
|
+
expect(result).toEqual({ name: "Alice", role: "user" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not overwrite present values with defaults", () => {
|
|
79
|
+
const s = object({
|
|
80
|
+
role: string().default("user"),
|
|
81
|
+
});
|
|
82
|
+
const result = s.parse({ role: "admin" });
|
|
83
|
+
expect(result).toEqual({ role: "admin" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("validates nested objects", () => {
|
|
87
|
+
const s = object({
|
|
88
|
+
user: object({
|
|
89
|
+
name: string(),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
const result = s.safeParse({ user: { name: 42 } });
|
|
93
|
+
expect(result.success).toBe(false);
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
expect(result.issues[0].path).toEqual(["user", "name"]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("collects multiple nested issues with precise paths", () => {
|
|
100
|
+
const s = object({
|
|
101
|
+
user: object({
|
|
102
|
+
name: string().minLength(2),
|
|
103
|
+
age: int().min(18),
|
|
104
|
+
}),
|
|
105
|
+
active: bool(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = s.safeParse({
|
|
109
|
+
user: { name: "", age: 12 },
|
|
110
|
+
active: "yes",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.success).toBe(false);
|
|
114
|
+
if (!result.success) {
|
|
115
|
+
expect(result.issues.map((issue) => issue.path)).toEqual(
|
|
116
|
+
expect.arrayContaining([
|
|
117
|
+
["user", "name"],
|
|
118
|
+
["user", "age"],
|
|
119
|
+
["active"],
|
|
120
|
+
])
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("treats inherited properties as absent", () => {
|
|
126
|
+
const base = { name: "Alice" };
|
|
127
|
+
const input = Object.create(base) as Record<string, unknown>;
|
|
128
|
+
input.age = 30;
|
|
129
|
+
|
|
130
|
+
const s = object({
|
|
131
|
+
name: string(),
|
|
132
|
+
age: int(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = s.safeParse(input);
|
|
136
|
+
expect(result.success).toBe(false);
|
|
137
|
+
if (!result.success) {
|
|
138
|
+
expect(result.issues).toEqual(
|
|
139
|
+
expect.arrayContaining([
|
|
140
|
+
expect.objectContaining({
|
|
141
|
+
code: "required",
|
|
142
|
+
path: ["name"],
|
|
143
|
+
}),
|
|
144
|
+
])
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns a detached parsed object", () => {
|
|
150
|
+
const s = object({
|
|
151
|
+
user: object({
|
|
152
|
+
name: string(),
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const input = { user: { name: "Alice" } };
|
|
157
|
+
const result = s.parse(input);
|
|
158
|
+
|
|
159
|
+
expect(result).toEqual(input);
|
|
160
|
+
expect(result).not.toBe(input);
|
|
161
|
+
expect(result.user).not.toBe(input.user);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("preserves __proto__ as data when unknown keys are allowed", () => {
|
|
165
|
+
const s = object({ name: string() }).unknownKeys("allow");
|
|
166
|
+
const input = JSON.parse(
|
|
167
|
+
'{"name":"Alice","__proto__":{"polluted":"yes"}}'
|
|
168
|
+
) as Record<string, unknown>;
|
|
169
|
+
|
|
170
|
+
const result = s.parse(input) as Record<string, unknown>;
|
|
171
|
+
|
|
172
|
+
expect(result.name).toBe("Alice");
|
|
173
|
+
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
|
174
|
+
expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(true);
|
|
175
|
+
expect(
|
|
176
|
+
Object.getOwnPropertyDescriptor(result, "__proto__")?.value
|
|
177
|
+
).toEqual({ polluted: "yes" });
|
|
178
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("fails closed on cyclic input for recursive schemas", () => {
|
|
182
|
+
let node!: ReturnType<typeof object>;
|
|
183
|
+
const next = optional(new RefSchema("#/definitions/Node", () => node));
|
|
184
|
+
node = object({
|
|
185
|
+
value: string(),
|
|
186
|
+
next,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const input: Record<string, unknown> = { value: "root" };
|
|
190
|
+
input.next = input;
|
|
191
|
+
|
|
192
|
+
let result:
|
|
193
|
+
| ReturnType<typeof node.safeParse>
|
|
194
|
+
| undefined;
|
|
195
|
+
|
|
196
|
+
expect(() => {
|
|
197
|
+
result = node.safeParse(input);
|
|
198
|
+
}).not.toThrow();
|
|
199
|
+
expect(result?.success).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("rejects non-objects", () => {
|
|
203
|
+
const s = object({ name: string() });
|
|
204
|
+
expect(s.safeParse("string").success).toBe(false);
|
|
205
|
+
expect(s.safeParse(null).success).toBe(false);
|
|
206
|
+
expect(s.safeParse([]).success).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { importSchema } from "../../src/index.js";
|
|
3
|
-
import type { AnyValiDocument } from "../../src/types.js";
|
|
4
|
-
|
|
5
|
-
// Recursive schema: Node = object({ value: int, next: optional(ref Node) }).
|
|
6
|
-
function recursiveSchemaDoc(): AnyValiDocument {
|
|
7
|
-
return {
|
|
8
|
-
anyvaliVersion: "1.0",
|
|
9
|
-
schemaVersion: "1",
|
|
10
|
-
root: { kind: "ref", ref: "#/definitions/Node" } as any,
|
|
11
|
-
definitions: {
|
|
12
|
-
Node: {
|
|
13
|
-
kind: "object",
|
|
14
|
-
properties: {
|
|
15
|
-
next: { kind: "ref", ref: "#/definitions/Node" },
|
|
16
|
-
},
|
|
17
|
-
required: [],
|
|
18
|
-
unknownKeys: "strip",
|
|
19
|
-
} as any,
|
|
20
|
-
},
|
|
21
|
-
extensions: {},
|
|
22
|
-
} as AnyValiDocument;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Build deeply nested data: { next: { next: { ... } } }.
|
|
26
|
-
function deepData(depth: number): unknown {
|
|
27
|
-
let cur: any = {};
|
|
28
|
-
for (let i = 0; i < depth; i++) cur = { next: cur };
|
|
29
|
-
return cur;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Build a deeply nested schema document: array(array(array(...))).
|
|
33
|
-
function deepArraySchemaDoc(depth: number): AnyValiDocument {
|
|
34
|
-
let node: any = { kind: "int" };
|
|
35
|
-
for (let i = 0; i < depth; i++) node = { kind: "array", items: node };
|
|
36
|
-
return {
|
|
37
|
-
anyvaliVersion: "1.0",
|
|
38
|
-
schemaVersion: "1",
|
|
39
|
-
root: node,
|
|
40
|
-
definitions: {},
|
|
41
|
-
extensions: {},
|
|
42
|
-
} as AnyValiDocument;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe("Recursion DoS (AVV-002 / AVV-003)", () => {
|
|
46
|
-
it("safeParse must not throw on deeply nested recursive-schema input", () => {
|
|
47
|
-
const schema = importSchema(recursiveSchemaDoc());
|
|
48
|
-
const data = deepData(100_000);
|
|
49
|
-
// safeParse contract: never throws, always returns a result.
|
|
50
|
-
let result: ReturnType<typeof schema.safeParse>;
|
|
51
|
-
expect(() => {
|
|
52
|
-
result = schema.safeParse(data);
|
|
53
|
-
}).not.toThrow();
|
|
54
|
-
// Bounded: extremely deep input is rejected, not accepted blindly.
|
|
55
|
-
expect(result!.success).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("depth guard is not bypassed by a union in the recursion path", () => {
|
|
59
|
-
// Node = object({ next: union(null, ref Node) }) -- the recursive ref is
|
|
60
|
-
// reached *through* a union variant. The union must propagate depth so the
|
|
61
|
-
// guard still bounds recursion.
|
|
62
|
-
const doc: AnyValiDocument = {
|
|
63
|
-
anyvaliVersion: "1.0",
|
|
64
|
-
schemaVersion: "1",
|
|
65
|
-
root: { kind: "ref", ref: "#/definitions/Node" } as any,
|
|
66
|
-
definitions: {
|
|
67
|
-
Node: {
|
|
68
|
-
kind: "object",
|
|
69
|
-
properties: {
|
|
70
|
-
next: {
|
|
71
|
-
kind: "union",
|
|
72
|
-
variants: [{ kind: "null" }, { kind: "ref", ref: "#/definitions/Node" }],
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
required: ["next"],
|
|
76
|
-
unknownKeys: "strip",
|
|
77
|
-
} as any,
|
|
78
|
-
},
|
|
79
|
-
extensions: {},
|
|
80
|
-
} as AnyValiDocument;
|
|
81
|
-
const schema = importSchema(doc);
|
|
82
|
-
|
|
83
|
-
let cur: any = { next: null };
|
|
84
|
-
for (let i = 0; i < 100_000; i++) cur = { next: cur };
|
|
85
|
-
|
|
86
|
-
let result: ReturnType<typeof schema.safeParse>;
|
|
87
|
-
expect(() => {
|
|
88
|
-
result = schema.safeParse(cur);
|
|
89
|
-
}).not.toThrow();
|
|
90
|
-
expect(result!.success).toBe(false);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("importSchema must not stack-overflow on a deeply nested schema doc", () => {
|
|
94
|
-
const doc = deepArraySchemaDoc(100_000);
|
|
95
|
-
// Should fail in a controlled way, not throw RangeError (stack overflow).
|
|
96
|
-
let err: unknown;
|
|
97
|
-
try {
|
|
98
|
-
importSchema(doc);
|
|
99
|
-
} catch (e) {
|
|
100
|
-
err = e;
|
|
101
|
-
}
|
|
102
|
-
expect(err).toBeInstanceOf(Error);
|
|
103
|
-
expect((err as Error) instanceof RangeError).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { importSchema } from "../../src/index.js";
|
|
3
|
+
import type { AnyValiDocument } from "../../src/types.js";
|
|
4
|
+
|
|
5
|
+
// Recursive schema: Node = object({ value: int, next: optional(ref Node) }).
|
|
6
|
+
function recursiveSchemaDoc(): AnyValiDocument {
|
|
7
|
+
return {
|
|
8
|
+
anyvaliVersion: "1.0",
|
|
9
|
+
schemaVersion: "1",
|
|
10
|
+
root: { kind: "ref", ref: "#/definitions/Node" } as any,
|
|
11
|
+
definitions: {
|
|
12
|
+
Node: {
|
|
13
|
+
kind: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
next: { kind: "ref", ref: "#/definitions/Node" },
|
|
16
|
+
},
|
|
17
|
+
required: [],
|
|
18
|
+
unknownKeys: "strip",
|
|
19
|
+
} as any,
|
|
20
|
+
},
|
|
21
|
+
extensions: {},
|
|
22
|
+
} as AnyValiDocument;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Build deeply nested data: { next: { next: { ... } } }.
|
|
26
|
+
function deepData(depth: number): unknown {
|
|
27
|
+
let cur: any = {};
|
|
28
|
+
for (let i = 0; i < depth; i++) cur = { next: cur };
|
|
29
|
+
return cur;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build a deeply nested schema document: array(array(array(...))).
|
|
33
|
+
function deepArraySchemaDoc(depth: number): AnyValiDocument {
|
|
34
|
+
let node: any = { kind: "int" };
|
|
35
|
+
for (let i = 0; i < depth; i++) node = { kind: "array", items: node };
|
|
36
|
+
return {
|
|
37
|
+
anyvaliVersion: "1.0",
|
|
38
|
+
schemaVersion: "1",
|
|
39
|
+
root: node,
|
|
40
|
+
definitions: {},
|
|
41
|
+
extensions: {},
|
|
42
|
+
} as AnyValiDocument;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("Recursion DoS (AVV-002 / AVV-003)", () => {
|
|
46
|
+
it("safeParse must not throw on deeply nested recursive-schema input", () => {
|
|
47
|
+
const schema = importSchema(recursiveSchemaDoc());
|
|
48
|
+
const data = deepData(100_000);
|
|
49
|
+
// safeParse contract: never throws, always returns a result.
|
|
50
|
+
let result: ReturnType<typeof schema.safeParse>;
|
|
51
|
+
expect(() => {
|
|
52
|
+
result = schema.safeParse(data);
|
|
53
|
+
}).not.toThrow();
|
|
54
|
+
// Bounded: extremely deep input is rejected, not accepted blindly.
|
|
55
|
+
expect(result!.success).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("depth guard is not bypassed by a union in the recursion path", () => {
|
|
59
|
+
// Node = object({ next: union(null, ref Node) }) -- the recursive ref is
|
|
60
|
+
// reached *through* a union variant. The union must propagate depth so the
|
|
61
|
+
// guard still bounds recursion.
|
|
62
|
+
const doc: AnyValiDocument = {
|
|
63
|
+
anyvaliVersion: "1.0",
|
|
64
|
+
schemaVersion: "1",
|
|
65
|
+
root: { kind: "ref", ref: "#/definitions/Node" } as any,
|
|
66
|
+
definitions: {
|
|
67
|
+
Node: {
|
|
68
|
+
kind: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
next: {
|
|
71
|
+
kind: "union",
|
|
72
|
+
variants: [{ kind: "null" }, { kind: "ref", ref: "#/definitions/Node" }],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ["next"],
|
|
76
|
+
unknownKeys: "strip",
|
|
77
|
+
} as any,
|
|
78
|
+
},
|
|
79
|
+
extensions: {},
|
|
80
|
+
} as AnyValiDocument;
|
|
81
|
+
const schema = importSchema(doc);
|
|
82
|
+
|
|
83
|
+
let cur: any = { next: null };
|
|
84
|
+
for (let i = 0; i < 100_000; i++) cur = { next: cur };
|
|
85
|
+
|
|
86
|
+
let result: ReturnType<typeof schema.safeParse>;
|
|
87
|
+
expect(() => {
|
|
88
|
+
result = schema.safeParse(cur);
|
|
89
|
+
}).not.toThrow();
|
|
90
|
+
expect(result!.success).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("importSchema must not stack-overflow on a deeply nested schema doc", () => {
|
|
94
|
+
const doc = deepArraySchemaDoc(100_000);
|
|
95
|
+
// Should fail in a controlled way, not throw RangeError (stack overflow).
|
|
96
|
+
let err: unknown;
|
|
97
|
+
try {
|
|
98
|
+
importSchema(doc);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
err = e;
|
|
101
|
+
}
|
|
102
|
+
expect(err).toBeInstanceOf(Error);
|
|
103
|
+
expect((err as Error) instanceof RangeError).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|