@fora-protocol/sdk 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
// Safe validation of a published registration schema — TS port of the sdk/go
|
|
2
|
+
// oracle (helpers/regschema.go).
|
|
3
|
+
//
|
|
4
|
+
// An Exchange MAY publish AccountRegistration.data_schema in its fora.json: a JSON
|
|
5
|
+
// Schema describing the RegisterRequest.registration_data it expects. Two parties
|
|
6
|
+
// read that schema and MUST agree — the Exchange enforcing it on the way in, and a
|
|
7
|
+
// client pre-checking a payload before it signs and sends one. A payload that
|
|
8
|
+
// passes one and fails the other is the failure this module exists to remove, so
|
|
9
|
+
// the rules live in the SDK once rather than in each consumer's choice of library.
|
|
10
|
+
//
|
|
11
|
+
// The client's copy is the harder half: it arrives out of a THIRD PARTY's
|
|
12
|
+
// manifest, which makes a schema an attacker-influenced input reached before any
|
|
13
|
+
// signature is checked. Hence: no reference is resolved outside the document, the
|
|
14
|
+
// dialect is pinned to draft 2020-12, and size, depth and the `pattern` alphabet
|
|
15
|
+
// are bounded. The pattern rule is not only about backtracking — see
|
|
16
|
+
// isSafeSchemaPattern.
|
|
17
|
+
//
|
|
18
|
+
// Pure: bytes in, verdict out, no IO and no state. Byte-parity-guarded against the
|
|
19
|
+
// Go oracle by the shared vectors at
|
|
20
|
+
// sdk/go/helpers/testdata/registration-schema-vectors.json.
|
|
21
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
22
|
+
import canonicalize from "canonicalize";
|
|
23
|
+
import { rawNestingDepth } from "./jsondepth.js";
|
|
24
|
+
/**
|
|
25
|
+
* maxRegistrationSchemaBytes is the published schema's size cap, measured as the
|
|
26
|
+
* UTF-8 bytes of the data_schema member AS SERVED in fora.json — which is why the
|
|
27
|
+
* compile face takes raw bytes rather than a decoded document. A re-encoding is a
|
|
28
|
+
* different length than what the origin sent, and the cap is defined over what the
|
|
29
|
+
* origin sent.
|
|
30
|
+
*/
|
|
31
|
+
export const maxRegistrationSchemaBytes = 16384;
|
|
32
|
+
/**
|
|
33
|
+
* maxRegistrationSchemaDepth bounds how deeply the schema document may nest. It
|
|
34
|
+
* counts JSON containers, so a bare `{}` is depth 1. Deep allOf/$ref chains are
|
|
35
|
+
* the cheapest way to make a compile expensive, and a real registration schema is
|
|
36
|
+
* three to five levels deep.
|
|
37
|
+
*/
|
|
38
|
+
export const maxRegistrationSchemaDepth = 32;
|
|
39
|
+
/**
|
|
40
|
+
* maxRegistrationFieldErrors is the number of member failures a refusal may carry
|
|
41
|
+
* — the wire's own bound (RegistrationFailure.field_errors declares
|
|
42
|
+
* repeated.max_items = 64), restated so the validator never builds a list the
|
|
43
|
+
* contract would reject.
|
|
44
|
+
*/
|
|
45
|
+
export const maxRegistrationFieldErrors = 64;
|
|
46
|
+
/** The wire bounds on RegistrationFieldError.path and .error. */
|
|
47
|
+
export const maxRegistrationFieldErrorPathLen = 255;
|
|
48
|
+
export const maxRegistrationFieldErrorTextLen = 255;
|
|
49
|
+
/**
|
|
50
|
+
* registrationSchemaDialect is the only $schema value a published data_schema may
|
|
51
|
+
* name. A document that names none is read as this dialect; one that names another
|
|
52
|
+
* is refused rather than validated under semantics its author did not intend.
|
|
53
|
+
*/
|
|
54
|
+
export const registrationSchemaDialect = "https://json-schema.org/draft/2020-12/schema";
|
|
55
|
+
/**
|
|
56
|
+
* maxRegistrationSchemaEvaluations bounds the WORK of checking a payload, which the
|
|
57
|
+
* size and depth caps do not: `anyOf` branches multiply along a reference chain, so a
|
|
58
|
+
* schema can be small and shallow and still cost an unbounded amount to evaluate.
|
|
59
|
+
* Cost is linear in this count, so the bound is really a time bound expressed as a
|
|
60
|
+
* number a static walk can compute and a shared corpus can pin, which a stopwatch
|
|
61
|
+
* cannot.
|
|
62
|
+
*/
|
|
63
|
+
export const maxRegistrationSchemaEvaluations = 10000;
|
|
64
|
+
/**
|
|
65
|
+
* maxRegistrationSchemaRefHops bounds how long a `$ref` chain may be, measured as the
|
|
66
|
+
* longest path of reference hops rather than as the number of references a document
|
|
67
|
+
* contains.
|
|
68
|
+
*
|
|
69
|
+
* A SEPARATE axis from maxRegistrationSchemaDepth, and the shape that forced it shows
|
|
70
|
+
* why: a chain of five hundred definitions, each referring to the next, is three JSON
|
|
71
|
+
* containers deep however long it is, so the depth cap never sees it. The evaluation cap
|
|
72
|
+
* does not see it either — a flat chain costs one evaluation per link.
|
|
73
|
+
*
|
|
74
|
+
* What it bounds is the RECURSION a validator does while resolving that chain. The cost
|
|
75
|
+
* walk here does not care, but the libraries the three SDKs hand an accepted schema to
|
|
76
|
+
* do, and one of them exhausted its interpreter stack at 495 links — raising out of a
|
|
77
|
+
* face documented as returning a verdict, on a document every SDK had just called valid.
|
|
78
|
+
*/
|
|
79
|
+
export const maxRegistrationSchemaRefHops = 100;
|
|
80
|
+
// NOTE on wall-clock bounds. The Go oracle carries a compile timeout, which it can
|
|
81
|
+
// enforce because its runtime preempts. This port carries none, deliberately: a
|
|
82
|
+
// CPU-bound spin blocks the event loop, so a timer never runs until the work it was
|
|
83
|
+
// meant to interrupt has finished. A control that cannot preempt the work it names is
|
|
84
|
+
// not a control. What bounds this port is entirely static and identical in all three
|
|
85
|
+
// languages: the size, depth and evaluation caps, and the pattern alphabet, which
|
|
86
|
+
// refuses the nested quantifiers that make backtracking catastrophic in the first
|
|
87
|
+
// place.
|
|
88
|
+
/**
|
|
89
|
+
* SchemaVerdict is the outcome of compiling a published data_schema. The tokens
|
|
90
|
+
* are the Go `SchemaVerdict.String()` vocabulary verbatim, which is what the
|
|
91
|
+
* shared vectors record — a union of string literals rather than a numeric enum,
|
|
92
|
+
* matching the sibling AudienceVerdict.
|
|
93
|
+
*
|
|
94
|
+
* "no_verdict" is Go's zero value and is never returned here; it is in the
|
|
95
|
+
* vocabulary because the corpus carries the whole vocabulary.
|
|
96
|
+
*/
|
|
97
|
+
export const schemaVerdicts = [
|
|
98
|
+
"no_verdict",
|
|
99
|
+
"accepted",
|
|
100
|
+
"malformed",
|
|
101
|
+
"wrong_dialect",
|
|
102
|
+
"remote_ref",
|
|
103
|
+
"too_large",
|
|
104
|
+
"too_deep",
|
|
105
|
+
"unsafe_pattern",
|
|
106
|
+
"too_complex",
|
|
107
|
+
"ref_cycle",
|
|
108
|
+
"ref_chain_too_long",
|
|
109
|
+
"compile_timeout",
|
|
110
|
+
"uncompilable",
|
|
111
|
+
"not_published",
|
|
112
|
+
];
|
|
113
|
+
// Escape letters every engine spells the same way AND reads the same way. This is an
|
|
114
|
+
// ALLOWLIST, and that is the point: the set of escapes the three engines disagree
|
|
115
|
+
// about is open-ended, so enumerating it means adding an entry every time somebody
|
|
116
|
+
// finds another one. See isSafeSchemaPattern for the full argument.
|
|
117
|
+
const portableEscapes = new Set("dDwWnrtfv".split(""));
|
|
118
|
+
// The regex metacharacters an author escapes to mean the character itself. Every
|
|
119
|
+
// engine accepts these — they are the characters that NEED escaping, so no dialect can
|
|
120
|
+
// refuse them. Escaping anything else is an "identity escape", and this port is the
|
|
121
|
+
// one that refuses those: ajv compiles every pattern with the `u` flag
|
|
122
|
+
// (`unicodeRegExp` defaults to true), under which ECMA-262 rejects `\-`, `\a` and the
|
|
123
|
+
// rest outright while RE2 and Python accept them.
|
|
124
|
+
const portableSyntaxEscapes = new Set("$()*+./?[\\]^{|}".split(""));
|
|
125
|
+
// The escapes standing for a SET of characters rather than one. A set cannot be the
|
|
126
|
+
// endpoint of a range, and the engines disagree about whether saying so ("[\w-x]") is
|
|
127
|
+
// an error or a reinterpretation.
|
|
128
|
+
const shorthandClassEscapes = new Set("dDwW".split(""));
|
|
129
|
+
/**
|
|
130
|
+
* correctedPatternSource rewrites one author-written regex into a source that means the
|
|
131
|
+
* SAME thing here as it does in RE2 and in Python's `re`.
|
|
132
|
+
*
|
|
133
|
+
* One correction, and it is this port's turn to make one: `.` excludes only `\n` in RE2
|
|
134
|
+
* and in Python, and excludes all four line terminators in ECMA-262, so `^.$` against a
|
|
135
|
+
* carriage return conformed in the other two SDKs and violated here. Refusing `.` would
|
|
136
|
+
* gut a construct that appears in most real patterns, so the odd one out is corrected
|
|
137
|
+
* instead — exactly what the Python port already does for `$`, which matches before a
|
|
138
|
+
* trailing newline there and nowhere else.
|
|
139
|
+
*
|
|
140
|
+
* The scan is bracket- and escape-aware: a `.` inside a character class is already a
|
|
141
|
+
* literal dot, and an escaped `\.` is a literal everywhere.
|
|
142
|
+
*/
|
|
143
|
+
function correctedPatternSource(pattern) {
|
|
144
|
+
const out = [];
|
|
145
|
+
let inClass = false;
|
|
146
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
147
|
+
const ch = pattern[i];
|
|
148
|
+
if (ch === "\\") {
|
|
149
|
+
out.push(pattern.slice(i, i + 2));
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === "[")
|
|
154
|
+
inClass = true;
|
|
155
|
+
else if (ch === "]")
|
|
156
|
+
inClass = false;
|
|
157
|
+
else if (ch === "." && !inClass) {
|
|
158
|
+
out.push("[^\n]");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
out.push(ch);
|
|
162
|
+
}
|
|
163
|
+
return out.join("");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* authoredPatternSource recovers the author's own text from a corrected source, for a
|
|
167
|
+
* refusal an operator reads.
|
|
168
|
+
*
|
|
169
|
+
* The rewrite is reversed rather than tracked alongside, which is exact in the direction
|
|
170
|
+
* that matters: an author who wrote `.` sees `.` back. It is not a round trip in the
|
|
171
|
+
* other direction — an author who wrote `[^\n]` also sees `.` — but the two are the same
|
|
172
|
+
* set of characters once corrected, and `.` is overwhelmingly the form people write. The
|
|
173
|
+
* refusal prose is validator-defined by contract and deliberately not pinned by the
|
|
174
|
+
* shared corpus, so an equivalent spelling is within what that contract allows.
|
|
175
|
+
*/
|
|
176
|
+
function authoredPatternSource(pattern) {
|
|
177
|
+
return pattern.split("[^\n]").join(".");
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* correctRegexes returns a copy of the document with every regex-valued position
|
|
181
|
+
* corrected.
|
|
182
|
+
*
|
|
183
|
+
* The walk mirrors the safety scan exactly, and for the same reasons: a `const` holds
|
|
184
|
+
* DATA whose contents are never read as keywords, and `properties`/`$defs` map NAMES to
|
|
185
|
+
* subschemas, so a property literally called "pattern" is a property name and not a
|
|
186
|
+
* regex. Correcting the SOURCE rather than the matcher reaches every place ajv compiles
|
|
187
|
+
* a regex — the `pattern` keyword, the `patternProperties` keys, and the matched-key
|
|
188
|
+
* scans behind `additionalProperties` and `unevaluatedProperties` — including any a
|
|
189
|
+
* later release adds.
|
|
190
|
+
*/
|
|
191
|
+
function correctRegexes(node) {
|
|
192
|
+
if (Array.isArray(node))
|
|
193
|
+
return node.map(correctRegexes);
|
|
194
|
+
if (node === null || typeof node !== "object")
|
|
195
|
+
return node;
|
|
196
|
+
const out = {};
|
|
197
|
+
for (const [key, child] of Object.entries(node)) {
|
|
198
|
+
if (nonSchemaKeywords.has(key)) {
|
|
199
|
+
out[key] = child;
|
|
200
|
+
}
|
|
201
|
+
else if (key === "pattern" && typeof child === "string") {
|
|
202
|
+
out[key] = correctedPatternSource(child);
|
|
203
|
+
}
|
|
204
|
+
else if (key === "patternProperties" && child !== null && typeof child === "object") {
|
|
205
|
+
// The regexes are the KEYS here, which is why correcting the matcher for the
|
|
206
|
+
// `pattern` keyword alone would never reach them.
|
|
207
|
+
out[key] = Object.fromEntries(Object.entries(child).map(([k, v]) => [
|
|
208
|
+
correctedPatternSource(k),
|
|
209
|
+
correctRegexes(v),
|
|
210
|
+
]));
|
|
211
|
+
}
|
|
212
|
+
else if (schemaMapKeywords.has(key) && child !== null && typeof child === "object") {
|
|
213
|
+
out[key] = Object.fromEntries(Object.entries(child).map(([n, sub]) => [n, correctRegexes(sub)]));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
out[key] = correctRegexes(child);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* isJsonBlank reports whether `bytes` carries nothing but JSON whitespace, which is
|
|
223
|
+
* what "this Exchange publishes no data_schema" looks like in bytes.
|
|
224
|
+
*
|
|
225
|
+
* The definition is RFC 8259's and nothing wider: space, tab, carriage return and line
|
|
226
|
+
* feed. It is deliberately NOT each language's idea of whitespace, because that is
|
|
227
|
+
* three different sets — Go's `unicode.IsSpace` takes U+00A0 and U+3000, a Python
|
|
228
|
+
* `bytes.strip()` takes neither, and this runtime's `String.trim` takes both. This gate
|
|
229
|
+
* decides the ENFORCEMENT SWITCH: reading a document as blank means reading it as "no
|
|
230
|
+
* schema published", which turns validation off.
|
|
231
|
+
*
|
|
232
|
+
* It asks the question over the BYTES rather than a decoded string, which matters most
|
|
233
|
+
* here: `TextDecoder` strips a leading byte order mark, so decoding first made "mark
|
|
234
|
+
* followed by a space" look blank and returned `not_published` — silently bypassing the
|
|
235
|
+
* rule below that exists to refuse a mark, and turning enforcement off for a document
|
|
236
|
+
* the other two SDKs call malformed.
|
|
237
|
+
*/
|
|
238
|
+
function isJsonBlank(bytes) {
|
|
239
|
+
for (const b of bytes) {
|
|
240
|
+
if (b !== 0x20 && b !== 0x09 && b !== 0x0d && b !== 0x0a)
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* hexEscapeLen reports the length of a `\xHH` escape at `i`, or 0 if there is not one
|
|
247
|
+
* there. Exactly two hex digits: `\x41` is read the same by all three engines, while
|
|
248
|
+
* the brace form `\x{41}` is an RE2 spelling the other two refuse and a short `\x4` is
|
|
249
|
+
* refused by all three.
|
|
250
|
+
*/
|
|
251
|
+
function hexEscapeLen(pattern, i) {
|
|
252
|
+
if (pattern[i] !== "\\" || pattern[i + 1] !== "x" || i + 3 >= pattern.length)
|
|
253
|
+
return 0;
|
|
254
|
+
if (!/^[0-9a-fA-F]{2}$/.test(pattern.slice(i + 2, i + 4)))
|
|
255
|
+
return 0;
|
|
256
|
+
return 4;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* isRangeHyphenAt reports whether the character at `i` is a "-" acting as a range
|
|
260
|
+
* operator inside a bracket expression, rather than the literal hyphen a class may
|
|
261
|
+
* end with ("[a-z-]").
|
|
262
|
+
*/
|
|
263
|
+
function isRangeHyphenAt(pattern, i) {
|
|
264
|
+
return pattern[i] === "-" && i + 1 < pattern.length && pattern[i + 1] !== "]";
|
|
265
|
+
}
|
|
266
|
+
// Keywords whose value is arbitrary JSON DATA rather than a subschema. Their
|
|
267
|
+
// contents are never read as keywords, so a `const` carrying a "$ref" member is
|
|
268
|
+
// data. Their nesting is still bounded, by the lexical depth scan over the raw bytes.
|
|
269
|
+
const nonSchemaKeywords = new Set(["const", "default", "enum", "examples"]);
|
|
270
|
+
// Keywords that map NAMES to subschemas. Their child keys are property or
|
|
271
|
+
// definition names rather than keywords: {"properties": {"$ref": {...}}} declares
|
|
272
|
+
// a property called "$ref", not a reference.
|
|
273
|
+
const schemaMapKeywords = new Set([
|
|
274
|
+
"properties",
|
|
275
|
+
"patternProperties",
|
|
276
|
+
"$defs",
|
|
277
|
+
"definitions",
|
|
278
|
+
"dependentSchemas",
|
|
279
|
+
]);
|
|
280
|
+
const referenceKeywords = ["$ref", "$dynamicRef", "$recursiveRef"];
|
|
281
|
+
/**
|
|
282
|
+
* The largest {n,m} bound admitted. RE2 refuses a repeat count over 1000 outright
|
|
283
|
+
* while the other two engines expand it, so a larger bound is a pattern one SDK
|
|
284
|
+
* compiles and another does not.
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* The largest {n,m} bound admitted. RE2 refuses a repeat count over 1000 outright while
|
|
288
|
+
* the other two engines expand it, so a larger bound is a pattern one SDK compiles and
|
|
289
|
+
* another does not. Exported so the parity suite can hold it against the corpus header:
|
|
290
|
+
* it and the escape alphabet are the two values that decide which patterns this port
|
|
291
|
+
* admits, and they were the two the suite did not check.
|
|
292
|
+
*/
|
|
293
|
+
export const maxPortableRepeat = 1000;
|
|
294
|
+
/**
|
|
295
|
+
* The length of the counted repeat starting at `s`, including its closing brace, or 0
|
|
296
|
+
* if `s` opens none that every engine reads the same way. A `{` that opens no valid
|
|
297
|
+
* quantifier is refused rather than treated as a literal, because whether it IS a
|
|
298
|
+
* literal is precisely what the engines disagree about.
|
|
299
|
+
*
|
|
300
|
+
* It returns a LENGTH rather than a boolean so the caller can consume the whole
|
|
301
|
+
* `{n,m}`. That is what lets an unmatched `}` be refused: once every well-formed
|
|
302
|
+
* quantifier is stepped over, a `}` the scan still reaches closes nothing.
|
|
303
|
+
*
|
|
304
|
+
* The first bound must be present. `a{,5}` is the shape that forced this: RE2 reads it
|
|
305
|
+
* as the five literal characters and Python reads it as a repeat of zero to five, so
|
|
306
|
+
* both compile the pattern and then disagree about which payloads match it, with
|
|
307
|
+
* nothing logged. The empty part is still allowed AFTER the comma, because `{n,}` is
|
|
308
|
+
* the ordinary open-ended repeat and every engine agrees on it.
|
|
309
|
+
*
|
|
310
|
+
* The body is split on EVERY comma, not with a limit. `String.split`'s second argument
|
|
311
|
+
* caps the result array and DISCARDS the remainder, where Go's SplitN and Python's
|
|
312
|
+
* maxsplit keep it in the last element — so `"1,2,3".split(",", 2)` was `["1","2"]`
|
|
313
|
+
* here and `["1", "2,3"]` there, and this port alone admitted `a{1,2,3}`.
|
|
314
|
+
*/
|
|
315
|
+
function quantifierLen(s) {
|
|
316
|
+
const end = s.indexOf("}");
|
|
317
|
+
if (end < 0)
|
|
318
|
+
return 0;
|
|
319
|
+
const body = s.slice(1, end);
|
|
320
|
+
if (body === "")
|
|
321
|
+
return 0;
|
|
322
|
+
const parts = body.split(",");
|
|
323
|
+
if (parts.length > 2)
|
|
324
|
+
return 0;
|
|
325
|
+
for (const [i, part] of parts.entries()) {
|
|
326
|
+
if (part === "") {
|
|
327
|
+
if (i === 0)
|
|
328
|
+
return 0; // "{,5}" — two engines, two readings
|
|
329
|
+
continue; // "{n,}" is well formed
|
|
330
|
+
}
|
|
331
|
+
if (!/^[0-9]+$/.test(part))
|
|
332
|
+
return 0;
|
|
333
|
+
if (Number(part) > maxPortableRepeat)
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
return end + 1;
|
|
337
|
+
}
|
|
338
|
+
/** The text between the parenthesis at `open` and its match, plus the index past it. */
|
|
339
|
+
function groupBody(p, open) {
|
|
340
|
+
let depth = 0;
|
|
341
|
+
let inClass = false;
|
|
342
|
+
for (let i = open; i < p.length; i++) {
|
|
343
|
+
const ch = p[i];
|
|
344
|
+
if (ch === "\\") {
|
|
345
|
+
i++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (ch === "[")
|
|
349
|
+
inClass = true;
|
|
350
|
+
else if (ch === "]")
|
|
351
|
+
inClass = false;
|
|
352
|
+
else if (ch === "(" && !inClass)
|
|
353
|
+
depth++;
|
|
354
|
+
else if (ch === ")" && !inClass) {
|
|
355
|
+
depth--;
|
|
356
|
+
if (depth === 0)
|
|
357
|
+
return { body: p.slice(open + 1, i), after: i + 1 };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
/** Remove escaped characters so an escaped metacharacter is not read as a quantifier. */
|
|
363
|
+
function stripEscapes(s) {
|
|
364
|
+
let out = "";
|
|
365
|
+
for (let i = 0; i < s.length; i++) {
|
|
366
|
+
if (s[i] === "\\") {
|
|
367
|
+
i++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
out += s[i];
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Whether the pattern quantifies a group whose body can itself repeat or branch — the
|
|
376
|
+
* shape that makes a backtracking engine explore exponentially many ways to match one
|
|
377
|
+
* input.
|
|
378
|
+
*
|
|
379
|
+
* This is the half of the catastrophic-backtracking answer that excluding lookaround
|
|
380
|
+
* and backreferences does not cover: nested quantifiers need neither, and every
|
|
381
|
+
* classic form — `(a+)+`, `(a|a)*`, `([a-z]+)*`, `(?:a*)*` — sits comfortably inside
|
|
382
|
+
* the rest of the alphabet. It has to be STATIC: a regex spin blocks this runtime's
|
|
383
|
+
* event loop, so no timer here could stop one.
|
|
384
|
+
*
|
|
385
|
+
* Deliberately coarse — a quantified group whose body contains any of `* + ? { |`.
|
|
386
|
+
* Deciding whether a particular body is genuinely ambiguous is not decidable in
|
|
387
|
+
* general, and the conservative answer costs an author a rewrite while the permissive
|
|
388
|
+
* one costs a service its availability.
|
|
389
|
+
*/
|
|
390
|
+
function hasNestedQuantifier(p) {
|
|
391
|
+
let inClass = false;
|
|
392
|
+
for (let i = 0; i < p.length; i++) {
|
|
393
|
+
const ch = p[i];
|
|
394
|
+
if (ch === "\\") {
|
|
395
|
+
i++;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (ch === "[")
|
|
399
|
+
inClass = true;
|
|
400
|
+
else if (ch === "]")
|
|
401
|
+
inClass = false;
|
|
402
|
+
else if (ch === "(" && !inClass) {
|
|
403
|
+
const found = groupBody(p, i);
|
|
404
|
+
if (found === null)
|
|
405
|
+
continue;
|
|
406
|
+
// A non-capturing group's "?:" is syntax, not content.
|
|
407
|
+
const body = found.body.startsWith("?:") ? found.body.slice(2) : found.body;
|
|
408
|
+
const next = p[found.after];
|
|
409
|
+
if ((next === "*" || next === "+" || next === "?" || next === "{") &&
|
|
410
|
+
/[*+?{|]/.test(stripEscapes(body))) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* isSafeSchemaPattern reports whether a `pattern` uses only constructs all three SDK
|
|
419
|
+
* languages express identically, and none that make a backtracking engine explode.
|
|
420
|
+
*
|
|
421
|
+
* Draft 2020-12 patterns are ECMA-262, and the three SDKs run three different engines
|
|
422
|
+
* over them. Two distinct failures follow, and this function answers only the first.
|
|
423
|
+
*
|
|
424
|
+
* Some constructs one engine cannot express at all — RE2 has no lookaround, this
|
|
425
|
+
* runtime has no inline flags — so no care at the call site reconciles them and they
|
|
426
|
+
* are refused here. Others every engine compiles and then reads DIFFERENTLY: `\d` is
|
|
427
|
+
* Unicode-aware in Python and ASCII here and in RE2, and Python's `$` also matches
|
|
428
|
+
* before a trailing newline. Those are not refused — they appear in almost every real
|
|
429
|
+
* pattern — they are corrected in the port that diverges, which is Python.
|
|
430
|
+
*
|
|
431
|
+
* The escape rule is an ALLOWLIST rather than a list of the divergent escapes, because
|
|
432
|
+
* the divergent set is open-ended: two successive reviews found new counterexamples by
|
|
433
|
+
* trying, which is the signature of a rule stated from the wrong side. The portable set
|
|
434
|
+
* is small, closed and checkable, and the corpus carries it as data.
|
|
435
|
+
*
|
|
436
|
+
* The last rule is about availability rather than agreement: a quantified group whose
|
|
437
|
+
* body can repeat or branch is refused, because that is what makes backtracking
|
|
438
|
+
* catastrophic and no timer here could stop one.
|
|
439
|
+
*/
|
|
440
|
+
export function isSafeSchemaPattern(pattern) {
|
|
441
|
+
let inClass = false;
|
|
442
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
443
|
+
const ch = pattern[i];
|
|
444
|
+
if (ch === "\\") {
|
|
445
|
+
if (i + 1 >= pattern.length) {
|
|
446
|
+
// A trailing backslash is not a pattern any engine compiles.
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
if (hexEscapeLen(pattern, i) > 0) {
|
|
450
|
+
i += 3; // the whole \xHH is consumed, never re-read as syntax
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const next = pattern[i + 1];
|
|
454
|
+
if (!portableEscapes.has(next) && !portableSyntaxEscapes.has(next))
|
|
455
|
+
return false;
|
|
456
|
+
// A range whose endpoint is a shorthand CLASS rather than a character —
|
|
457
|
+
// "[\w-x]". RE2 reads it as a range and compiles; Python and this runtime
|
|
458
|
+
// under the `u` flag both refuse it. Only the adjacency is refused.
|
|
459
|
+
if (inClass && shorthandClassEscapes.has(next) && isRangeHyphenAt(pattern, i + 2)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
i++; // the escaped character is consumed, never re-read as syntax
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// The mirror of the case above: "[a-\w]".
|
|
466
|
+
if (ch === "-" &&
|
|
467
|
+
inClass &&
|
|
468
|
+
isRangeHyphenAt(pattern, i) &&
|
|
469
|
+
pattern[i + 1] === "\\" &&
|
|
470
|
+
shorthandClassEscapes.has(pattern[i + 2] ?? "")) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (ch === "[") {
|
|
474
|
+
if (inClass) {
|
|
475
|
+
// A literal "[" inside a class — EXCEPT when it opens a POSIX name.
|
|
476
|
+
// "[:alpha:]" is a character class to RE2 and the literal characters
|
|
477
|
+
// ":alph" here: both compile, then match different strings.
|
|
478
|
+
if (pattern.startsWith("[:", i))
|
|
479
|
+
return false;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
inClass = true;
|
|
483
|
+
let rest = pattern.slice(i + 1);
|
|
484
|
+
if (rest.startsWith("^"))
|
|
485
|
+
rest = rest.slice(1);
|
|
486
|
+
// "]" straight after the opening bracket is a literal in POSIX and an
|
|
487
|
+
// empty class in ECMA; the engines disagree about whether it compiles.
|
|
488
|
+
if (rest.startsWith("]") || rest === "")
|
|
489
|
+
return false;
|
|
490
|
+
if (pattern.startsWith("[[:", i))
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
else if (ch === "]") {
|
|
494
|
+
// An unmatched "]" is a literal to RE2 and a syntax error here.
|
|
495
|
+
if (!inClass)
|
|
496
|
+
return false;
|
|
497
|
+
inClass = false;
|
|
498
|
+
}
|
|
499
|
+
else if (ch === "(" && !inClass) {
|
|
500
|
+
if (pattern[i + 1] === "?") {
|
|
501
|
+
// Everything spelled "(?..." is refused except the non-capturing
|
|
502
|
+
// group, the only one all three engines write the same way.
|
|
503
|
+
if (pattern[i + 2] !== ":")
|
|
504
|
+
return false;
|
|
505
|
+
i += 2;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else if (ch === "{" && !inClass) {
|
|
509
|
+
const consumed = quantifierLen(pattern.slice(i));
|
|
510
|
+
if (consumed === 0)
|
|
511
|
+
return false;
|
|
512
|
+
i += consumed - 1; // consume through the closing brace
|
|
513
|
+
}
|
|
514
|
+
else if (ch === "}" && !inClass) {
|
|
515
|
+
// Every well-formed quantifier was stepped over above, so a "}" reached here
|
|
516
|
+
// closes nothing. RE2 and Python read it as a literal and ECMA-262 under the
|
|
517
|
+
// `u` flag refuses it outright — the same split the unmatched "]" rule exists
|
|
518
|
+
// for, so it gets the same answer. A literal brace is written "\}", which the
|
|
519
|
+
// alphabet admits.
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// An unclosed class is a literal "[" to RE2 and a syntax error here.
|
|
524
|
+
if (inClass)
|
|
525
|
+
return false;
|
|
526
|
+
return !hasNestedQuantifier(pattern);
|
|
527
|
+
}
|
|
528
|
+
/** The 2020-12 identifier in the two spellings that name the same dialect. */
|
|
529
|
+
function isDialect(value) {
|
|
530
|
+
return value === registrationSchemaDialect || value === `${registrationSchemaDialect}#`;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Object keys in CODE POINT order.
|
|
534
|
+
*
|
|
535
|
+
* JavaScript's default sort compares UTF-16 code units, which orders an astral
|
|
536
|
+
* character BEFORE U+FFFF where Go and Python order it after. Because the scan
|
|
537
|
+
* answers with the FIRST fault it finds, that difference changed the compile verdict:
|
|
538
|
+
* a document with two faults under two keys answered `remote_ref` here and
|
|
539
|
+
* `wrong_dialect` in the other two.
|
|
540
|
+
*/
|
|
541
|
+
function sortedKeys(o) {
|
|
542
|
+
return Object.keys(o).sort(cmp);
|
|
543
|
+
}
|
|
544
|
+
function isPlainObject(v) {
|
|
545
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
546
|
+
}
|
|
547
|
+
/** Per-object rules, in the fixed order a document breaking two must answer. */
|
|
548
|
+
function checkKeywords(obj) {
|
|
549
|
+
const dialect = obj["$schema"];
|
|
550
|
+
if (typeof dialect === "string" && !isDialect(dialect))
|
|
551
|
+
return "wrong_dialect";
|
|
552
|
+
for (const keyword of referenceKeywords) {
|
|
553
|
+
const ref = obj[keyword];
|
|
554
|
+
// A same-document reference starts at the document root ("#/$defs/x") or at
|
|
555
|
+
// an anchor in it ("#name"). Anything else names a resource this document
|
|
556
|
+
// does not carry, and resolving it is the fetch the contract forbids.
|
|
557
|
+
if (typeof ref === "string" && !ref.startsWith("#"))
|
|
558
|
+
return "remote_ref";
|
|
559
|
+
}
|
|
560
|
+
const pattern = obj["pattern"];
|
|
561
|
+
if (typeof pattern === "string" && !isSafeSchemaPattern(pattern))
|
|
562
|
+
return "unsafe_pattern";
|
|
563
|
+
// patternProperties states its regexes as KEYS, so they are checked here rather
|
|
564
|
+
// than by the generic pattern branch above.
|
|
565
|
+
const patternProperties = obj["patternProperties"];
|
|
566
|
+
if (isPlainObject(patternProperties)) {
|
|
567
|
+
for (const key of sortedKeys(patternProperties)) {
|
|
568
|
+
if (!isSafeSchemaPattern(key))
|
|
569
|
+
return "unsafe_pattern";
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return "accepted";
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Walk a keyword whose child object maps NAMES to subschemas. The child's keys are
|
|
576
|
+
* property or definition names, never keywords, so only its values are read as
|
|
577
|
+
* schemas.
|
|
578
|
+
*/
|
|
579
|
+
function scanSchemaMap(child) {
|
|
580
|
+
if (!isPlainObject(child)) {
|
|
581
|
+
// Not the shape the keyword takes; walk it generically rather than guess. An
|
|
582
|
+
// invalid schema is the compiler's to reject.
|
|
583
|
+
return scan(child);
|
|
584
|
+
}
|
|
585
|
+
for (const name of sortedKeys(child)) {
|
|
586
|
+
const verdict = scan(child[name]);
|
|
587
|
+
if (verdict !== "accepted")
|
|
588
|
+
return verdict;
|
|
589
|
+
}
|
|
590
|
+
return "accepted";
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Walk the decoded document once, enforcing the dialect, reference and pattern
|
|
594
|
+
* rules. Depth is NOT its business — rawNestingDepth owns that bound and has
|
|
595
|
+
* already run, which is what lets this walk recurse freely. The first failure
|
|
596
|
+
* decides.
|
|
597
|
+
*/
|
|
598
|
+
function scan(node) {
|
|
599
|
+
if (Array.isArray(node)) {
|
|
600
|
+
for (const item of node) {
|
|
601
|
+
const verdict = scan(item);
|
|
602
|
+
if (verdict !== "accepted")
|
|
603
|
+
return verdict;
|
|
604
|
+
}
|
|
605
|
+
return "accepted";
|
|
606
|
+
}
|
|
607
|
+
if (!isPlainObject(node))
|
|
608
|
+
return "accepted";
|
|
609
|
+
const own = checkKeywords(node);
|
|
610
|
+
if (own !== "accepted")
|
|
611
|
+
return own;
|
|
612
|
+
// Sorted so a document with two faults answers the same way on every run and in
|
|
613
|
+
// every language.
|
|
614
|
+
for (const key of sortedKeys(node)) {
|
|
615
|
+
// A non-schema keyword's value is DATA. Its contents are not read as keywords
|
|
616
|
+
// at all, so a `const` carrying a "$ref" member is a value a payload may equal
|
|
617
|
+
// rather than a reference to resolve.
|
|
618
|
+
if (nonSchemaKeywords.has(key))
|
|
619
|
+
continue;
|
|
620
|
+
const child = node[key];
|
|
621
|
+
const verdict = schemaMapKeywords.has(key) ? scanSchemaMap(child) : scan(child);
|
|
622
|
+
if (verdict !== "accepted")
|
|
623
|
+
return verdict;
|
|
624
|
+
}
|
|
625
|
+
return "accepted";
|
|
626
|
+
}
|
|
627
|
+
// --- work bound -----------------------------------------------------------------
|
|
628
|
+
// Keywords holding a LIST of subschemas, each of which may be evaluated against the
|
|
629
|
+
// same instance. They are what makes cost multiply along a reference chain.
|
|
630
|
+
const branchKeywords = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
|
631
|
+
// Keywords holding exactly one subschema.
|
|
632
|
+
const singleSubschemaKeywords = new Set([
|
|
633
|
+
"not", "if", "then", "else", "items", "contains", "propertyNames",
|
|
634
|
+
"additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contentSchema",
|
|
635
|
+
]);
|
|
636
|
+
// Saturation point: past the cap the exact value carries no information.
|
|
637
|
+
const costCeiling = maxRegistrationSchemaEvaluations + 1;
|
|
638
|
+
function addCost(a, b) {
|
|
639
|
+
if (a >= costCeiling || b >= costCeiling || a + b >= costCeiling)
|
|
640
|
+
return costCeiling;
|
|
641
|
+
return a + b;
|
|
642
|
+
}
|
|
643
|
+
/** Index every `$anchor` so a "#name" reference resolves without a second walk. */
|
|
644
|
+
function collectAnchors(node, out) {
|
|
645
|
+
if (Array.isArray(node)) {
|
|
646
|
+
for (const item of node)
|
|
647
|
+
collectAnchors(item, out);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!isPlainObject(node))
|
|
651
|
+
return;
|
|
652
|
+
const anchor = node["$anchor"];
|
|
653
|
+
if (typeof anchor === "string" && !out.has(anchor))
|
|
654
|
+
out.set(anchor, node);
|
|
655
|
+
for (const key of sortedKeys(node))
|
|
656
|
+
collectAnchors(node[key], out);
|
|
657
|
+
}
|
|
658
|
+
class CostWalker {
|
|
659
|
+
#root;
|
|
660
|
+
#anchors = new Map();
|
|
661
|
+
#memo = new Map();
|
|
662
|
+
#onStack = new Set();
|
|
663
|
+
verdict = "accepted";
|
|
664
|
+
constructor(root) {
|
|
665
|
+
this.#root = root;
|
|
666
|
+
collectAnchors(root, this.#anchors);
|
|
667
|
+
}
|
|
668
|
+
fail(v) {
|
|
669
|
+
if (this.verdict === "accepted")
|
|
670
|
+
this.verdict = v;
|
|
671
|
+
return costCeiling;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* The worst-case evaluations `node` can require against one instance, and the longest
|
|
675
|
+
* chain of `$ref` hops starting anywhere inside it.
|
|
676
|
+
*
|
|
677
|
+
* A boolean schema is one evaluation; an object schema is itself plus everything it
|
|
678
|
+
* can delegate to. The second number rides along because the reference bound is over
|
|
679
|
+
* the longest PATH, so something has to compute it, and this walk already follows
|
|
680
|
+
* every reference to its target — counting the two together is what keeps them
|
|
681
|
+
* consistent.
|
|
682
|
+
*
|
|
683
|
+
* `$defs` and `definitions` are deliberately NOT counted — they are reachable only
|
|
684
|
+
* through a reference, and counting them here as well would charge a shared
|
|
685
|
+
* definition once per declaration plus once per use.
|
|
686
|
+
*/
|
|
687
|
+
cost(node, hops) {
|
|
688
|
+
if (this.verdict !== "accepted")
|
|
689
|
+
return { cost: costCeiling, below: 0 };
|
|
690
|
+
if (!isPlainObject(node))
|
|
691
|
+
return { cost: 1, below: 0 };
|
|
692
|
+
let total = 1;
|
|
693
|
+
let below = 0;
|
|
694
|
+
for (const key of sortedKeys(node)) {
|
|
695
|
+
const value = node[key];
|
|
696
|
+
if (nonSchemaKeywords.has(key) || key === "$defs" || key === "definitions")
|
|
697
|
+
continue;
|
|
698
|
+
if (referenceKeywords.includes(key)) {
|
|
699
|
+
if (typeof value === "string") {
|
|
700
|
+
const r = this.refCost(value, hops + 1);
|
|
701
|
+
total = addCost(total, r.cost);
|
|
702
|
+
// The reference is itself a hop, so the chain through it is one longer
|
|
703
|
+
// than whatever chain remains below its target.
|
|
704
|
+
below = Math.max(below, 1 + r.below);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
else if (branchKeywords.has(key)) {
|
|
708
|
+
if (Array.isArray(value)) {
|
|
709
|
+
for (const item of value) {
|
|
710
|
+
const r = this.cost(item, hops);
|
|
711
|
+
total = addCost(total, r.cost);
|
|
712
|
+
below = Math.max(below, r.below);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
const r = this.cost(value, hops);
|
|
717
|
+
total = addCost(total, r.cost);
|
|
718
|
+
below = Math.max(below, r.below);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
else if (singleSubschemaKeywords.has(key)) {
|
|
722
|
+
const r = this.cost(value, hops);
|
|
723
|
+
total = addCost(total, r.cost);
|
|
724
|
+
below = Math.max(below, r.below);
|
|
725
|
+
}
|
|
726
|
+
else if (schemaMapKeywords.has(key)) {
|
|
727
|
+
if (isPlainObject(value)) {
|
|
728
|
+
for (const name of sortedKeys(value)) {
|
|
729
|
+
const r = this.cost(value[name], hops);
|
|
730
|
+
total = addCost(total, r.cost);
|
|
731
|
+
below = Math.max(below, r.below);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
const r = this.cost(value, hops);
|
|
736
|
+
total = addCost(total, r.cost);
|
|
737
|
+
below = Math.max(below, r.below);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (total >= costCeiling)
|
|
741
|
+
return { cost: costCeiling, below };
|
|
742
|
+
}
|
|
743
|
+
return { cost: total, below };
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Count a reference's target once and remember it, and enforce the chain bound at
|
|
747
|
+
* this reference. A location already being counted is a cycle: its cost is not
|
|
748
|
+
* finite, and it is what makes this port abort rather than answer.
|
|
749
|
+
*
|
|
750
|
+
* The chain through this reference is `hops + below`: what it took to arrive, plus
|
|
751
|
+
* what remains beneath the target. Both halves are needed. A count that watches only
|
|
752
|
+
* the first half measures the walk instead of the document — it passes a chain split
|
|
753
|
+
* into short segments that are each entered from the root, and it answers the same
|
|
754
|
+
* graph differently depending on the order that document happens to list its
|
|
755
|
+
* references in.
|
|
756
|
+
*/
|
|
757
|
+
refCost(ref, hops) {
|
|
758
|
+
const { facts, judge } = this.#refFacts(ref, hops);
|
|
759
|
+
if (!judge)
|
|
760
|
+
return facts;
|
|
761
|
+
// ONE check, at the reference site, so it is applied identically whether the
|
|
762
|
+
// target was just walked or was already known. Splitting it — once where a target
|
|
763
|
+
// is counted and again where the memo answers — states the same condition twice,
|
|
764
|
+
// and a condition stated twice is a condition neither statement is responsible
|
|
765
|
+
// for.
|
|
766
|
+
if (hops + facts.below > maxRegistrationSchemaRefHops) {
|
|
767
|
+
return { cost: this.fail("ref_chain_too_long"), below: facts.below };
|
|
768
|
+
}
|
|
769
|
+
return facts;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* A reference target's facts, from the memo when the target has been counted before
|
|
773
|
+
* and by walking it otherwise.
|
|
774
|
+
*
|
|
775
|
+
* `judge` is false when the walk has already reached a verdict of its own — a cycle,
|
|
776
|
+
* an unresolvable reference, a chain past the recursion guard — and the caller should
|
|
777
|
+
* propagate that rather than measure a chain that no longer means anything.
|
|
778
|
+
*/
|
|
779
|
+
#refFacts(ref, hops) {
|
|
780
|
+
const memo = this.#memo.get(ref);
|
|
781
|
+
if (memo !== undefined)
|
|
782
|
+
return { facts: memo, judge: true };
|
|
783
|
+
if (this.#onStack.has(ref)) {
|
|
784
|
+
return { facts: { cost: this.fail("ref_cycle"), below: 0 }, judge: false };
|
|
785
|
+
}
|
|
786
|
+
// After the cycle check, so a chain that closes on itself still reports the more
|
|
787
|
+
// specific diagnosis. `hops` is tracked explicitly rather than read off the size
|
|
788
|
+
// of #onStack: the two agree here, where the walk recurses and the set mirrors the
|
|
789
|
+
// path, and they do NOT agree in a port whose walk is iterative and whose
|
|
790
|
+
// equivalent set is a search frontier. The bound is a number in the contract, so
|
|
791
|
+
// the three SDKs compute the same quantity by construction, not by coincidence.
|
|
792
|
+
//
|
|
793
|
+
// This is NOT the bound — the bound is the hops+below check above, which is
|
|
794
|
+
// exact. It is a recursion guard: cost and refCost call each other, so without it
|
|
795
|
+
// a chain as long as the size cap admits would be walked as deep as it is long
|
|
796
|
+
// before anything refused it.
|
|
797
|
+
if (hops > maxRegistrationSchemaRefHops) {
|
|
798
|
+
return { facts: { cost: this.fail("ref_chain_too_long"), below: 0 }, judge: false };
|
|
799
|
+
}
|
|
800
|
+
const target = this.resolve(ref);
|
|
801
|
+
if (!target.ok) {
|
|
802
|
+
return { facts: { cost: this.fail("uncompilable"), below: 0 }, judge: false };
|
|
803
|
+
}
|
|
804
|
+
this.#onStack.add(ref);
|
|
805
|
+
const facts = this.cost(target.node, hops);
|
|
806
|
+
this.#onStack.delete(ref);
|
|
807
|
+
// A walk cut short by the cost ceiling memoises a partial `below`, which cannot
|
|
808
|
+
// produce a wrong accept: a saturated cost is maxRegistrationSchemaEvaluations+1,
|
|
809
|
+
// so the document is refused as too complex whatever its chain length.
|
|
810
|
+
this.#memo.set(ref, facts);
|
|
811
|
+
return { facts, judge: true };
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Follow a same-document reference. The scan has already refused anything not
|
|
815
|
+
* beginning with "#", so only three forms reach here: the whole document, an RFC
|
|
816
|
+
* 6901 pointer into it, and a `$anchor` name.
|
|
817
|
+
*/
|
|
818
|
+
resolve(ref) {
|
|
819
|
+
const frag = ref.startsWith("#") ? ref.slice(1) : ref;
|
|
820
|
+
if (frag === "")
|
|
821
|
+
return { node: this.#root, ok: true };
|
|
822
|
+
if (!frag.startsWith("/")) {
|
|
823
|
+
const target = this.#anchors.get(frag);
|
|
824
|
+
return { node: target, ok: this.#anchors.has(frag) };
|
|
825
|
+
}
|
|
826
|
+
let node = this.#root;
|
|
827
|
+
for (const raw of frag.slice(1).split("/")) {
|
|
828
|
+
const token = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
829
|
+
if (isPlainObject(node)) {
|
|
830
|
+
if (!(token in node))
|
|
831
|
+
return { node: undefined, ok: false };
|
|
832
|
+
node = node[token];
|
|
833
|
+
}
|
|
834
|
+
else if (Array.isArray(node)) {
|
|
835
|
+
if (!/^[0-9]+$/.test(token) || Number(token) >= node.length) {
|
|
836
|
+
return { node: undefined, ok: false };
|
|
837
|
+
}
|
|
838
|
+
node = node[Number(token)];
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
return { node: undefined, ok: false };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { node, ok: true };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Bound how much work validating a payload can cost. The size and depth caps bound
|
|
849
|
+
* the DOCUMENT and say nothing about this.
|
|
850
|
+
*/
|
|
851
|
+
function checkEvaluationCost(doc) {
|
|
852
|
+
const walker = new CostWalker(doc);
|
|
853
|
+
const { cost } = walker.cost(doc, 0);
|
|
854
|
+
if (walker.verdict !== "accepted")
|
|
855
|
+
return walker.verdict;
|
|
856
|
+
return cost > maxRegistrationSchemaEvaluations ? "too_complex" : "accepted";
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* clampPointer keeps a pointer inside the wire's length bound WITHOUT truncating
|
|
860
|
+
* it. A pointer cut mid-token addresses a different member — or none — so an
|
|
861
|
+
* over-long one degrades to the longest ANCESTOR that fits.
|
|
862
|
+
*/
|
|
863
|
+
function clampPointer(pointer) {
|
|
864
|
+
// CODE POINTS, not UTF-16 code units. protovalidate's string.max_len counts
|
|
865
|
+
// characters, and Go and Python both count them — so measuring `.length` here made
|
|
866
|
+
// this port clamp a pointer the other two left whole, and a pointer of 201 astral
|
|
867
|
+
// characters degraded all the way to the empty string, naming the root object where
|
|
868
|
+
// the others named the failing member.
|
|
869
|
+
const chars = [...pointer];
|
|
870
|
+
if (chars.length <= maxRegistrationFieldErrorPathLen)
|
|
871
|
+
return pointer;
|
|
872
|
+
for (let i = chars.length - 1; i > 0; i--) {
|
|
873
|
+
if (chars[i] === "/" && i <= maxRegistrationFieldErrorPathLen)
|
|
874
|
+
return chars.slice(0, i).join("");
|
|
875
|
+
}
|
|
876
|
+
return "";
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* clampText keeps the constraint text inside the wire's bound. The field also has
|
|
880
|
+
* a minimum of one character, so an empty description becomes a generic one rather
|
|
881
|
+
* than a message the contract would reject.
|
|
882
|
+
*/
|
|
883
|
+
function clampText(text) {
|
|
884
|
+
if (!text)
|
|
885
|
+
return "does not conform to the published schema";
|
|
886
|
+
// CODE POINTS, for the same reason as clampPointer — and here a UTF-16 slice could
|
|
887
|
+
// also cut through a surrogate pair, emitting a lone surrogate where Go emits valid
|
|
888
|
+
// UTF-8. The wire counts characters; so does this.
|
|
889
|
+
const chars = [...text];
|
|
890
|
+
if (chars.length <= maxRegistrationFieldErrorTextLen)
|
|
891
|
+
return text;
|
|
892
|
+
return chars.slice(0, maxRegistrationFieldErrorTextLen).join("");
|
|
893
|
+
}
|
|
894
|
+
// Keywords whose constraint is a single schema-side bound worth stating verbatim.
|
|
895
|
+
const boundKeywords = new Set([
|
|
896
|
+
"exclusiveMaximum",
|
|
897
|
+
"exclusiveMinimum",
|
|
898
|
+
"maxContains",
|
|
899
|
+
"maxItems",
|
|
900
|
+
"maxLength",
|
|
901
|
+
"maxProperties",
|
|
902
|
+
"maximum",
|
|
903
|
+
"minContains",
|
|
904
|
+
"minItems",
|
|
905
|
+
"minLength",
|
|
906
|
+
"minProperties",
|
|
907
|
+
"minimum",
|
|
908
|
+
"multipleOf",
|
|
909
|
+
"pattern",
|
|
910
|
+
]);
|
|
911
|
+
// Keywords whose constraint has no short value-free rendering, so the text names
|
|
912
|
+
// the rule instead. `enum` and `const` omit the allowed values: they come off the
|
|
913
|
+
// schema and would be safe, but they can be long and are not needed to act.
|
|
914
|
+
// `additionalProperties` omits the offending member NAMES, which come off the
|
|
915
|
+
// PAYLOAD — a member name an operator chose is as much their data as its value.
|
|
916
|
+
const fixedText = {
|
|
917
|
+
additionalProperties: "additional properties are not allowed",
|
|
918
|
+
allOf: "must match every branch of allOf",
|
|
919
|
+
anyOf: "must match at least one branch of anyOf",
|
|
920
|
+
const: "must equal the value the schema fixes",
|
|
921
|
+
contains: "must contain a matching item",
|
|
922
|
+
dependentRequired: "dependentRequired",
|
|
923
|
+
enum: "must be one of the values the schema enumerates",
|
|
924
|
+
not: "must not match the schema under not",
|
|
925
|
+
oneOf: "must match exactly one branch of oneOf",
|
|
926
|
+
propertyNames: "property name does not match propertyNames",
|
|
927
|
+
uniqueItems: "items must be unique",
|
|
928
|
+
};
|
|
929
|
+
/**
|
|
930
|
+
* describe turns a failure into (keyword, constraint text).
|
|
931
|
+
*
|
|
932
|
+
* It reads ONLY the constraint side. Ajv's own `error.message` is never used,
|
|
933
|
+
* because it quotes the offending value for several keywords, and the wire
|
|
934
|
+
* contract forbids a refusal from carrying the submitted value back out.
|
|
935
|
+
*/
|
|
936
|
+
function describe(error) {
|
|
937
|
+
const keyword = error.keyword || "schema";
|
|
938
|
+
const params = error.params;
|
|
939
|
+
if (keyword === "required") {
|
|
940
|
+
// The name comes off the SCHEMA's required list, not off the payload — the
|
|
941
|
+
// value that failed is precisely the value that is absent.
|
|
942
|
+
const missing = params["missingProperty"];
|
|
943
|
+
return {
|
|
944
|
+
keyword,
|
|
945
|
+
text: typeof missing === "string" ? `required: ${missing}` : "required",
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
if (keyword === "type") {
|
|
949
|
+
const want = params["type"];
|
|
950
|
+
const wanted = Array.isArray(want) ? want : [want];
|
|
951
|
+
return { keyword, text: `must be of type ${wanted.join(" or ")}` };
|
|
952
|
+
}
|
|
953
|
+
if (keyword === "pattern") {
|
|
954
|
+
// The AUTHOR's pattern, not this port's corrected copy: an operator reading a
|
|
955
|
+
// refusal should see the regex their Exchange published.
|
|
956
|
+
return { keyword, text: `pattern: ${authoredPatternSource(String(params["pattern"]))}` };
|
|
957
|
+
}
|
|
958
|
+
if (boundKeywords.has(keyword)) {
|
|
959
|
+
// The bound comes off the schema, so naming it leaks nothing and is the one
|
|
960
|
+
// piece of detail that makes a refusal actionable.
|
|
961
|
+
const want = params["limit"] ?? params["pattern"] ?? params["multipleOf"];
|
|
962
|
+
return { keyword, text: `${keyword}: ${String(want)}` };
|
|
963
|
+
}
|
|
964
|
+
const fixed = fixedText[keyword];
|
|
965
|
+
if (fixed !== undefined)
|
|
966
|
+
return { keyword, text: fixed };
|
|
967
|
+
// Anything the table does not name still reports the keyword that failed, which
|
|
968
|
+
// is enough to act on and carries nothing off the payload by construction.
|
|
969
|
+
return { keyword, text: `does not satisfy ${keyword}` };
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* A compiled, accepted data_schema. Immutable and safe to share, so a server
|
|
973
|
+
* compiles the operator's schema once at start-up and a client caches one per
|
|
974
|
+
* Exchange.
|
|
975
|
+
*/
|
|
976
|
+
export class RegistrationSchema {
|
|
977
|
+
#validate;
|
|
978
|
+
constructor(validate) {
|
|
979
|
+
this.#validate = validate;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Check a registration_data payload and name what failed. An empty array means
|
|
983
|
+
* the payload conforms.
|
|
984
|
+
*
|
|
985
|
+
* The result is ready for the refusal builder: `path` an RFC 6901 pointer
|
|
986
|
+
* relative to registration_data ("" addresses the whole object), `error` the
|
|
987
|
+
* violated CONSTRAINT and never the submitted value.
|
|
988
|
+
*
|
|
989
|
+
* The order is deterministic — entries are deduplicated by pointer and keyword,
|
|
990
|
+
* then sorted by both, before the list is capped. Three validators walk a
|
|
991
|
+
* failing document in three different orders, so an unsorted list is one no
|
|
992
|
+
* shared corpus could pin.
|
|
993
|
+
*/
|
|
994
|
+
validate(data) {
|
|
995
|
+
return this.violations(data).map(({ path, error }) => ({ path, error: clampText(error) }));
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The whole answer, keyword included. `validate` narrows it to the wire's
|
|
999
|
+
* two-field shape; the parity suite reads it whole, because the corpus pins the
|
|
1000
|
+
* keyword while `error` wording is validator-defined by contract.
|
|
1001
|
+
*/
|
|
1002
|
+
violations(data) {
|
|
1003
|
+
const instance = data === null || data === undefined ? {} : data;
|
|
1004
|
+
if (this.#validate(instance))
|
|
1005
|
+
return [];
|
|
1006
|
+
const errors = this.#validate.errors ?? [];
|
|
1007
|
+
// Drop any error that has descendants AT THE SAME INSTANCE: a composite
|
|
1008
|
+
// keyword (oneOf, anyOf, if) reports itself AND the branch failures underneath
|
|
1009
|
+
// it, while Go's oracle reports only the leaves.
|
|
1010
|
+
//
|
|
1011
|
+
// Both halves of the key are load-bearing. Keying on schemaPath alone — the
|
|
1012
|
+
// first version of this — dropped a composite failure at one member because an
|
|
1013
|
+
// UNRELATED error at a DIFFERENT member happened to sit under the same shared
|
|
1014
|
+
// subschema, so a genuinely failing member vanished from the refusal and the
|
|
1015
|
+
// operator was never told to fix it. A composite that failed with no branch
|
|
1016
|
+
// errors (oneOf matching two branches) is still a leaf and survives.
|
|
1017
|
+
// A composite is a leaf only when nothing beneath it failed. Nesting is read off
|
|
1018
|
+
// the schema path, and a $ref BREAKS that nesting: the branch's own failure is
|
|
1019
|
+
// reported under "#/$defs/..." rather than under the anyOf, so an anyOf whose
|
|
1020
|
+
// branches are ALL $refs looked childless and survived beside the real error.
|
|
1021
|
+
// "one of several defined formats" is an ordinary registration shape, so the
|
|
1022
|
+
// hop is recognised rather than assumed away.
|
|
1023
|
+
const leaves = errors.filter((e) => !errors.some((o) => o !== e && isBeneath(o, e)));
|
|
1024
|
+
const flat = [];
|
|
1025
|
+
const seen = new Map();
|
|
1026
|
+
for (const error of leaves) {
|
|
1027
|
+
const { keyword, text } = describe(error);
|
|
1028
|
+
const path = clampPointer(error.instancePath);
|
|
1029
|
+
const key = `${path}${keyword}`;
|
|
1030
|
+
const at = seen.get(key);
|
|
1031
|
+
if (at !== undefined) {
|
|
1032
|
+
// Where a key repeats, the lexicographically smallest text wins, so the
|
|
1033
|
+
// prose does not depend on which duplicate was walked first.
|
|
1034
|
+
if (cmp(text, flat[at].error) < 0)
|
|
1035
|
+
flat[at].error = text;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
seen.set(key, flat.length);
|
|
1039
|
+
flat.push({ path, keyword, error: text });
|
|
1040
|
+
}
|
|
1041
|
+
flat.sort((a, b) => (a.path === b.path ? cmp(a.keyword, b.keyword) : cmp(a.path, b.path)));
|
|
1042
|
+
return flat.slice(0, maxRegistrationFieldErrors);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// The keywords that fail as a WHOLE because every branch under them failed. Their own
|
|
1046
|
+
// error adds nothing an operator can act on once the branch failures are reported, so
|
|
1047
|
+
// it survives only when nothing beneath it did.
|
|
1048
|
+
const branchComposites = new Set(["anyOf", "oneOf"]);
|
|
1049
|
+
/**
|
|
1050
|
+
* isBeneath reports whether error `o` sits under error `e`, so that `e` is a composite
|
|
1051
|
+
* whose real cause is already reported and not a leaf.
|
|
1052
|
+
*
|
|
1053
|
+
* The schema-path test is the ordinary one. The composite test exists because a `$ref`
|
|
1054
|
+
* relocates the branch's failure out from under the composite — it is reported at the
|
|
1055
|
+
* definition it points to — leaving a nesting test nothing to match on.
|
|
1056
|
+
*/
|
|
1057
|
+
function isBeneath(o, e) {
|
|
1058
|
+
if (o.instancePath !== e.instancePath)
|
|
1059
|
+
return false;
|
|
1060
|
+
if (o.schemaPath.startsWith(`${e.schemaPath}/`))
|
|
1061
|
+
return true;
|
|
1062
|
+
return branchComposites.has(e.keyword) && !branchComposites.has(o.keyword);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* CODE POINT comparison, matching Go's byte order over UTF-8 and Python's ordering
|
|
1066
|
+
* over str.
|
|
1067
|
+
*
|
|
1068
|
+
* `a < b` compares UTF-16 code units, which is NOT the same order: a surrogate pair
|
|
1069
|
+
* (U+10000 and above) begins with 0xD800-0xDBFF and therefore sorts before U+E000-
|
|
1070
|
+
* U+FFFF, where the other two languages sort it after. That difference is not
|
|
1071
|
+
* cosmetic here — the scan answers with the first fault it finds, so key order
|
|
1072
|
+
* decides the verdict.
|
|
1073
|
+
*/
|
|
1074
|
+
function cmp(a, b) {
|
|
1075
|
+
const ac = [...a];
|
|
1076
|
+
const bc = [...b];
|
|
1077
|
+
const n = Math.min(ac.length, bc.length);
|
|
1078
|
+
for (let i = 0; i < n; i++) {
|
|
1079
|
+
const x = ac[i].codePointAt(0);
|
|
1080
|
+
const y = bc[i].codePointAt(0);
|
|
1081
|
+
if (x !== y)
|
|
1082
|
+
return x < y ? -1 : 1;
|
|
1083
|
+
}
|
|
1084
|
+
return ac.length - bc.length;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* compileRegistrationSchema checks a published data_schema against every rule and
|
|
1088
|
+
* compiles it.
|
|
1089
|
+
*
|
|
1090
|
+
* `raw` is the schema AS SERVED — the exact UTF-8 bytes of the data_schema member
|
|
1091
|
+
* in fora.json — because maxRegistrationSchemaBytes is defined over those bytes.
|
|
1092
|
+
*
|
|
1093
|
+
* The schema is null unless the verdict is "accepted". Nothing throws: every way
|
|
1094
|
+
* this can fail is a property of the schema, and both callers need to know WHICH.
|
|
1095
|
+
* They read the same refusal differently, and that difference is the contract. A
|
|
1096
|
+
* CLIENT pre-checking a payload treats any non-accepted verdict as "do not
|
|
1097
|
+
* pre-check" and sends anyway — the Exchange's enforcement is the deciding one, and
|
|
1098
|
+
* a client that refused here would block a payload the Exchange would have taken.
|
|
1099
|
+
* An EXCHANGE compiling its OWN configured schema treats the same verdict as an
|
|
1100
|
+
* operator misconfiguration, and must not advertise a schema it cannot enforce.
|
|
1101
|
+
*/
|
|
1102
|
+
export function compileRegistrationSchema(raw) {
|
|
1103
|
+
const bytes = typeof raw === "string" ? new TextEncoder().encode(raw) : raw;
|
|
1104
|
+
// Nothing to compile is its own answer, not a malformed document: an Exchange that
|
|
1105
|
+
// publishes no data_schema is the contract's ordinary case.
|
|
1106
|
+
if (isJsonBlank(bytes)) {
|
|
1107
|
+
return { schema: null, verdict: "not_published" };
|
|
1108
|
+
}
|
|
1109
|
+
// Size first, on the bytes as served and before any parse: an oversized document
|
|
1110
|
+
// must not be decoded to find out that it was oversized.
|
|
1111
|
+
if (bytes.length > maxRegistrationSchemaBytes)
|
|
1112
|
+
return { schema: null, verdict: "too_large" };
|
|
1113
|
+
// A byte order mark is refused. RFC 8259 forbids adding one and lets a parser ignore
|
|
1114
|
+
// one, so both policies conform and the choice had to be made once for all three
|
|
1115
|
+
// SDKs: TextDecoder strips it by default and Python's json.loads strips it from
|
|
1116
|
+
// bytes, while Go's parser does not — so the same document compiled in two SDKs and
|
|
1117
|
+
// was malformed in the third. Refusing is the side that keeps the size cap above
|
|
1118
|
+
// honest, since a stripped mark would make it count three bytes the schema does not
|
|
1119
|
+
// contain, and a mark is only valid at the start of a JSON text, never inside the
|
|
1120
|
+
// fora.json member this schema lives in.
|
|
1121
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
1122
|
+
return { schema: null, verdict: "malformed" };
|
|
1123
|
+
}
|
|
1124
|
+
// Depth SECOND, and still on the raw bytes — before the document is handed to a
|
|
1125
|
+
// JSON parser rather than after. Every parser across the three SDKs descends
|
|
1126
|
+
// recursively, and two of them abort on a deeply nested document in a way this
|
|
1127
|
+
// face cannot map onto a verdict (Python raises RecursionError, which is not the
|
|
1128
|
+
// exception a malformed document raises), so a check placed after the parse is
|
|
1129
|
+
// reached only for documents harmless enough to parse. Lexical counting needs no
|
|
1130
|
+
// recursion at all.
|
|
1131
|
+
if (rawNestingDepth(bytes) > maxRegistrationSchemaDepth) {
|
|
1132
|
+
return { schema: null, verdict: "too_deep" };
|
|
1133
|
+
}
|
|
1134
|
+
let doc;
|
|
1135
|
+
try {
|
|
1136
|
+
doc = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
return { schema: null, verdict: "malformed" };
|
|
1140
|
+
}
|
|
1141
|
+
// A JSON OBJECT and nothing else. 2020-12 admits a bare boolean as a schema, but
|
|
1142
|
+
// data_schema is a google.protobuf.Struct, which carries an object — so a boolean
|
|
1143
|
+
// cannot reach this face over the wire at all, and admitting one would pin
|
|
1144
|
+
// behaviour for a document the contract has no way to transport.
|
|
1145
|
+
if (!isPlainObject(doc)) {
|
|
1146
|
+
return { schema: null, verdict: "malformed" };
|
|
1147
|
+
}
|
|
1148
|
+
const verdict = scan(doc);
|
|
1149
|
+
if (verdict !== "accepted")
|
|
1150
|
+
return { schema: null, verdict };
|
|
1151
|
+
// The work bound, still before the library is involved. This is also where a
|
|
1152
|
+
// reference cycle and a same-document reference that resolves to nothing are
|
|
1153
|
+
// found, because counting the cost means following every reference to its target.
|
|
1154
|
+
const costVerdict = checkEvaluationCost(doc);
|
|
1155
|
+
if (costVerdict !== "accepted")
|
|
1156
|
+
return { schema: null, verdict: costVerdict };
|
|
1157
|
+
// strict:false because a published schema may legitimately carry keywords this
|
|
1158
|
+
// version of Ajv does not know, and the robustness principle applies to those
|
|
1159
|
+
// exactly as it does elsewhere in FORA. allErrors because a refusal names EVERY
|
|
1160
|
+
// offending member, not the first. validateFormats:false because format,
|
|
1161
|
+
// contentEncoding and contentMediaType stay ANNOTATIONS — the three languages'
|
|
1162
|
+
// libraries default differently, so leaving this to a default would make the
|
|
1163
|
+
// same document conform in one SDK and not in another.
|
|
1164
|
+
//
|
|
1165
|
+
// No loadSchema and no external resource is registered, deliberately: with none,
|
|
1166
|
+
// a reference this module's scan did not recognise fails closed at compile
|
|
1167
|
+
// instead of being fetched.
|
|
1168
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
1169
|
+
try {
|
|
1170
|
+
// Every regex in the document, rewritten to mean here what it means in the other
|
|
1171
|
+
// two SDKs. This happens after every rule above, so what is scanned and bounded
|
|
1172
|
+
// as a schema is always the published document; only the copy handed to the
|
|
1173
|
+
// matcher carries the correction.
|
|
1174
|
+
const corrected = correctRegexes(doc);
|
|
1175
|
+
return {
|
|
1176
|
+
schema: new RegistrationSchema(ajv.compile(corrected)),
|
|
1177
|
+
verdict: "accepted",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
return { schema: null, verdict: "uncompilable" };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* maxRegistrationDataBytes bounds a submitted registration_data payload, measured as
|
|
1186
|
+
* its RFC 8785 canonical JSON encoding.
|
|
1187
|
+
*
|
|
1188
|
+
* The UNIT has to be named, and that is the whole point of this constant. Every other
|
|
1189
|
+
* cap in this module is over bytes a party actually served; registration_data is not
|
|
1190
|
+
* served as bytes at all — it arrives as a decoded google.protobuf.Struct — so "16KB"
|
|
1191
|
+
* means nothing until an encoding is chosen, and two implementations choosing privately
|
|
1192
|
+
* is the disagreement this module exists to remove. JCS is the choice because all three
|
|
1193
|
+
* SDKs already compute it for the signing primitive, and because it pins number
|
|
1194
|
+
* formatting: a payload carrying 1e300 is seven bytes to one renderer and three hundred
|
|
1195
|
+
* to another.
|
|
1196
|
+
*
|
|
1197
|
+
* It bounds WORK, not storage. The schema's own caps bound the schema; nothing bounded
|
|
1198
|
+
* the payload the schema is applied to, and validation cost is roughly the schema's cost
|
|
1199
|
+
* multiplied by the elements in the payload — a subschema under `items` is counted once
|
|
1200
|
+
* by maxRegistrationSchemaEvaluations and evaluated once per element.
|
|
1201
|
+
*/
|
|
1202
|
+
export const maxRegistrationDataBytes = 16384;
|
|
1203
|
+
/**
|
|
1204
|
+
* maxRegistrationDataMembers bounds the number of members at the TOP LEVEL of a
|
|
1205
|
+
* payload. Top level rather than recursive, deliberately: nested bulk is already bounded
|
|
1206
|
+
* by the byte cap, and a recursive count would refuse a small document that merely
|
|
1207
|
+
* nests, which a business entity legitimately does (an address is an object).
|
|
1208
|
+
*/
|
|
1209
|
+
export const maxRegistrationDataMembers = 64;
|
|
1210
|
+
/**
|
|
1211
|
+
* maxRegistrationDataDepth bounds how deeply a submitted registration_data payload may
|
|
1212
|
+
* nest, counting JSON containers so a bare `{}` is depth 1. Same number and same counting
|
|
1213
|
+
* rule as maxRegistrationSchemaDepth, because it is the same question asked of the other
|
|
1214
|
+
* document.
|
|
1215
|
+
*
|
|
1216
|
+
* It exists because without it the ANSWER depended on the reader's runtime rather than on
|
|
1217
|
+
* the payload. Canonicalising walks the payload recursively, and where that walk runs out
|
|
1218
|
+
* of stack differs by language and even by interpreter version — one port refused a
|
|
1219
|
+
* payload past about five hundred containers on one Python and accepted nine hundred on
|
|
1220
|
+
* the next, while this one and Go accepted every depth tried. A static bound checked
|
|
1221
|
+
* first turns that into one verdict every implementation reaches.
|
|
1222
|
+
*/
|
|
1223
|
+
export const maxRegistrationDataDepth = 32;
|
|
1224
|
+
/**
|
|
1225
|
+
* RegistrationDataVerdict is the outcome of checking a submitted registration_data
|
|
1226
|
+
* payload. The tokens are the Go oracle's vocabulary verbatim.
|
|
1227
|
+
*
|
|
1228
|
+
* "no_verdict" is Go's zero value and is never returned here; it is in the vocabulary
|
|
1229
|
+
* because the corpus carries the whole vocabulary.
|
|
1230
|
+
*/
|
|
1231
|
+
export const registrationDataVerdicts = [
|
|
1232
|
+
"no_verdict",
|
|
1233
|
+
"accepted",
|
|
1234
|
+
"too_large",
|
|
1235
|
+
"too_many_members",
|
|
1236
|
+
"too_deep",
|
|
1237
|
+
"uncanonicalizable",
|
|
1238
|
+
];
|
|
1239
|
+
/**
|
|
1240
|
+
* registrationDataDepth returns how many JSON containers the payload nests, counting the
|
|
1241
|
+
* payload object itself as the first. A scalar is a value, not a container, so
|
|
1242
|
+
* `{"a":"x"}` nests one — the same rule the schema side applies, where the count is of a
|
|
1243
|
+
* document's opening braces and brackets and nothing else.
|
|
1244
|
+
*
|
|
1245
|
+
* Only containers are pushed, so every frame on the stack is one and the count needs no
|
|
1246
|
+
* test at the far end. The walk is ITERATIVE: it runs before the depth bound is known to
|
|
1247
|
+
* hold, so it is the one walk that must survive any input.
|
|
1248
|
+
*/
|
|
1249
|
+
function registrationDataDepth(data) {
|
|
1250
|
+
const isContainer = (v) => Array.isArray(v) || (v !== null && typeof v === "object");
|
|
1251
|
+
let deepest = 0;
|
|
1252
|
+
const stack = [{ node: data, depth: 1 }];
|
|
1253
|
+
while (stack.length > 0) {
|
|
1254
|
+
const { node, depth } = stack.pop();
|
|
1255
|
+
if (depth > deepest)
|
|
1256
|
+
deepest = depth;
|
|
1257
|
+
// No need to walk past the bound: the answer is already decided, and a payload
|
|
1258
|
+
// deep enough to matter is also deep enough to be expensive to finish walking.
|
|
1259
|
+
if (depth > maxRegistrationDataDepth)
|
|
1260
|
+
return depth;
|
|
1261
|
+
let children = [];
|
|
1262
|
+
if (Array.isArray(node))
|
|
1263
|
+
children = node;
|
|
1264
|
+
else if (node !== null && typeof node === "object")
|
|
1265
|
+
children = Object.values(node);
|
|
1266
|
+
for (const child of children) {
|
|
1267
|
+
if (isContainer(child))
|
|
1268
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return deepest;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* checkRegistrationData bounds a submitted registration_data payload.
|
|
1275
|
+
*
|
|
1276
|
+
* `data` is the decoded object. A null/undefined or empty payload is accepted: sending
|
|
1277
|
+
* no business data is a matter for the published schema's `required` list, not for a
|
|
1278
|
+
* size bound.
|
|
1279
|
+
*
|
|
1280
|
+
* This runs BEFORE RegistrationSchema.validate, for the same reason the schema's own
|
|
1281
|
+
* size cap runs before the schema is parsed: the bound exists to stop work, so it has to
|
|
1282
|
+
* precede the work. An Exchange refuses an over-bound payload outright — a malformed
|
|
1283
|
+
* request rather than a schema failure, so NOT
|
|
1284
|
+
* REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, which names non-conformance to
|
|
1285
|
+
* a published schema and applies only when one is published.
|
|
1286
|
+
*/
|
|
1287
|
+
export function checkRegistrationData(data) {
|
|
1288
|
+
const obj = data ?? {};
|
|
1289
|
+
// Members first: it is a length check, and it bounds the document the canonical
|
|
1290
|
+
// encoding below then has to walk.
|
|
1291
|
+
if (Object.keys(obj).length > maxRegistrationDataMembers)
|
|
1292
|
+
return "too_many_members";
|
|
1293
|
+
// Depth SECOND, and before anything walks the payload recursively. The check is
|
|
1294
|
+
// iterative for that reason: a recursive one would hit the very limit it exists to
|
|
1295
|
+
// keep the caller away from, throwing while discovering that the payload is too deep.
|
|
1296
|
+
if (registrationDataDepth(obj) > maxRegistrationDataDepth)
|
|
1297
|
+
return "too_deep";
|
|
1298
|
+
let jcs;
|
|
1299
|
+
try {
|
|
1300
|
+
jcs = canonicalize(obj);
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
// The reachable case is a non-finite number, which JSON cannot represent and
|
|
1304
|
+
// this canonicalizer throws on. It is a verdict rather than an exception because
|
|
1305
|
+
// this face, like the rest of the registration surface, does not throw.
|
|
1306
|
+
return "uncanonicalizable";
|
|
1307
|
+
}
|
|
1308
|
+
if (jcs === undefined)
|
|
1309
|
+
return "uncanonicalizable";
|
|
1310
|
+
// BYTES, not UTF-16 code units: the cap is over the canonical encoding, and a
|
|
1311
|
+
// non-ASCII member name costs more than one unit per character.
|
|
1312
|
+
if (new TextEncoder().encode(jcs).length > maxRegistrationDataBytes)
|
|
1313
|
+
return "too_large";
|
|
1314
|
+
return "accepted";
|
|
1315
|
+
}
|