typespec-http-zod 0.26.0 → 0.27.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/dist/src/api.d.ts +21 -0
- package/dist/src/api.js +107 -16
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +233 -10
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/lib.d.ts +32 -3
- package/dist/src/lib.js +53 -0
- package/dist/src/linter.d.ts +15 -0
- package/dist/src/linter.js +23 -0
- package/dist/src/registry.d.ts +1 -1
- package/dist/src/registry.js +135 -36
- package/dist/src/rules/redos-prone-pattern.rule.d.ts +27 -0
- package/dist/src/rules/redos-prone-pattern.rule.js +47 -0
- package/dist/src/zod.d.ts +10 -17
- package/dist/src/zod.js +50 -13
- package/package.json +6 -3
package/dist/src/api.d.ts
CHANGED
|
@@ -83,6 +83,15 @@ export interface EmittedResponseHeader {
|
|
|
83
83
|
readonly name: string;
|
|
84
84
|
/** The TypeScript type of the value, so a server emitter can put it in a signature. */
|
|
85
85
|
readonly type: string;
|
|
86
|
+
/**
|
|
87
|
+
* The Zod expression for the value, so a server can check what it is about to send.
|
|
88
|
+
*
|
|
89
|
+
* The TYPE above constrains what a handler may return at compile time; this constrains what
|
|
90
|
+
* actually goes on the wire, which is the same split `schema` and the contract types already make
|
|
91
|
+
* for a body. A value that crossed a boundary the type system cannot see into - a service binding,
|
|
92
|
+
* a cast - is exactly what it catches.
|
|
93
|
+
*/
|
|
94
|
+
readonly schema: string;
|
|
86
95
|
/** The document's `required: false`. */
|
|
87
96
|
readonly optional: boolean;
|
|
88
97
|
}
|
|
@@ -298,6 +307,8 @@ export interface EmittedRoute {
|
|
|
298
307
|
* request. It read as "the parameters" and was the field an adopter reached for first.
|
|
299
308
|
*/
|
|
300
309
|
readonly pathSchema: string | undefined;
|
|
310
|
+
/** Cookie parameters, as one object schema, or `undefined` where the operation declares none. */
|
|
311
|
+
readonly cookieSchema: string | undefined;
|
|
301
312
|
readonly querySchema: string | undefined;
|
|
302
313
|
/**
|
|
303
314
|
* The query OBJECT itself, which differs from `querySchema` only where a form-exploded record or
|
|
@@ -416,6 +427,11 @@ export interface RouteSchemaNames {
|
|
|
416
427
|
*/
|
|
417
428
|
readonly queryFields: string | undefined;
|
|
418
429
|
readonly header: string | undefined;
|
|
430
|
+
/**
|
|
431
|
+
* Cookie parameters. **Optional on the interface, so a server emitter that does not read it yet
|
|
432
|
+
* still compiles** - this arrived after the other four and two emitters consume this type.
|
|
433
|
+
*/
|
|
434
|
+
readonly cookie?: string | undefined;
|
|
419
435
|
readonly body: string | undefined;
|
|
420
436
|
/**
|
|
421
437
|
* The identifier each response's body is declared under, parallel to `EmittedRoute.responses` and
|
|
@@ -428,6 +444,11 @@ export interface RouteSchemaNames {
|
|
|
428
444
|
readonly arms: readonly {
|
|
429
445
|
readonly status: StatusKey;
|
|
430
446
|
readonly schema: string | undefined;
|
|
447
|
+
/**
|
|
448
|
+
* The identifier holding this status's declared response headers, where it declares any.
|
|
449
|
+
* **Optional on the interface**, so a server emitter that does not check them yet still builds.
|
|
450
|
+
*/
|
|
451
|
+
readonly headers?: string | undefined;
|
|
431
452
|
}[];
|
|
432
453
|
/**
|
|
433
454
|
* The `readonly ResponseArm[]` const - what the operation may answer with, and with which body.
|
package/dist/src/api.js
CHANGED
|
@@ -316,6 +316,14 @@ function responsesOf(program, operation, registry) {
|
|
|
316
316
|
entry.headers.push({
|
|
317
317
|
name,
|
|
318
318
|
type: typeToTs(program, property.type),
|
|
319
|
+
/**
|
|
320
|
+
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter
|
|
321
|
+
* goes through `expressionForProperty` a few hundred lines below; a response header
|
|
322
|
+
* went through `typeToTs` and nothing else, so the emitted arm carried a name and an
|
|
323
|
+
* optionality and no way to check a value. A response body that breaks its schema
|
|
324
|
+
* throws `ResponseContractError`; a header that breaks its own was served.
|
|
325
|
+
*/
|
|
326
|
+
schema: registry.expressionForProperty(property),
|
|
319
327
|
optional: property.optional,
|
|
320
328
|
});
|
|
321
329
|
}
|
|
@@ -968,9 +976,39 @@ function mediaTypeDecoded(declared) {
|
|
|
968
976
|
.map((entry) => entry.trim().replace(/^"|"$/g, ""));
|
|
969
977
|
if (values === undefined || values.length === 0)
|
|
970
978
|
return declared;
|
|
979
|
+
/**
|
|
980
|
+
* **A declared media RANGE admits the types inside it, and a literal admits only itself.**
|
|
981
|
+
*
|
|
982
|
+
* \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
|
|
983
|
+
* satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
|
|
984
|
+
* Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
|
|
985
|
+
* document declares the whole \`text\` group.
|
|
986
|
+
*
|
|
987
|
+
* A template literal is the Zod counterpart of the TypeScript type the contract already uses for
|
|
988
|
+
* a range, so the validator and the handler's own type say the same thing.
|
|
989
|
+
*/
|
|
990
|
+
const admitted = values.map((value) => {
|
|
991
|
+
/**
|
|
992
|
+
* **The full wildcard admits every media type, so there is nothing left to match on.**
|
|
993
|
+
*
|
|
994
|
+
* Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
|
|
995
|
+
* Content-Type whose text begins with the range's own first two characters. Measured on the
|
|
996
|
+
* emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
|
|
997
|
+
* literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
|
|
998
|
+
* to remove rather than to respell. A consumer declares this on a file upload that takes
|
|
999
|
+
* whatever the caller sends.
|
|
1000
|
+
*/
|
|
1001
|
+
if (value === "*/*")
|
|
1002
|
+
return "z.string()";
|
|
1003
|
+
return value.endsWith("/*")
|
|
1004
|
+
? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
|
|
1005
|
+
: `z.literal(${JSON.stringify(value)})`;
|
|
1006
|
+
});
|
|
1007
|
+
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1008
|
+
const ranged = values.some((value) => value.endsWith("/*")) ? schema : declared;
|
|
971
1009
|
// Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
|
|
972
1010
|
const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
|
|
973
|
-
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${
|
|
1011
|
+
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${ranged})`;
|
|
974
1012
|
}
|
|
975
1013
|
function parameterSchemasOf(program, operation, registry, segments,
|
|
976
1014
|
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
@@ -983,7 +1021,22 @@ literalQueryNames) {
|
|
|
983
1021
|
.filter((parameter) => parameter.type === "query")
|
|
984
1022
|
.map((parameter) => parameter.name);
|
|
985
1023
|
for (const parameter of operation.parameters.parameters) {
|
|
986
|
-
|
|
1024
|
+
/**
|
|
1025
|
+
* **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
|
|
1026
|
+
* `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
|
|
1027
|
+
* filter named three of the four, so a `@cookie` reached no validator, no handler input and no
|
|
1028
|
+
* diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
|
|
1029
|
+
* requirement the document states and the server cannot see is the defect class this package
|
|
1030
|
+
* exists to remove, and a cookie is where a session and a CSRF token normally live.
|
|
1031
|
+
*
|
|
1032
|
+
* The exclusion was by omission rather than by a `never` check, which is why TypeScript never
|
|
1033
|
+
* objected; every other place that reads `parameter.type` handles an encoding question a cookie
|
|
1034
|
+
* does not have, since `CookieOptions` carries only a name.
|
|
1035
|
+
*/
|
|
1036
|
+
if (parameter.type !== "path" &&
|
|
1037
|
+
parameter.type !== "query" &&
|
|
1038
|
+
parameter.type !== "header" &&
|
|
1039
|
+
parameter.type !== "cookie") {
|
|
987
1040
|
continue;
|
|
988
1041
|
}
|
|
989
1042
|
/**
|
|
@@ -1102,6 +1155,7 @@ literalQueryNames) {
|
|
|
1102
1155
|
};
|
|
1103
1156
|
const query = shape("query");
|
|
1104
1157
|
return {
|
|
1158
|
+
cookie: shape("cookie"),
|
|
1105
1159
|
path: shape("path"),
|
|
1106
1160
|
query: query === undefined
|
|
1107
1161
|
? undefined
|
|
@@ -1299,6 +1353,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1299
1353
|
...(() => {
|
|
1300
1354
|
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1301
1355
|
return {
|
|
1356
|
+
cookieSchema: split.cookie,
|
|
1302
1357
|
pathSchema: split.path,
|
|
1303
1358
|
querySchema: split.query,
|
|
1304
1359
|
queryFieldsSchema: split.queryFields,
|
|
@@ -1452,13 +1507,30 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1452
1507
|
* parse - the walk captures the reference while it is still in the temporal dead zone, so the
|
|
1453
1508
|
* failure surfaces at request time rather than at import.
|
|
1454
1509
|
*
|
|
1455
|
-
* **
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1510
|
+
* **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
|
|
1511
|
+
*
|
|
1512
|
+
* It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
|
|
1513
|
+
* when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
|
|
1514
|
+
* compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
|
|
1515
|
+
* recursive parse state weak. The failure moved from the deferred declaration to the one that
|
|
1516
|
+
* REFERENCES it, which was still being compiled:
|
|
1517
|
+
*
|
|
1518
|
+
* ```
|
|
1519
|
+
* const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
|
|
1520
|
+
* const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
|
|
1521
|
+
* nodeSchema.safeParse({ label: "a", attributes: {} });
|
|
1522
|
+
* zod 4.5.2: true
|
|
1523
|
+
* zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
|
|
1524
|
+
* ```
|
|
1525
|
+
*
|
|
1526
|
+
* Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
|
|
1527
|
+
* the self-referencing getter is not needed for it. So the same rule the registry already
|
|
1528
|
+
* applies to structural types applies here - every member of the cycle, not only the deferred
|
|
1529
|
+
* one - and `structural` is the fact it already holds for exactly that set.
|
|
1530
|
+
*
|
|
1531
|
+
* The peer range admits 4.6, so this reached a running server on the first parse.
|
|
1460
1532
|
*/
|
|
1461
|
-
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.
|
|
1533
|
+
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
|
|
1462
1534
|
});
|
|
1463
1535
|
/**
|
|
1464
1536
|
* **Written only when something in the file names it**, like the `ResponseArm` import below and for
|
|
@@ -1633,6 +1705,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1633
1705
|
const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
|
|
1634
1706
|
? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
|
|
1635
1707
|
: route.querySchema);
|
|
1708
|
+
const cookie = declare("Cookie", route.cookieSchema);
|
|
1636
1709
|
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
1637
1710
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
1638
1711
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
@@ -1643,13 +1716,28 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1643
1716
|
*/
|
|
1644
1717
|
const declaredBodies = new Map();
|
|
1645
1718
|
const arms = route.responses.map((response) => {
|
|
1646
|
-
|
|
1647
|
-
|
|
1719
|
+
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1720
|
+
/**
|
|
1721
|
+
* The headers this status declares, as one object schema, so a server can check what it is
|
|
1722
|
+
* about to send the way it already checks the body.
|
|
1723
|
+
*
|
|
1724
|
+
* **A plain `z.object`, which strips.** A response carries headers the document does not
|
|
1725
|
+
* declare - `Content-Type` among them, which the server sets itself - and those are not a
|
|
1726
|
+
* contract violation. What is checked is that every header the document DOES declare carries
|
|
1727
|
+
* a value the document permits.
|
|
1728
|
+
*/
|
|
1729
|
+
const headers = response.headers.length === 0
|
|
1730
|
+
? undefined
|
|
1731
|
+
: declare(`Response${suffix}Headers`, `z.object({\n${response.headers
|
|
1732
|
+
.map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
|
|
1733
|
+
.join("\n")}\n})`);
|
|
1734
|
+
if (response.schema === undefined) {
|
|
1735
|
+
return { status: response.status, schema: undefined, headers };
|
|
1736
|
+
}
|
|
1648
1737
|
if (!declaredBodies.has(response.schema)) {
|
|
1649
|
-
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1650
1738
|
declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
|
|
1651
1739
|
}
|
|
1652
|
-
return { status: response.status, schema: declaredBodies.get(response.schema) };
|
|
1740
|
+
return { status: response.status, schema: declaredBodies.get(response.schema), headers };
|
|
1653
1741
|
});
|
|
1654
1742
|
const responses = schemaConst(route.operationId, "Responses");
|
|
1655
1743
|
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
@@ -1659,6 +1747,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1659
1747
|
query,
|
|
1660
1748
|
queryFields: gathers ? queryFields : query,
|
|
1661
1749
|
header,
|
|
1750
|
+
cookie,
|
|
1662
1751
|
body,
|
|
1663
1752
|
arms,
|
|
1664
1753
|
responses,
|
|
@@ -1741,7 +1830,9 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1741
1830
|
const extraProperties = operation.parameters.parameters
|
|
1742
1831
|
.filter((parameter) => parameter.type === "path" ||
|
|
1743
1832
|
parameter.type === "query" ||
|
|
1744
|
-
parameter.type === "header"
|
|
1833
|
+
parameter.type === "header" ||
|
|
1834
|
+
// The second half of the same omission: a validator with no matching type.
|
|
1835
|
+
parameter.type === "cookie")
|
|
1745
1836
|
/**
|
|
1746
1837
|
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
1747
1838
|
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
@@ -2058,7 +2149,7 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
2058
2149
|
* that does not.
|
|
2059
2150
|
*/
|
|
2060
2151
|
/**
|
|
2061
|
-
*
|
|
2152
|
+
* **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
|
|
2062
2153
|
* deliberate on both sides.**
|
|
2063
2154
|
*
|
|
2064
2155
|
* The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
|
|
@@ -2121,8 +2212,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2121
2212
|
: T;
|
|
2122
2213
|
|
|
2123
2214
|
/**
|
|
2124
|
-
*
|
|
2125
|
-
* type when the two disagree, and a type alias to an odd type compiles fine
|
|
2215
|
+
* **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
2216
|
+
* type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
|
|
2126
2217
|
* assert nothing. Only a constraint violation is an error.
|
|
2127
2218
|
*/
|
|
2128
2219
|
type MustHold<T extends true> = T;
|
|
@@ -15,6 +15,27 @@ import { type Program, type Type } from "@typespec/compiler";
|
|
|
15
15
|
* asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
|
|
16
16
|
*/
|
|
17
17
|
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
18
|
+
/**
|
|
19
|
+
* **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
|
|
20
|
+
*
|
|
21
|
+
* A pure verdict, with no program and no report. The judgement belongs to the
|
|
22
|
+
* `redos-prone-pattern` LINTER rule rather than to emission, because the spec is valid, the
|
|
23
|
+
* document is correct and the emitted regex is exactly the published one: what is wrong is a cost
|
|
24
|
+
* no artefact states, which is the case TypeSpec reserves for a linter.
|
|
25
|
+
*
|
|
26
|
+
* **The engine is the one Zod runs on**, so the analysis carries whichever flag
|
|
27
|
+
* {@link patternToRegex} will emit. Analysing `\p{L}+` with `unicode: false` asks about a different
|
|
28
|
+
* expression from the one that runs - the identity escape for `p` - and a verdict about the wrong
|
|
29
|
+
* pattern is worth nothing whichever way it comes out.
|
|
30
|
+
*
|
|
31
|
+
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
32
|
+
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
33
|
+
* the timeout is judged the same way as one it decides against. Measured over a realistic spread of
|
|
34
|
+
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
35
|
+
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
36
|
+
* ordinary and takes **11 seconds** on a 29-character input.
|
|
37
|
+
*/
|
|
38
|
+
export declare function patternIsProvablySafe(pattern: string): boolean;
|
|
18
39
|
/**
|
|
19
40
|
* A `@pattern` string becomes a regex literal - **verbatim**.
|
|
20
41
|
*
|
|
@@ -37,3 +58,28 @@ export declare function applyConstraints(program: Program, expression: string, t
|
|
|
37
58
|
* instead of guessing at an equivalence.
|
|
38
59
|
*/
|
|
39
60
|
export declare function patternToRegex(pattern: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the expression is one the `u` flag accepts, which is how a JSON Schema reader compiles it.
|
|
63
|
+
*
|
|
64
|
+
* **The flag is part of the contract, and leaving it off was the divergence.** A bare `/.../` makes
|
|
65
|
+
* `\p{L}` an identity escape for the letter `p`, so the expression the server ran was not the
|
|
66
|
+
* expression the spec author wrote. Measured against Ajv on the published document, with every
|
|
67
|
+
* verdict inverted:
|
|
68
|
+
*
|
|
69
|
+
* ```
|
|
70
|
+
* value document (Ajv) emitted validator
|
|
71
|
+
* "abc" VALID INVALID
|
|
72
|
+
* "Unicode" VALID INVALID
|
|
73
|
+
* "p{L}" INVALID VALID
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* Ajv compiles a JSON Schema `pattern` with `u` by default, so carrying it here is not a rule laid
|
|
77
|
+
* on top of the contract; it is the contract.
|
|
78
|
+
*
|
|
79
|
+
* **Only when the pattern compiles under it.** `u` makes several ordinary spellings an error - a
|
|
80
|
+
* redundant `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a` - and
|
|
81
|
+
* 5 of 15 realistic patterns tested fail to compile with it. Those keep the bare form they already
|
|
82
|
+
* had, and {@link reportNonUnicodePattern} says so at build time, because a reader of the document
|
|
83
|
+
* cannot compile them either.
|
|
84
|
+
*/
|
|
85
|
+
export declare function unicodeSafe(pattern: string): boolean;
|
package/dist/src/constraints.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
1
|
+
import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
|
+
import { isSafePattern } from "redos-detector";
|
|
3
|
+
import { reportDiagnostic } from "./lib.js";
|
|
2
4
|
/**
|
|
3
5
|
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
4
6
|
*
|
|
@@ -23,26 +25,165 @@ export function applyConstraints(program, expression, target) {
|
|
|
23
25
|
const minItems = getMinItems(program, target);
|
|
24
26
|
const maxItems = getMaxItems(program, target);
|
|
25
27
|
const pattern = getPattern(program, target);
|
|
28
|
+
const minExclusive = getMinValueExclusive(program, target);
|
|
29
|
+
const maxExclusive = getMaxValueExclusive(program, target);
|
|
30
|
+
/**
|
|
31
|
+
* **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
|
|
32
|
+
* numeric property and makes the wire value a string, so the expression here is `z.string()` while
|
|
33
|
+
* `@minValue` is still a statement about the number it spells. Folding the two together emitted a
|
|
34
|
+
* LENGTH check: `@minValue(1)` accepted `"0"`, and `@minValue(100)` refused `"150"`. The exclusive
|
|
35
|
+
* form was worse - `z.string().gt(0)` is not a function, so the module threw on import and took
|
|
36
|
+
* the whole service down with it.
|
|
37
|
+
*
|
|
38
|
+
* It cannot be enforced either, and not for want of a mechanism: the document publishes `minimum`
|
|
39
|
+
* on a `type: string` schema, which JSON Schema ignores, so a validator that checked it would
|
|
40
|
+
* refuse payloads the published contract accepts. That is the same defect in the other direction,
|
|
41
|
+
* and this package's rule is that the two artefacts agree. So the bound is dropped and reported.
|
|
42
|
+
*/
|
|
43
|
+
const encodedAsText = encodesAsString(program, target);
|
|
44
|
+
const numeric = [minValue, maxValue, minExclusive, maxExclusive].some((bound) => bound !== undefined);
|
|
45
|
+
if (encodedAsText && numeric) {
|
|
46
|
+
reportDiagnostic(program, {
|
|
47
|
+
code: "unenforceable-encoded-bound",
|
|
48
|
+
format: {
|
|
49
|
+
name: target.kind === "ModelProperty" || target.kind === "Scalar" ? target.name : "",
|
|
50
|
+
},
|
|
51
|
+
target,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
26
54
|
// `@minLength`/`@minValue`/`@minItems` all land on Zod's `.min()`; they are distinguished by the
|
|
27
|
-
// type they are legal on, which TypeSpec has already checked by the time we get here
|
|
28
|
-
|
|
29
|
-
const
|
|
55
|
+
// type they are legal on, which TypeSpec has already checked by the time we get here - except
|
|
56
|
+
// under `@encode(string)`, where the type they are legal on is not the type emitted.
|
|
57
|
+
const min = minLength ?? (encodedAsText ? undefined : minValue) ?? minItems;
|
|
58
|
+
const max = maxLength ?? (encodedAsText ? undefined : maxValue) ?? maxItems;
|
|
30
59
|
if (min !== undefined)
|
|
31
60
|
result += `.min(${min})`;
|
|
32
61
|
if (max !== undefined)
|
|
33
62
|
result += `.max(${max})`;
|
|
34
63
|
// Exclusive bounds are a distinct question from inclusive ones - `z.number().positive()` is
|
|
35
64
|
// `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
|
|
36
|
-
|
|
37
|
-
const maxExclusive = getMaxValueExclusive(program, target);
|
|
38
|
-
if (minExclusive !== undefined)
|
|
65
|
+
if (!encodedAsText && minExclusive !== undefined)
|
|
39
66
|
result += `.gt(${minExclusive})`;
|
|
40
|
-
if (maxExclusive !== undefined)
|
|
67
|
+
if (!encodedAsText && maxExclusive !== undefined)
|
|
41
68
|
result += `.lt(${maxExclusive})`;
|
|
42
|
-
if (pattern !== undefined)
|
|
69
|
+
if (pattern !== undefined) {
|
|
70
|
+
reportNonUnicodePattern(program, pattern, target);
|
|
43
71
|
result += `.regex(${patternToRegex(pattern)})`;
|
|
72
|
+
}
|
|
44
73
|
return result;
|
|
45
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether the value this type puts on the wire is text because `@encode` says so. Read from the
|
|
77
|
+
* property and then from its scalar, which is where an encoding declared once for a named scalar
|
|
78
|
+
* lives.
|
|
79
|
+
*/
|
|
80
|
+
function encodesAsString(program, target) {
|
|
81
|
+
if (target.kind !== "ModelProperty" && target.kind !== "Scalar")
|
|
82
|
+
return false;
|
|
83
|
+
const own = getEncode(program, target);
|
|
84
|
+
const scalar = own === undefined && target.kind === "ModelProperty" && target.type.kind === "Scalar"
|
|
85
|
+
? getEncode(program, target.type)
|
|
86
|
+
: undefined;
|
|
87
|
+
return (own ?? scalar)?.type.name === "string";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* How long one pattern may be analysed before the answer is "could not prove it".
|
|
91
|
+
*
|
|
92
|
+
* **Bounded, because the analysis is itself exponential in the bad case.** Measured: `^(a|a)+$`
|
|
93
|
+
* takes 1.6 seconds to decide, and a build should not stall on a pattern whose whole problem is that
|
|
94
|
+
* it does too much work. A pattern that cannot be decided inside this is one this emitter cannot
|
|
95
|
+
* certify, which is what the warning says.
|
|
96
|
+
*/
|
|
97
|
+
const ANALYSIS_TIMEOUT_MS = 500;
|
|
98
|
+
/**
|
|
99
|
+
* Patterns already judged, keyed on the text, which is the only input the verdict depends on.
|
|
100
|
+
*
|
|
101
|
+
* The analysis costs up to {@link ANALYSIS_TIMEOUT_MS} a pattern, and one pattern text commonly
|
|
102
|
+
* recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
|
|
103
|
+
* consumer spec - so each distinct text is analysed once per process.
|
|
104
|
+
*/
|
|
105
|
+
const verdicts = new Map();
|
|
106
|
+
/**
|
|
107
|
+
* Which declarations have already been reported, per program.
|
|
108
|
+
*
|
|
109
|
+
* **Per PROGRAM, and that distinction is the whole reason this is a `WeakMap`.** One scalar carrying
|
|
110
|
+
* a pattern is reached once per USE - the reference service reaches a single scalar 153 times - and
|
|
111
|
+
* three identical warnings pointing at one declaration are noise a reader learns to skip. But a
|
|
112
|
+
* module-level "already said that" would also silence the second compile in a watch session, or the
|
|
113
|
+
* second service in one program, which is a warning lost rather than a warning tidied. Keyed on the
|
|
114
|
+
* program, it is emptied whenever the program is.
|
|
115
|
+
*
|
|
116
|
+
* The verdict cache above is different and is deliberately global: it is a pure function of the
|
|
117
|
+
* pattern text, so it cannot be wrong for a later program.
|
|
118
|
+
*/
|
|
119
|
+
const reported = new WeakMap();
|
|
120
|
+
/**
|
|
121
|
+
* **Whether this diagnostic has already been raised against this declaration.**
|
|
122
|
+
*
|
|
123
|
+
* Keyed on the code as well as the target. Keying on the target alone once made the memo shared
|
|
124
|
+
* between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
|
|
125
|
+
* the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
|
|
126
|
+
* since moved to a linter rule, which reports per declaration by construction, so that pairing can
|
|
127
|
+
* no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
|
|
128
|
+
* diagnostic added beside this one, and costs nothing to keep right.
|
|
129
|
+
*
|
|
130
|
+
* The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
|
|
131
|
+
* 153 times for a single scalar in the reference service - and repeating one warning that often
|
|
132
|
+
* is noise a reader learns to skip, which is how a real one gets missed.
|
|
133
|
+
*/
|
|
134
|
+
function firstReportOf(program, code, target) {
|
|
135
|
+
let byCode = reported.get(program);
|
|
136
|
+
if (byCode === undefined) {
|
|
137
|
+
byCode = new Map();
|
|
138
|
+
reported.set(program, byCode);
|
|
139
|
+
}
|
|
140
|
+
let already = byCode.get(code);
|
|
141
|
+
if (already === undefined) {
|
|
142
|
+
already = new WeakSet();
|
|
143
|
+
byCode.set(code, already);
|
|
144
|
+
}
|
|
145
|
+
if (already.has(target))
|
|
146
|
+
return false;
|
|
147
|
+
already.add(target);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
|
|
152
|
+
*
|
|
153
|
+
* A pure verdict, with no program and no report. The judgement belongs to the
|
|
154
|
+
* `redos-prone-pattern` LINTER rule rather than to emission, because the spec is valid, the
|
|
155
|
+
* document is correct and the emitted regex is exactly the published one: what is wrong is a cost
|
|
156
|
+
* no artefact states, which is the case TypeSpec reserves for a linter.
|
|
157
|
+
*
|
|
158
|
+
* **The engine is the one Zod runs on**, so the analysis carries whichever flag
|
|
159
|
+
* {@link patternToRegex} will emit. Analysing `\p{L}+` with `unicode: false` asks about a different
|
|
160
|
+
* expression from the one that runs - the identity escape for `p` - and a verdict about the wrong
|
|
161
|
+
* pattern is worth nothing whichever way it comes out.
|
|
162
|
+
*
|
|
163
|
+
* **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
|
|
164
|
+
* `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
|
|
165
|
+
* the timeout is judged the same way as one it decides against. Measured over a realistic spread of
|
|
166
|
+
* 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
|
|
167
|
+
* overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
|
|
168
|
+
* ordinary and takes **11 seconds** on a 29-character input.
|
|
169
|
+
*/
|
|
170
|
+
export function patternIsProvablySafe(pattern) {
|
|
171
|
+
let safe = verdicts.get(pattern);
|
|
172
|
+
if (safe === undefined) {
|
|
173
|
+
try {
|
|
174
|
+
safe = isSafePattern(pattern, {
|
|
175
|
+
unicode: unicodeSafe(pattern),
|
|
176
|
+
timeout: ANALYSIS_TIMEOUT_MS,
|
|
177
|
+
}).safe;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// A pattern the analyser cannot even parse is not one to make a claim about.
|
|
181
|
+
safe = true;
|
|
182
|
+
}
|
|
183
|
+
verdicts.set(pattern, safe);
|
|
184
|
+
}
|
|
185
|
+
return safe;
|
|
186
|
+
}
|
|
46
187
|
/**
|
|
47
188
|
* A `@pattern` string becomes a regex literal - **verbatim**.
|
|
48
189
|
*
|
|
@@ -65,5 +206,87 @@ export function applyConstraints(program, expression, target) {
|
|
|
65
206
|
* instead of guessing at an equivalence.
|
|
66
207
|
*/
|
|
67
208
|
export function patternToRegex(pattern) {
|
|
68
|
-
return `/${pattern
|
|
209
|
+
return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* **Escapes the slashes a regex literal needs escaped, and only those.**
|
|
213
|
+
*
|
|
214
|
+
* A `.replace(/\//g, "\\/")` escapes unconditionally, so a pattern that already spelled a slash
|
|
215
|
+
* as an escape had its backslash doubled: `^\/api\/v[0-9]+$` became the literal
|
|
216
|
+
* `/^\\/api\\/v[0-9]+$/u`, which ends at the third slash and reads `api` as flags. Measured on
|
|
217
|
+
* that pattern: `tsp compile` EXIT=0 and silent, `node` refused the emitted module with
|
|
218
|
+
* `SyntaxError: Invalid regular expression flags`, and `tsc` reported `TS1127: Invalid character`.
|
|
219
|
+
* A path regex is the most ordinary place a slash appears, and no fixture here had ever carried
|
|
220
|
+
* one - nor does any of the 67 specs in `@typespec/http-specs`.
|
|
221
|
+
*
|
|
222
|
+
* A backslash takes the character after it with it, so `\/` is already escaped and is left alone,
|
|
223
|
+
* while `\\` is a literal backslash and a slash following it still needs escaping. `\/` remains a
|
|
224
|
+
* legal identity escape under the `u` flag, so this does not change which patterns can carry it.
|
|
225
|
+
*/
|
|
226
|
+
function escapeSlashes(pattern) {
|
|
227
|
+
let escaped = "";
|
|
228
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
229
|
+
const character = pattern[index];
|
|
230
|
+
if (character === "\\") {
|
|
231
|
+
escaped += character + (pattern[index + 1] ?? "");
|
|
232
|
+
index += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
escaped += character === "/" ? "\\/" : character;
|
|
236
|
+
}
|
|
237
|
+
return escaped;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Whether the expression is one the `u` flag accepts, which is how a JSON Schema reader compiles it.
|
|
241
|
+
*
|
|
242
|
+
* **The flag is part of the contract, and leaving it off was the divergence.** A bare `/.../` makes
|
|
243
|
+
* `\p{L}` an identity escape for the letter `p`, so the expression the server ran was not the
|
|
244
|
+
* expression the spec author wrote. Measured against Ajv on the published document, with every
|
|
245
|
+
* verdict inverted:
|
|
246
|
+
*
|
|
247
|
+
* ```
|
|
248
|
+
* value document (Ajv) emitted validator
|
|
249
|
+
* "abc" VALID INVALID
|
|
250
|
+
* "Unicode" VALID INVALID
|
|
251
|
+
* "p{L}" INVALID VALID
|
|
252
|
+
* ```
|
|
253
|
+
*
|
|
254
|
+
* Ajv compiles a JSON Schema `pattern` with `u` by default, so carrying it here is not a rule laid
|
|
255
|
+
* on top of the contract; it is the contract.
|
|
256
|
+
*
|
|
257
|
+
* **Only when the pattern compiles under it.** `u` makes several ordinary spellings an error - a
|
|
258
|
+
* redundant `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a` - and
|
|
259
|
+
* 5 of 15 realistic patterns tested fail to compile with it. Those keep the bare form they already
|
|
260
|
+
* had, and {@link reportNonUnicodePattern} says so at build time, because a reader of the document
|
|
261
|
+
* cannot compile them either.
|
|
262
|
+
*/
|
|
263
|
+
export function unicodeSafe(pattern) {
|
|
264
|
+
try {
|
|
265
|
+
new RegExp(pattern, "u");
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Warn when a `@pattern` is one a JSON Schema reader cannot compile at all.
|
|
274
|
+
*
|
|
275
|
+
* Separate from the `redos-prone-pattern` linter rule because the failure runs the other way: that
|
|
276
|
+
* one is about a pattern the validator runs too slowly, this one about a pattern nothing READING
|
|
277
|
+
* the document can run. And this one stays a diagnostic, because here the emitter cannot do what the
|
|
278
|
+
* spec asks: it falls back to a different expression from the one it publishes. Measured, Ajv on a published `^\-?\d+$`:
|
|
279
|
+
* `Invalid regular expression: /^\-?\d+$/u: Invalid escape`. The emitted validator falls back to the
|
|
280
|
+
* bare form and enforces it anyway, so the two artefacts stop agreeing without either one failing.
|
|
281
|
+
*
|
|
282
|
+
* De-duplicated per declaration through {@link firstReportOf}, which keys on the diagnostic as
|
|
283
|
+
* well as the declaration. Sharing one memo between the two warnings meant a declaration with
|
|
284
|
+
* both faults reported only whichever ran first.
|
|
285
|
+
*/
|
|
286
|
+
function reportNonUnicodePattern(program, pattern, target) {
|
|
287
|
+
if (unicodeSafe(pattern))
|
|
288
|
+
return;
|
|
289
|
+
if (!firstReportOf(program, "non-unicode-pattern", target))
|
|
290
|
+
return;
|
|
291
|
+
reportDiagnostic(program, { code: "non-unicode-pattern", format: { pattern }, target });
|
|
69
292
|
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED