tempest-react-sdk 0.80.0 → 0.82.0
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/README.md +40 -10
- package/bin/create-tempest-app.e2e.test.mjs +31 -0
- package/bin/create-tempest-app.mjs +11 -1
- package/bin/lib/component/generate.test.mjs +13 -1
- package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
- package/bin/lib/doctor/vitest.mjs +120 -0
- package/bin/lib/doctor/vitest.test.mjs +139 -0
- package/bin/lib/fs/contain.mjs +7 -0
- package/bin/lib/openapi/escape.mjs +68 -4
- package/bin/lib/openapi/generate.hostile.test.mjs +150 -1
- package/bin/lib/openapi/generate.mjs +78 -13
- package/bin/lib/openapi/generate.test.mjs +13 -1
- package/bin/lib/openapi/pattern-risk.mjs +134 -0
- package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
- package/bin/tempest-gen.test.mjs +100 -0
- package/bin/tempest.mjs +24 -8
- package/dist/access/use-can.cjs +1 -1
- package/dist/access/use-can.cjs.map +1 -1
- package/dist/access/use-can.js +25 -28
- package/dist/access/use-can.js.map +1 -1
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +234 -122
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/session-identity.cjs +2 -0
- package/dist/auth/session-identity.cjs.map +1 -0
- package/dist/auth/session-identity.js +12 -0
- package/dist/auth/session-identity.js.map +1 -0
- package/dist/br/boleto.cjs +1 -1
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js +2 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/br.d.ts +22 -3
- package/dist/components/AuthForm/AuthFieldControl.cjs +2 -0
- package/dist/components/AuthForm/AuthFieldControl.cjs.map +1 -0
- package/dist/components/AuthForm/AuthFieldControl.js +85 -0
- package/dist/components/AuthForm/AuthFieldControl.js.map +1 -0
- package/dist/components/AuthForm/AuthForm.cjs +2 -0
- package/dist/components/AuthForm/AuthForm.cjs.map +1 -0
- package/dist/components/AuthForm/AuthForm.js +83 -0
- package/dist/components/AuthForm/AuthForm.js.map +1 -0
- package/dist/components/AuthForm/AuthForm.module.cjs +3 -0
- package/dist/components/AuthForm/AuthForm.module.cjs.map +1 -0
- package/dist/components/AuthForm/AuthForm.module.js +14 -0
- package/dist/components/AuthForm/AuthForm.module.js.map +1 -0
- package/dist/components/AuthForm/AuthLinks.cjs +2 -0
- package/dist/components/AuthForm/AuthLinks.cjs.map +1 -0
- package/dist/components/AuthForm/AuthLinks.js +32 -0
- package/dist/components/AuthForm/AuthLinks.js.map +1 -0
- package/dist/components/AuthForm/ForgotPasswordForm.cjs +2 -0
- package/dist/components/AuthForm/ForgotPasswordForm.cjs.map +1 -0
- package/dist/components/AuthForm/ForgotPasswordForm.js +32 -0
- package/dist/components/AuthForm/ForgotPasswordForm.js.map +1 -0
- package/dist/components/AuthForm/LoginForm.cjs +2 -0
- package/dist/components/AuthForm/LoginForm.cjs.map +1 -0
- package/dist/components/AuthForm/LoginForm.js +39 -0
- package/dist/components/AuthForm/LoginForm.js.map +1 -0
- package/dist/components/AuthForm/ResetPasswordForm.cjs +2 -0
- package/dist/components/AuthForm/ResetPasswordForm.cjs.map +1 -0
- package/dist/components/AuthForm/ResetPasswordForm.js +39 -0
- package/dist/components/AuthForm/ResetPasswordForm.js.map +1 -0
- package/dist/components/AuthForm/SignUpForm.cjs +2 -0
- package/dist/components/AuthForm/SignUpForm.cjs.map +1 -0
- package/dist/components/AuthForm/SignUpForm.js +32 -0
- package/dist/components/AuthForm/SignUpForm.js.map +1 -0
- package/dist/components/AuthForm/auth-defaults.cjs +2 -0
- package/dist/components/AuthForm/auth-defaults.cjs.map +1 -0
- package/dist/components/AuthForm/auth-defaults.js +54 -0
- package/dist/components/AuthForm/auth-defaults.js.map +1 -0
- package/dist/components/AuthForm/auth-schemas.cjs +2 -0
- package/dist/components/AuthForm/auth-schemas.cjs.map +1 -0
- package/dist/components/AuthForm/auth-schemas.js +26 -0
- package/dist/components/AuthForm/auth-schemas.js.map +1 -0
- package/dist/components/AuthPage/AuthPage.cjs +2 -0
- package/dist/components/AuthPage/AuthPage.cjs.map +1 -0
- package/dist/components/AuthPage/AuthPage.js +97 -0
- package/dist/components/AuthPage/AuthPage.js.map +1 -0
- package/dist/components/AuthPage/AuthPage.module.cjs +3 -0
- package/dist/components/AuthPage/AuthPage.module.cjs.map +1 -0
- package/dist/components/AuthPage/AuthPage.module.js +24 -0
- package/dist/components/AuthPage/AuthPage.module.js.map +1 -0
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
- package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
- package/dist/components/Button/Button.cjs +1 -1
- package/dist/components/Button/Button.cjs.map +1 -1
- package/dist/components/Button/Button.js +41 -40
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +38 -38
- package/dist/components/FilterBar/filter-apply.cjs +1 -1
- package/dist/components/FilterBar/filter-apply.cjs.map +1 -1
- package/dist/components/FilterBar/filter-apply.js +7 -7
- package/dist/components/Form/Form.cjs +1 -1
- package/dist/components/Form/Form.cjs.map +1 -1
- package/dist/components/Form/Form.js +30 -32
- package/dist/components/Form/Form.js.map +1 -1
- package/dist/components/Layout/Layout.cjs +1 -1
- package/dist/components/Layout/Layout.cjs.map +1 -1
- package/dist/components/Layout/Layout.js +25 -24
- package/dist/components/Layout/Layout.js.map +1 -1
- package/dist/components/Layout/gap.cjs +2 -0
- package/dist/components/Layout/gap.cjs.map +1 -0
- package/dist/components/Layout/gap.js +15 -0
- package/dist/components/Layout/gap.js.map +1 -0
- package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
- package/dist/components/Sidebar/Sidebar.js.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.js +30 -29
- package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +1 -2
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/forms/index.js +7 -7
- package/dist/forms/masked-inputs.cjs +1 -1
- package/dist/forms/masked-inputs.cjs.map +1 -1
- package/dist/forms/masked-inputs.js +15 -15
- package/dist/forms/validate-form.cjs +1 -1
- package/dist/forms/validate-form.cjs.map +1 -1
- package/dist/forms/validate-form.js +6 -1
- package/dist/forms/validate-form.js.map +1 -1
- package/dist/forms/zod-resolver.cjs +1 -1
- package/dist/forms/zod-resolver.cjs.map +1 -1
- package/dist/forms/zod-resolver.js +8 -3
- package/dist/forms/zod-resolver.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +137 -116
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/credential-scope.cjs +1 -1
- package/dist/http/credential-scope.cjs.map +1 -1
- package/dist/http/credential-scope.js +33 -9
- package/dist/http/credential-scope.js.map +1 -1
- package/dist/http/presigned-post.cjs.map +1 -1
- package/dist/http/presigned-post.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +140 -134
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js +17 -17
- package/dist/http/upload-with-progress.js.map +1 -1
- package/dist/http/use-describe-api-error.cjs +1 -1
- package/dist/http/use-describe-api-error.cjs.map +1 -1
- package/dist/http/use-describe-api-error.js +9 -9
- package/dist/oauth/GoogleSignIn.cjs +1 -1
- package/dist/oauth/GoogleSignIn.cjs.map +1 -1
- package/dist/oauth/GoogleSignIn.js +5 -4
- package/dist/oauth/GoogleSignIn.js.map +1 -1
- package/dist/offline/create-offline-sync.cjs +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js +108 -68
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +54 -44
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +48 -41
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/sse/ticket-event-stream.cjs +1 -1
- package/dist/sse/ticket-event-stream.cjs.map +1 -1
- package/dist/sse/ticket-event-stream.js +48 -19
- package/dist/sse/ticket-event-stream.js.map +1 -1
- package/dist/sse/use-event-stream.cjs +1 -1
- package/dist/sse/use-event-stream.cjs.map +1 -1
- package/dist/sse/use-event-stream.js +19 -14
- package/dist/sse/use-event-stream.js.map +1 -1
- package/dist/styles/AuthForm.css +2 -0
- package/dist/styles/AuthPage.css +2 -0
- package/dist/styles/component/AuthForm.css +9 -0
- package/dist/styles/component/AuthPage.css +29 -0
- package/dist/styles/forms.css +36 -0
- package/dist/styles/manifest.json +63 -0
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +132 -112
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +36 -30
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/create-push-handler.cjs +1 -1
- package/dist/sw/create-push-handler.cjs.map +1 -1
- package/dist/sw/create-push-handler.js +20 -10
- package/dist/sw/create-push-handler.js.map +1 -1
- package/dist/sw.d.ts +93 -28
- package/dist/tabular/compact.cjs +1 -1
- package/dist/tabular/compact.cjs.map +1 -1
- package/dist/tabular/compact.js +35 -16
- package/dist/tabular/compact.js.map +1 -1
- package/dist/tabular/manifest.cjs +1 -1
- package/dist/tabular/manifest.cjs.map +1 -1
- package/dist/tabular/manifest.js +33 -15
- package/dist/tabular/manifest.js.map +1 -1
- package/dist/tabular.d.ts +11 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +1057 -83
- package/dist/tempest-react-sdk.js +373 -365
- package/dist/theme/initial-theme.cjs +4 -4
- package/dist/theme/initial-theme.cjs.map +1 -1
- package/dist/theme/initial-theme.js +8 -5
- package/dist/theme/initial-theme.js.map +1 -1
- package/dist/utils/compressed-storage.cjs +1 -1
- package/dist/utils/compressed-storage.cjs.map +1 -1
- package/dist/utils/compressed-storage.js +13 -7
- package/dist/utils/compressed-storage.js.map +1 -1
- package/dist/utils/format.cjs +1 -1
- package/dist/utils/format.cjs.map +1 -1
- package/dist/utils/format.js +25 -25
- package/dist/utils/numbers.cjs +1 -1
- package/dist/utils/numbers.cjs.map +1 -1
- package/dist/utils/numbers.js +7 -7
- package/dist/utils/objects.cjs +1 -1
- package/dist/utils/objects.cjs.map +1 -1
- package/dist/utils/objects.js +13 -7
- package/dist/utils/objects.js.map +1 -1
- package/dist/utils/safe-url.cjs +1 -1
- package/dist/utils/safe-url.cjs.map +1 -1
- package/dist/utils/safe-url.js +4 -1
- package/dist/utils/safe-url.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +7 -1
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +77 -61
- package/dist/ws/create-web-socket.js.map +1 -1
- package/dist/ws/use-web-socket.cjs +1 -1
- package/dist/ws/use-web-socket.cjs.map +1 -1
- package/dist/ws/use-web-socket.js +4 -3
- package/dist/ws/use-web-socket.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +42 -0
- package/template-pwa/src/sw.ts +15 -10
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
* {@link assertNoCollision}, never silently merged;
|
|
17
17
|
* - string position → `JSON.stringify` at the call site;
|
|
18
18
|
* - doc comment → {@link commentText};
|
|
19
|
-
* - URL template literal → {@link urlTemplate}
|
|
19
|
+
* - URL template literal → {@link urlTemplate};
|
|
20
|
+
* - the developer's terminal → {@link terminalText}.
|
|
21
|
+
*
|
|
22
|
+
* One more check covers the text that becomes a URL rather than being escaped
|
|
23
|
+
* into one: {@link assertSafePath}.
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
@@ -150,19 +154,79 @@ export function assertNoCollision(entries, what) {
|
|
|
150
154
|
}
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
/**
|
|
158
|
+
* C0 and C1 control characters except tab and line feed: everything a terminal
|
|
159
|
+
* may read as the start of an escape sequence (`ESC`, the 8-bit `CSI` and
|
|
160
|
+
* `OSC`), plus `CR` and backspace, which rewrite what is already on the line.
|
|
161
|
+
*/
|
|
162
|
+
const TERMINAL_CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Make spec text safe to print to the developer's terminal.
|
|
166
|
+
*
|
|
167
|
+
* `tempest gen api` echoes the spec's tag names, and its errors quote spec
|
|
168
|
+
* names. `JSON.stringify` escapes the C0 range but leaves the C1 range raw, so
|
|
169
|
+
* neither path was safe on its own: a tag carrying `ESC ] 0 ; ... BEL` retitled
|
|
170
|
+
* the terminal window, and an OSC 8 sequence printed a clickable link whose
|
|
171
|
+
* target the developer never saw (#533). Every control character a terminal
|
|
172
|
+
* acts on is dropped; tab and line feed are kept so a multi-line error still
|
|
173
|
+
* reads as one.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} text - Text that may come from the spec.
|
|
176
|
+
* @returns {string} The text without terminal control characters.
|
|
177
|
+
*/
|
|
178
|
+
export function terminalText(text) {
|
|
179
|
+
return String(text).replace(TERMINAL_CONTROLS, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
153
182
|
/**
|
|
154
183
|
* Make spec text safe inside a `/** ... *\/` block comment.
|
|
155
184
|
*
|
|
156
185
|
* Line terminators collapse to a space, so the text cannot start a new line of
|
|
157
186
|
* the comment, and every `*\/` is broken as `*\\/` so it cannot close it.
|
|
187
|
+
* Terminal control characters are dropped too ({@link terminalText}): they are
|
|
188
|
+
* not code, but printing the generated file with `cat` would replay them.
|
|
158
189
|
*
|
|
159
190
|
* @param {unknown} text - The spec text (tag, summary, path).
|
|
160
191
|
* @returns {string} The neutralized single-line text.
|
|
161
192
|
*/
|
|
162
193
|
export function commentText(text) {
|
|
163
|
-
return String(text)
|
|
164
|
-
|
|
165
|
-
|
|
194
|
+
return terminalText(String(text).replace(/[\r\n\u2028\u2029]+/g, " ")).replace(/\*\//g, "*\\/");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Throw when an OpenAPI path key would not stay a path under the client's
|
|
199
|
+
* `baseURL`.
|
|
200
|
+
*
|
|
201
|
+
* The key becomes the URL the generated method requests, and the client passes
|
|
202
|
+
* an absolute URL through untouched: a key `"https://other.example/x"` made the
|
|
203
|
+
* method call another origin \u2014 the Bearer is withheld there, but the body and,
|
|
204
|
+
* with `credentials: "include"`, the cookies were not. A static `.` or `..`
|
|
205
|
+
* segment (`"/../x"`) climbs out of the `baseURL`'s path prefix once the URL is
|
|
206
|
+
* normalized. OpenAPI requires a path key to start with `/` and to be relative
|
|
207
|
+
* to the server URL, so neither is a valid spec; both are refused before any
|
|
208
|
+
* file is written instead of becoming a call the developer never reads. `\` is
|
|
209
|
+
* a separator and `%2e` a dot here because the WHATWG URL parser normalizes them
|
|
210
|
+
* that way.
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} path - The key from `paths`.
|
|
213
|
+
* @throws {Error} When the key does not start with `/` or has a dot segment.
|
|
214
|
+
*/
|
|
215
|
+
export function assertSafePath(path) {
|
|
216
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`OpenAPI path ${JSON.stringify(path)} does not start with "/". A path key is relative to the server URL; an absolute URL here would send the request to another origin.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const dotSegment = path
|
|
222
|
+
.replace(/[?#][\s\S]*$/, "")
|
|
223
|
+
.split(/[/\\]/)
|
|
224
|
+
.some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment));
|
|
225
|
+
if (dotSegment) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`OpenAPI path ${JSON.stringify(path)} has a "." or ".." segment, which leaves the baseURL's path once the URL is normalized. Rename the path in the spec.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
166
230
|
}
|
|
167
231
|
|
|
168
232
|
/**
|
|
@@ -4,11 +4,23 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import vm from "node:vm";
|
|
5
5
|
|
|
6
6
|
import ts from "typescript";
|
|
7
|
-
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
8
|
|
|
9
9
|
import { encodeIdSegment } from "../../../src/data";
|
|
10
10
|
import { generate } from "./generate.mjs";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Gives the tests in this file 60 s instead of Vitest's 5 s default.
|
|
14
|
+
*
|
|
15
|
+
* Several of them build a TypeScript program (and, some, an ESLint instance)
|
|
16
|
+
* in-process over the generated files. That takes 3-5 s on an idle machine,
|
|
17
|
+
* right at the default, so a busy one — another suite, a model training — failed
|
|
18
|
+
* them by timeout while the code was correct: 5.1-5.7 s measured in three
|
|
19
|
+
* `make check` runs on 2026-11-01, load average between 21 and 48.
|
|
20
|
+
* `test/docs-guard.test.ts` gives its compile 120 s for the same reason.
|
|
21
|
+
*/
|
|
22
|
+
vi.setConfig({ testTimeout: 60_000 });
|
|
23
|
+
|
|
12
24
|
const require = createRequire(import.meta.url);
|
|
13
25
|
/**
|
|
14
26
|
* Transpile one generated TypeScript file and run it in a fresh VM context.
|
|
@@ -660,3 +672,140 @@ describe("generate — names that collide with what the generator itself declare
|
|
|
660
672
|
);
|
|
661
673
|
});
|
|
662
674
|
});
|
|
675
|
+
|
|
676
|
+
describe("generate — spec text that would leave its place in the output (#533)", () => {
|
|
677
|
+
const ESC_TAG = "items\u001b]0;PWNED\u0007\u009b31m\r";
|
|
678
|
+
const POST_BODY = {
|
|
679
|
+
"/a": { post: { tags: ["t"], requestBody: JSON_BODY("Body"), responses: OK } },
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
it("a path key that is an absolute URL is refused, so no method calls another origin", () => {
|
|
683
|
+
expect(() =>
|
|
684
|
+
generate(spec({ "https://other.example/x": { get: { tags: ["t"], responses: OK } } })),
|
|
685
|
+
).toThrow(/path "https:\/\/other\.example\/x" does not start with "\/"/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it.each(["/../x", "/a/./b", "/a/..", "/%2e%2E/x", "/a\\..\\x", "/./x?y=1"])(
|
|
689
|
+
"a path key with a dot segment (%s) is refused, so it cannot climb out of the baseURL path",
|
|
690
|
+
(path) => {
|
|
691
|
+
expect(() =>
|
|
692
|
+
generate(spec({ [path]: { get: { tags: ["t"], responses: OK } } })),
|
|
693
|
+
).toThrow(/has a "\." or "\.\." segment/);
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
it("dots inside a segment, dots in the query and a doubled slash still generate", () => {
|
|
698
|
+
const { files } = generate(
|
|
699
|
+
spec({
|
|
700
|
+
"/v1.2/..cache/file.json": { get: { tags: ["t"], responses: OK } },
|
|
701
|
+
"//double/slash": { get: { tags: ["t"], responses: OK } },
|
|
702
|
+
"/search?q=..": { get: { tags: ["t"], responses: OK } },
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
expect(files["t/service.ts"]).toContain('"/v1.2/..cache/file.json"');
|
|
706
|
+
expect(files["t/service.ts"]).toContain('"/search?q=.."');
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("a property named __proto__ is refused instead of silently vanishing from the Zod shape", () => {
|
|
710
|
+
const Body = JSON.parse(
|
|
711
|
+
'{"type":"object","required":["__proto__"],"properties":{"__proto__":{"type":"string"}}}',
|
|
712
|
+
);
|
|
713
|
+
expect(() => generate(spec(POST_BODY, { Body }))).toThrow(
|
|
714
|
+
/schema "Body" at \(root\): a property named "__proto__" cannot be validated/,
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("a default value with a nested __proto__ key is refused", () => {
|
|
719
|
+
const Body = JSON.parse(
|
|
720
|
+
'{"type":"object","properties":{"meta":{"type":"object","default":{"a":{"__proto__":{"admin":true}}}}}}',
|
|
721
|
+
);
|
|
722
|
+
expect(() => generate(spec(POST_BODY, { Body }))).toThrow(
|
|
723
|
+
/schema "Body" at properties\.meta: the default value has a "__proto__" key/,
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("properties called constructor or prototype still generate and are validated", () => {
|
|
728
|
+
const { files } = generate(
|
|
729
|
+
spec(POST_BODY, {
|
|
730
|
+
Body: {
|
|
731
|
+
type: "object",
|
|
732
|
+
required: ["constructor"],
|
|
733
|
+
properties: {
|
|
734
|
+
constructor: { type: "string" },
|
|
735
|
+
prototype: { type: "string" },
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
}),
|
|
739
|
+
);
|
|
740
|
+
const schemas = evaluate(files["t/schemas.ts"], vm.createContext({}), {});
|
|
741
|
+
expect(schemas.BodySchema.safeParse({ constructor: 1 }).success).toBe(false);
|
|
742
|
+
expect(schemas.BodySchema.safeParse({ constructor: "x" }).success).toBe(true);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("a nested-quantifier pattern still generates and comes back as a warning naming the schema", () => {
|
|
746
|
+
const { files, warnings } = generate(
|
|
747
|
+
spec(POST_BODY, {
|
|
748
|
+
Body: {
|
|
749
|
+
type: "object",
|
|
750
|
+
properties: {
|
|
751
|
+
slug: { type: "string", pattern: "^([a-z0-9]+-)*[a-z0-9]+$" },
|
|
752
|
+
evil: { type: "string", pattern: "^(a+)+$" },
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
}),
|
|
756
|
+
);
|
|
757
|
+
expect(files["t/schemas.ts"]).toContain('new RegExp("^(a+)+$")');
|
|
758
|
+
expect(warnings).toHaveLength(1);
|
|
759
|
+
expect(warnings[0]).toMatch(/schema "Body" at properties\.evil: pattern "\^\(a\+\)\+\$"/);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("terminal control characters in a tag do not reach the generated doc comment", () => {
|
|
763
|
+
const { files, tags } = generate(
|
|
764
|
+
spec({ "/a": { get: { tags: [ESC_TAG], responses: OK } } }),
|
|
765
|
+
);
|
|
766
|
+
const service = Object.entries(files).find(([f]) => f.endsWith("/service.ts"))[1];
|
|
767
|
+
expect(tags).toEqual([ESC_TAG]);
|
|
768
|
+
expect(service).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/);
|
|
769
|
+
expect(service).toContain('the "items]0;PWNED31m " routes');
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("the folder and class reported for each group are the ones written", () => {
|
|
773
|
+
const { files, services } = generate(
|
|
774
|
+
spec({ "/a": { get: { tags: ["Items API"], responses: OK } } }),
|
|
775
|
+
);
|
|
776
|
+
expect(services).toEqual([{ slug: "items-api", className: "ItemsApiService" }]);
|
|
777
|
+
expect(files["items-api/service.ts"]).toContain("export class ItemsApiService");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("the output for a spec with control characters and a flagged pattern typechecks", () => {
|
|
781
|
+
const { files } = generate(
|
|
782
|
+
spec(
|
|
783
|
+
{
|
|
784
|
+
"/a/{id}": {
|
|
785
|
+
post: {
|
|
786
|
+
tags: ["t"],
|
|
787
|
+
summary: "x\u001b[2J\u009d8;;https://evil/\u009c",
|
|
788
|
+
parameters: [
|
|
789
|
+
{
|
|
790
|
+
name: "id",
|
|
791
|
+
in: "path",
|
|
792
|
+
required: true,
|
|
793
|
+
schema: { type: "string" },
|
|
794
|
+
},
|
|
795
|
+
],
|
|
796
|
+
requestBody: JSON_BODY("Body"),
|
|
797
|
+
responses: OK,
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
Body: {
|
|
803
|
+
type: "object",
|
|
804
|
+
properties: { evil: { type: "string", pattern: "(\\w+\\s?)*" } },
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
),
|
|
808
|
+
);
|
|
809
|
+
expect(typecheck(files, "t")).toEqual([]);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// OpenAPI 3.x → per-tag { schemas.ts (Zod), types.ts, service.ts (class) }.
|
|
2
2
|
// Pure: takes the parsed spec object, returns a { path: contents } map. No I/O.
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assertNoCollision,
|
|
5
|
+
assertSafePath,
|
|
6
|
+
commentText,
|
|
7
|
+
toIdentifier,
|
|
8
|
+
urlTemplate,
|
|
9
|
+
} from "./escape.mjs";
|
|
10
|
+
import { hasNestedQuantifier } from "./pattern-risk.mjs";
|
|
4
11
|
import { refName, schemaToZod, typeName, zodName } from "./schema-to-zod.mjs";
|
|
5
12
|
|
|
6
13
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
@@ -157,30 +164,77 @@ function isSegmentScalar(schema, schemas, depth = 0) {
|
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
/**
|
|
160
|
-
*
|
|
167
|
+
* Whether a JSON value has an own key `__proto__` at any depth.
|
|
161
168
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
169
|
+
* @param {unknown} value - A value parsed from the spec.
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function hasProtoKey(value) {
|
|
173
|
+
if (!value || typeof value !== "object") return false;
|
|
174
|
+
if (Object.hasOwn(value, "__proto__")) return true;
|
|
175
|
+
return Object.values(value).some(hasProtoKey);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check a component schema before it is turned into Zod source.
|
|
180
|
+
*
|
|
181
|
+
* - Every `pattern` must compile as a JavaScript regex. The generated schema
|
|
182
|
+
* builds it with `new RegExp(...)` when the module is imported, so an invalid
|
|
183
|
+
* one would only surface in the app at runtime — a blank screen.
|
|
184
|
+
* - A `pattern` with the nested-quantifier shape ({@link hasNestedQuantifier})
|
|
185
|
+
* is pushed to `warnings`: it compiles, and it can freeze the tab on one
|
|
186
|
+
* crafted value, but a heuristic does not get to fail the run.
|
|
187
|
+
* - A property, or a key inside a `default`, named `__proto__` is refused. The
|
|
188
|
+
* generated source is an object literal, where `"__proto__": ...` sets the
|
|
189
|
+
* prototype instead of adding a key: the field vanished from the Zod shape and
|
|
190
|
+
* the value went unvalidated, and Zod 4 drops the key even when it is written
|
|
191
|
+
* as a computed `["__proto__"]` (measured on 4.6.7: a required `__proto__:
|
|
192
|
+
* z.string()` accepted `{}` and `5`). There is no source that validates it, so
|
|
193
|
+
* the spec is refused (#533).
|
|
194
|
+
*
|
|
195
|
+
* Every failure names the schema and where in it the problem sits.
|
|
165
196
|
*
|
|
166
197
|
* @param {unknown} node - The schema (sub)node.
|
|
167
|
-
* @param {string} schema - The component schema name, for the
|
|
198
|
+
* @param {string} schema - The component schema name, for the messages.
|
|
199
|
+
* @param {string[]} warnings - Collects the non-fatal findings.
|
|
168
200
|
* @param {string[]} [at] - Keys from the schema root to `node`.
|
|
169
|
-
* @throws {Error} When a pattern does not compile
|
|
201
|
+
* @throws {Error} When a pattern does not compile, or a `__proto__` key would be
|
|
202
|
+
* emitted.
|
|
170
203
|
*/
|
|
171
|
-
function
|
|
204
|
+
function checkSchema(node, schema, warnings, at = []) {
|
|
172
205
|
if (!node || typeof node !== "object") return;
|
|
206
|
+
const where = `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}`;
|
|
173
207
|
if (typeof node.pattern === "string") {
|
|
174
208
|
try {
|
|
175
209
|
new RegExp(node.pattern);
|
|
176
210
|
} catch (err) {
|
|
177
211
|
throw new Error(
|
|
178
|
-
|
|
212
|
+
`${where}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
|
|
179
213
|
);
|
|
180
214
|
}
|
|
215
|
+
if (hasNestedQuantifier(node.pattern)) {
|
|
216
|
+
warnings.push(
|
|
217
|
+
`${where}: pattern ${JSON.stringify(node.pattern)} repeats a group that ends in a repetition; it can backtrack exponentially on a near-miss value and freeze the tab that validates it.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
node.properties &&
|
|
223
|
+
typeof node.properties === "object" &&
|
|
224
|
+
Object.hasOwn(node.properties, "__proto__")
|
|
225
|
+
) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`${where}: a property named "__proto__" cannot be validated — in the generated object literal it sets the prototype instead of declaring the field. Rename it in the spec.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (hasProtoKey(node.default)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${where}: the default value has a "__proto__" key, which the generated object literal would turn into its prototype. Remove it from the spec.`,
|
|
233
|
+
);
|
|
181
234
|
}
|
|
182
235
|
for (const [key, value] of Object.entries(node)) {
|
|
183
|
-
if (key !== "pattern"
|
|
236
|
+
if (key !== "pattern" && key !== "default")
|
|
237
|
+
checkSchema(value, schema, warnings, [...at, key]);
|
|
184
238
|
}
|
|
185
239
|
}
|
|
186
240
|
|
|
@@ -432,8 +486,15 @@ function compareNames(a, b) {
|
|
|
432
486
|
* because a browser `fetch` cannot set `Cookie` — the browser sends its own
|
|
433
487
|
* cookies with `credentials`, which is how a cookie parameter reaches the server.
|
|
434
488
|
*
|
|
489
|
+
* Every path key that has an operation goes through {@link assertSafePath}, and
|
|
490
|
+
* every emitted component schema through {@link checkSchema}: spec text that
|
|
491
|
+
* would turn into a call to another origin, or into an object literal whose
|
|
492
|
+
* prototype the spec picks, fails the run before anything is written (#533).
|
|
493
|
+
*
|
|
435
494
|
* @param {object} doc - The parsed OpenAPI 3.x spec.
|
|
436
|
-
* @returns {{ files: Record<string, string>, tags: string[] }}
|
|
495
|
+
* @returns {{ files: Record<string, string>, tags: string[], services: { slug: string, className: string }[], warnings: string[] }}
|
|
496
|
+
* The files by relative path; each group's spec tag, folder and service class
|
|
497
|
+
* (in the same order); and the non-fatal findings the CLI prints.
|
|
437
498
|
*/
|
|
438
499
|
export function generate(doc) {
|
|
439
500
|
const schemas = doc.components?.schemas ?? {};
|
|
@@ -444,6 +505,7 @@ export function generate(doc) {
|
|
|
444
505
|
for (const method of HTTP_METHODS) {
|
|
445
506
|
const op = item[method];
|
|
446
507
|
if (!op) continue;
|
|
508
|
+
assertSafePath(path);
|
|
447
509
|
const tag = op.tags?.[0] ?? "default";
|
|
448
510
|
const slug = tagSlug(tag);
|
|
449
511
|
if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
|
|
@@ -459,10 +521,13 @@ export function generate(doc) {
|
|
|
459
521
|
|
|
460
522
|
const files = {};
|
|
461
523
|
const tags = [];
|
|
524
|
+
const services = [];
|
|
525
|
+
const warnings = [];
|
|
462
526
|
|
|
463
527
|
for (const { tag, slug, ops } of groups.values()) {
|
|
464
528
|
tags.push(tag);
|
|
465
529
|
const Class = `${slugIdent(pascal(slug))}Service`;
|
|
530
|
+
services.push({ slug, className: Class });
|
|
466
531
|
|
|
467
532
|
const usedNames = new Set(CLASS_MEMBERS);
|
|
468
533
|
const signature = { refs: new Set(), validates: false, sendsHeaders: false };
|
|
@@ -483,7 +548,7 @@ export function generate(doc) {
|
|
|
483
548
|
sorted.map((n) => [n, typeName(n)]),
|
|
484
549
|
"schema names",
|
|
485
550
|
);
|
|
486
|
-
for (const name of sorted)
|
|
551
|
+
for (const name of sorted) checkSchema(schemas[name], name, warnings);
|
|
487
552
|
|
|
488
553
|
const schemaLines = sorted.map((name) => {
|
|
489
554
|
const expr = schemaToZod(schemas[name], (ref) => {
|
|
@@ -542,7 +607,7 @@ export function generate(doc) {
|
|
|
542
607
|
.map((slug) => `export * as ${slugIdent(slug)} from "./${slug}";`)
|
|
543
608
|
.join("\n") + "\n";
|
|
544
609
|
|
|
545
|
-
return { files, tags };
|
|
610
|
+
return { files, tags, services, warnings };
|
|
546
611
|
}
|
|
547
612
|
|
|
548
613
|
/**
|
|
@@ -4,11 +4,23 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
|
|
5
5
|
import { ESLint } from "eslint";
|
|
6
6
|
import ts from "typescript";
|
|
7
|
-
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
8
|
|
|
9
9
|
import { encodeIdSegment } from "../../../src/data";
|
|
10
10
|
import { generate } from "./generate.mjs";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Gives the tests in this file 60 s instead of Vitest's 5 s default.
|
|
14
|
+
*
|
|
15
|
+
* Several of them build a TypeScript program (and, some, an ESLint instance)
|
|
16
|
+
* in-process over the generated files. That takes 3-5 s on an idle machine,
|
|
17
|
+
* right at the default, so a busy one — another suite, a model training — failed
|
|
18
|
+
* them by timeout while the code was correct: 5.1-5.7 s measured in three
|
|
19
|
+
* `make check` runs on 2026-11-01, load average between 21 and 48.
|
|
20
|
+
* `test/docs-guard.test.ts` gives its compile 120 s for the same reason.
|
|
21
|
+
*/
|
|
22
|
+
vi.setConfig({ testTimeout: 60_000 });
|
|
23
|
+
|
|
12
24
|
const SPEC = {
|
|
13
25
|
openapi: "3.1.0",
|
|
14
26
|
components: {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static check for the regular expressions a spec's `pattern` keywords become.
|
|
3
|
+
*
|
|
4
|
+
* The generated Zod schema compiles each `pattern` with `new RegExp(...)` and
|
|
5
|
+
* runs it on every value the app validates. V8 backtracks, so a pattern with a
|
|
6
|
+
* repeated group whose body can itself match a variable amount — `^(a+)+$` — is
|
|
7
|
+
* exponential on a near-miss input: 28 characters took 873 ms and every extra
|
|
8
|
+
* one doubles it (Node 24.15, `/^(a+)+$/.test("a".repeat(28) + "!")`). The app
|
|
9
|
+
* tab freezes on its own data. A spec is input the developer does not control,
|
|
10
|
+
* so `tempest gen api` flags such a pattern when it generates the schema (#533).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the quantifier starting at `pattern[i]`, if any.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} pattern - The regex source.
|
|
17
|
+
* @param {number} i - Index right after an atom.
|
|
18
|
+
* @returns {{ min: number, max: number, length: number } | null} The bounds
|
|
19
|
+
* (`max` is `Infinity` when unbounded) and how many characters the quantifier
|
|
20
|
+
* spans, its lazy `?` included; `null` when no quantifier starts there.
|
|
21
|
+
*/
|
|
22
|
+
function quantifierAt(pattern, i) {
|
|
23
|
+
const ch = pattern[i];
|
|
24
|
+
let q = null;
|
|
25
|
+
if (ch === "*") q = { min: 0, max: Infinity, length: 1 };
|
|
26
|
+
else if (ch === "+") q = { min: 1, max: Infinity, length: 1 };
|
|
27
|
+
else if (ch === "?") q = { min: 0, max: 1, length: 1 };
|
|
28
|
+
else if (ch === "{") {
|
|
29
|
+
const m = /^\{(\d+)(,(\d*))?\}/.exec(pattern.slice(i));
|
|
30
|
+
if (m) {
|
|
31
|
+
const min = Number(m[1]);
|
|
32
|
+
const max = m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
|
|
33
|
+
q = { min, max, length: m[0].length };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (q && pattern[i + q.length] === "?") q.length += 1;
|
|
37
|
+
return q;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Index right after the character class that opens at `pattern[i]` (`[`).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} pattern - The regex source.
|
|
44
|
+
* @param {number} i - Index of the `[`.
|
|
45
|
+
* @returns {number} Index after the closing `]`, or the pattern's length.
|
|
46
|
+
*/
|
|
47
|
+
function classEnd(pattern, i) {
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1;
|
|
50
|
+
return j + 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Length of a group's opening syntax at `pattern[i]` (`(`, `(?:`, `(?=`,
|
|
55
|
+
* `(?<name>`, ...), so its `?` is not read as a quantifier.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} pattern - The regex source.
|
|
58
|
+
* @param {number} i - Index of the `(`.
|
|
59
|
+
* @returns {number} Characters to skip.
|
|
60
|
+
*/
|
|
61
|
+
function groupOpenLength(pattern, i) {
|
|
62
|
+
if (pattern[i + 1] !== "?") return 1;
|
|
63
|
+
if (pattern[i + 2] === "<" && pattern[i + 3] !== "=" && pattern[i + 3] !== "!") {
|
|
64
|
+
const close = pattern.indexOf(">", i);
|
|
65
|
+
return close === -1 ? 1 : close - i + 1;
|
|
66
|
+
}
|
|
67
|
+
return pattern[i + 2] === "<" ? 4 : 3;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a regex source has a repeated group whose body ends in a
|
|
72
|
+
* variable-length repetition — the nested-quantifier shape behind most
|
|
73
|
+
* catastrophic backtracking (`(a+)+`, `(\w+\s?)*`, `((x*))+`).
|
|
74
|
+
*
|
|
75
|
+
* "Ends in" is what keeps the common safe patterns quiet: in
|
|
76
|
+
* `^([a-z0-9]+-)*[a-z0-9]+$` every iteration must end on the mandatory `-`, so
|
|
77
|
+
* the inner `+` cannot trade characters with the next iteration and the group is
|
|
78
|
+
* not flagged; only optional atoms (`?`, `*`, `{0,n}`) may follow the inner
|
|
79
|
+
* repetition for the group to count. Fixed counts (`(\d{3})+`) are not variable
|
|
80
|
+
* and do not count.
|
|
81
|
+
*
|
|
82
|
+
* This is a heuristic for one shape, not a proof of safety: overlapping
|
|
83
|
+
* alternation (`(a|aa)+`) and backreferences are not analysed. A flag is a
|
|
84
|
+
* warning to read the pattern, not a verdict.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} pattern - The regex source from the spec.
|
|
87
|
+
* @returns {boolean} `true` when the nested-quantifier shape is present.
|
|
88
|
+
*/
|
|
89
|
+
export function hasNestedQuantifier(pattern) {
|
|
90
|
+
const frames = [{ tail: false, anyAlternative: false }];
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < pattern.length) {
|
|
93
|
+
const ch = pattern[i];
|
|
94
|
+
const frame = frames[frames.length - 1];
|
|
95
|
+
if (ch === "|") {
|
|
96
|
+
frame.anyAlternative ||= frame.tail;
|
|
97
|
+
frame.tail = false;
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === "(") {
|
|
102
|
+
frames.push({ tail: false, anyAlternative: false });
|
|
103
|
+
i += groupOpenLength(pattern, i);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let groupTail = false;
|
|
107
|
+
let isGroup = false;
|
|
108
|
+
if (ch === ")") {
|
|
109
|
+
if (frames.length === 1) {
|
|
110
|
+
i += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const closed = frames.pop();
|
|
114
|
+
groupTail = closed.anyAlternative || closed.tail;
|
|
115
|
+
isGroup = true;
|
|
116
|
+
i += 1;
|
|
117
|
+
} else if (ch === "[") {
|
|
118
|
+
i = classEnd(pattern, i);
|
|
119
|
+
} else if (ch === "\\") {
|
|
120
|
+
i += 2;
|
|
121
|
+
} else {
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
const outer = frames[frames.length - 1];
|
|
125
|
+
const q = quantifierAt(pattern, i);
|
|
126
|
+
if (q) i += q.length;
|
|
127
|
+
const repeats = q !== null && q.max > 1;
|
|
128
|
+
if (isGroup && groupTail && repeats) return true;
|
|
129
|
+
const variable = repeats && q.max > q.min;
|
|
130
|
+
if (variable || (isGroup && groupTail && !repeats)) outer.tail = true;
|
|
131
|
+
else if (!q || q.min > 0) outer.tail = false;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { hasNestedQuantifier } from "./pattern-risk.mjs";
|
|
4
|
+
|
|
5
|
+
describe("hasNestedQuantifier — flags the repeated-repetition shape (#533)", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
"^(a+)+$",
|
|
8
|
+
"^(a*)*$",
|
|
9
|
+
"(\\w+\\s?)*",
|
|
10
|
+
"^((x+))+$",
|
|
11
|
+
"(?:a+){2,}",
|
|
12
|
+
"(?<word>\\d+)+",
|
|
13
|
+
"^(a|b+)*$",
|
|
14
|
+
"(a{1,5})+",
|
|
15
|
+
])("%s is flagged", (pattern) => {
|
|
16
|
+
expect(hasNestedQuantifier(pattern)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it.each([
|
|
20
|
+
"^[a-z]+$",
|
|
21
|
+
"^([a-z0-9]+-)*[a-z0-9]+$",
|
|
22
|
+
"^(\\d{3})+$",
|
|
23
|
+
"^(ab)+$",
|
|
24
|
+
"^(a+)$",
|
|
25
|
+
"^(a+)?$",
|
|
26
|
+
"[(a+)+]",
|
|
27
|
+
"\\(a+\\)+",
|
|
28
|
+
"^(?=a+)b$",
|
|
29
|
+
"^\\d{5}-?\\d{3}$",
|
|
30
|
+
"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
|
|
31
|
+
")a+(",
|
|
32
|
+
])("%s is not flagged", (pattern) => {
|
|
33
|
+
expect(hasNestedQuantifier(pattern)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("the flagged shape really is exponential in V8, so the warning is not theoretical", () => {
|
|
37
|
+
const time = (n) => {
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
/^(a+)+$/.test(`${"a".repeat(n)}!`);
|
|
40
|
+
return performance.now() - start;
|
|
41
|
+
};
|
|
42
|
+
time(14);
|
|
43
|
+
expect(time(22)).toBeGreaterThan(time(14) * 4);
|
|
44
|
+
});
|
|
45
|
+
});
|