@packet-schema/core 0.1.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/collect-refs.d.ts +4 -0
- package/dist/collect-refs.d.ts.map +1 -0
- package/dist/collect-refs.js +76 -0
- package/dist/collect-refs.js.map +1 -0
- package/dist/constraint.d.ts +66 -0
- package/dist/constraint.d.ts.map +1 -0
- package/dist/constraint.js +286 -0
- package/dist/constraint.js.map +1 -0
- package/dist/expr.d.ts +32 -0
- package/dist/expr.d.ts.map +1 -0
- package/dist/expr.js +190 -0
- package/dist/expr.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +7 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +217 -0
- package/dist/layout.js.map +1 -0
- package/dist/normalize.d.ts +37 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +857 -0
- package/dist/normalize.js.map +1 -0
- package/dist/types.d.ts +538 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +1222 -0
- package/dist/validate.js.map +1 -0
- package/dist/values.d.ts +31 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +73 -0
- package/dist/values.js.map +1 -0
- package/dist/yaml.d.ts +11 -0
- package/dist/yaml.d.ts.map +1 -0
- package/dist/yaml.js +145 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +35 -0
- package/schemas/psdl-0.5.yaml +1223 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
// PSDL semantic validator — walks the Container tree and enforces invariants
|
|
2
|
+
// (§11.1) that JSON Schema alone cannot express: id format, expression
|
|
3
|
+
// placement, ref-cycle detection, switch case key format, import uniqueness.
|
|
4
|
+
import { walkExpr } from "./expr.js";
|
|
5
|
+
import { isField } from "./utils.js";
|
|
6
|
+
import { BIN_OPS } from "./types.js";
|
|
7
|
+
const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
8
|
+
const DECIMAL_INT_RE = /^(0|[1-9][0-9]*)$/;
|
|
9
|
+
/** Ternary bit-pattern for ValueEntry.pattern (§5.3): 0, 1, or x/X. */
|
|
10
|
+
const PATTERN_RE = /^[01xX]+$/;
|
|
11
|
+
const SWITCH_KEY_RE = /^(_|(0|[1-9][0-9]*)|(0|[1-9][0-9]*)-(0|[1-9][0-9]*)|(0|[1-9][0-9]*)(,(0|[1-9][0-9]*))+)$/;
|
|
12
|
+
/** Hex-string form for wide checksum params (§8, D9): `^0x[0-9A-Fa-f]+$`. */
|
|
13
|
+
const HEX_PARAM_RE = /^0x[0-9A-Fa-f]+$/;
|
|
14
|
+
/** True if a `bytes.n` is the delimiter form, however malformed (§3, D3). */
|
|
15
|
+
function isBytesDelimitedShape(n) {
|
|
16
|
+
return typeof n === "object" && n !== null && !Array.isArray(n) && "delimiter" in n;
|
|
17
|
+
}
|
|
18
|
+
const NORM_LEVELS = new Set(["must", "should", "may"]);
|
|
19
|
+
/** The closed nine-token category set (§5.1, schema CategoryToken enum). */
|
|
20
|
+
const CATEGORY_TOKEN_SET = new Set([
|
|
21
|
+
"addressing", "identifier", "length", "type", "flags",
|
|
22
|
+
"reserved", "checksum", "variable", "payload-marker",
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Named checksum algorithms that do NOT use the CRC parameter model (§8). Using
|
|
26
|
+
* `checksumParams` with one of these is a validation error (§11.1) because they
|
|
27
|
+
* have fixed internal parameters structurally incompatible with the CRC set.
|
|
28
|
+
*/
|
|
29
|
+
const NON_CRC_CHECKSUM_ALGORITHMS = new Set(["internet", "adler32"]);
|
|
30
|
+
/** ValueEntry keys (schema ValueEntry, `additionalProperties: false`, §5.3). */
|
|
31
|
+
const VALUE_ENTRY_KEYS = new Set([
|
|
32
|
+
"value", "range", "pattern", "name", "label", "doc", "level", "meta",
|
|
33
|
+
]);
|
|
34
|
+
/** Enum variant object keys (schema EnumVariant, `additionalProperties: false`, §3). */
|
|
35
|
+
const ENUM_VARIANT_KEYS = new Set(["label", "doc", "level", "meta"]);
|
|
36
|
+
/** Constraint keys (schema Constraint, `additionalProperties: false`, §9). */
|
|
37
|
+
const CONSTRAINT_KEYS = new Set(["lhs", "rhs", "doc", "level"]);
|
|
38
|
+
/* ------------------------------------------------------------------ *
|
|
39
|
+
* RFC provenance shape (§5.4)
|
|
40
|
+
* ------------------------------------------------------------------ */
|
|
41
|
+
/**
|
|
42
|
+
* A single `updates` entry (§5.4): a bare integer RFC number, or an object
|
|
43
|
+
* `{ rfc (required integer), section? (string) }` with no surplus keys. A bare
|
|
44
|
+
* number N means `{ rfc: N }` (no section); the object form names the section
|
|
45
|
+
* of that updating RFC.
|
|
46
|
+
*/
|
|
47
|
+
function isValidUpdateRef(u) {
|
|
48
|
+
if (typeof u === "number")
|
|
49
|
+
return Number.isInteger(u);
|
|
50
|
+
if (typeof u !== "object" || u === null || Array.isArray(u))
|
|
51
|
+
return false;
|
|
52
|
+
const o = u;
|
|
53
|
+
for (const key of Object.keys(o)) {
|
|
54
|
+
if (key !== "rfc" && key !== "section")
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(o.rfc))
|
|
58
|
+
return false;
|
|
59
|
+
if (o.section !== undefined && typeof o.section !== "string")
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/** RfcRef shape (§5.4): a bare integer, or `{ defined, updates? }` with no surplus keys. */
|
|
64
|
+
function isValidRfcRef(v) {
|
|
65
|
+
if (typeof v === "number")
|
|
66
|
+
return Number.isInteger(v);
|
|
67
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
68
|
+
return false;
|
|
69
|
+
const o = v;
|
|
70
|
+
for (const key of Object.keys(o)) {
|
|
71
|
+
if (key !== "defined" && key !== "updates")
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isInteger(o.defined))
|
|
75
|
+
return false;
|
|
76
|
+
if (o.updates !== undefined &&
|
|
77
|
+
(!Array.isArray(o.updates) || !o.updates.every((u) => isValidUpdateRef(u))))
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/** FieldMeta keys (schema FieldMeta, `additionalProperties: false`). */
|
|
82
|
+
const FIELD_META_KEYS = new Set(["rfc", "section"]);
|
|
83
|
+
/** PacketMeta keys (schema PacketMeta additionally allows `aliases`, `tags`, `family`). */
|
|
84
|
+
const PACKET_META_KEYS = new Set(["rfc", "section", "aliases", "tags", "family"]);
|
|
85
|
+
/**
|
|
86
|
+
* Lightweight `meta` shape check (§5.4), mirroring the schema's FieldMeta /
|
|
87
|
+
* PacketMeta / RfcRef defs so the two validation layers agree on what a
|
|
88
|
+
* well-formed provenance annotation is (the schema rejects these shapes too):
|
|
89
|
+
* unknown keys are rejected (`additionalProperties: false`), `section` must be
|
|
90
|
+
* a string, `aliases` (packet meta only) must be an array of strings.
|
|
91
|
+
*/
|
|
92
|
+
function validateMeta(meta, ctx, errors, allowedKeys = FIELD_META_KEYS) {
|
|
93
|
+
if (meta === undefined)
|
|
94
|
+
return;
|
|
95
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
96
|
+
errors.push({ message: `${ctx}: meta must be an object (§5.4).` });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(meta)) {
|
|
100
|
+
if (!allowedKeys.has(key))
|
|
101
|
+
errors.push({ message: `${ctx}: meta has unknown key "${key}" (allowed: ${[...allowedKeys].join(", ")}) (§5.4).` });
|
|
102
|
+
}
|
|
103
|
+
const { rfc, section, aliases, tags, family } = meta;
|
|
104
|
+
if (rfc !== undefined && !isValidRfcRef(rfc))
|
|
105
|
+
errors.push({ message: `${ctx}: meta.rfc must be an integer or { defined, updates? } where each updates entry is an integer or { rfc, section? } (§5.4).` });
|
|
106
|
+
if (section !== undefined && typeof section !== "string")
|
|
107
|
+
errors.push({ message: `${ctx}: meta.section must be a string (e.g. "3.1") (§5.4).` });
|
|
108
|
+
if (allowedKeys.has("aliases") && aliases !== undefined &&
|
|
109
|
+
(!Array.isArray(aliases) || !aliases.every((a) => typeof a === "string")))
|
|
110
|
+
errors.push({ message: `${ctx}: meta.aliases must be an array of strings.` });
|
|
111
|
+
// §1.1: free-form catalog classification — the language checks only the shape
|
|
112
|
+
// (string[] / string); the vocabulary is governed by the catalog layer.
|
|
113
|
+
if (allowedKeys.has("tags") && tags !== undefined &&
|
|
114
|
+
(!Array.isArray(tags) || !tags.every((t) => typeof t === "string")))
|
|
115
|
+
errors.push({ message: `${ctx}: meta.tags must be an array of strings (§1.1).` });
|
|
116
|
+
if (allowedKeys.has("family") && family !== undefined && typeof family !== "string")
|
|
117
|
+
errors.push({ message: `${ctx}: meta.family must be a string (§1.1).` });
|
|
118
|
+
}
|
|
119
|
+
/* ------------------------------------------------------------------ *
|
|
120
|
+
* Structural expression well-formedness
|
|
121
|
+
* ------------------------------------------------------------------ */
|
|
122
|
+
export function isValidExpr(expr) {
|
|
123
|
+
if (typeof expr !== "object" || expr === null)
|
|
124
|
+
return false;
|
|
125
|
+
const e = expr;
|
|
126
|
+
switch (e.kind) {
|
|
127
|
+
case "lit":
|
|
128
|
+
return typeof expr.value === "number" &&
|
|
129
|
+
Number.isFinite(expr.value);
|
|
130
|
+
case "ref":
|
|
131
|
+
case "prevIter":
|
|
132
|
+
case "enclosingField":
|
|
133
|
+
return typeof expr.field === "string";
|
|
134
|
+
case "op": {
|
|
135
|
+
const o = expr;
|
|
136
|
+
return typeof o.op === "string" && BIN_OPS.includes(o.op) &&
|
|
137
|
+
isValidExpr(o.a) && isValidExpr(o.b);
|
|
138
|
+
}
|
|
139
|
+
case "cond": {
|
|
140
|
+
const c = expr;
|
|
141
|
+
return isValidExpr(c.test) && isValidExpr(c.t) && isValidExpr(c.f);
|
|
142
|
+
}
|
|
143
|
+
case "peek": {
|
|
144
|
+
const p = expr;
|
|
145
|
+
if (typeof p.bits !== "number" || !Number.isInteger(p.bits) || p.bits < 1 || p.bits > 64)
|
|
146
|
+
return false;
|
|
147
|
+
return p.offset === undefined || isValidExpr(p.offset);
|
|
148
|
+
}
|
|
149
|
+
case "lookup": {
|
|
150
|
+
const l = expr;
|
|
151
|
+
if (!isValidExpr(l.key))
|
|
152
|
+
return false;
|
|
153
|
+
if (typeof l.table !== "object" || l.table === null)
|
|
154
|
+
return false;
|
|
155
|
+
// §4/§11.1: every lookup key and value must be a non-negative decimal
|
|
156
|
+
// integer. Keys arrive as object-key strings; values must be integers.
|
|
157
|
+
for (const [k, v] of Object.entries(l.table)) {
|
|
158
|
+
if (!DECIMAL_INT_RE.test(k))
|
|
159
|
+
return false;
|
|
160
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0)
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
case "wireSize":
|
|
166
|
+
return typeof expr.target === "string";
|
|
167
|
+
case "remaining":
|
|
168
|
+
case "enclosingBits":
|
|
169
|
+
return true;
|
|
170
|
+
default:
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const PEEK_ALLOWED = new Set([
|
|
175
|
+
"switch.on", "optional.when", "repeat.count", "repeat.until",
|
|
176
|
+
]);
|
|
177
|
+
function validateExprPlacement(expr, slot, ctx, errors, pc,
|
|
178
|
+
/**
|
|
179
|
+
* §10.7 carve-out (passed ONLY from the two `repeat.count`/`repeat.until`
|
|
180
|
+
* call sites in validateRepeat): the set of referenceable ids contributed by
|
|
181
|
+
* the SAME repeat's element subtree. A leaf `ref` naming one of these resolves
|
|
182
|
+
* to the just-completed iteration's field value (§10.7) and is therefore
|
|
183
|
+
* exempt from the §10.1 forward-reference rule. The set is built from
|
|
184
|
+
* `element.fields` only, so it excludes the repeat container's own id and any
|
|
185
|
+
* non-element sibling — a self-ref to the repeat id or a forward ref to a
|
|
186
|
+
* later sibling still errors. Undefined at every other call site.
|
|
187
|
+
*/
|
|
188
|
+
elementRefExempt) {
|
|
189
|
+
walkExpr(expr, (e) => {
|
|
190
|
+
if (e.kind === "peek" && !PEEK_ALLOWED.has(slot))
|
|
191
|
+
errors.push({ message: `${ctx}: peek may not appear in ${slot} (allowed: switch.on, optional.when, repeat.count/until).` });
|
|
192
|
+
if (e.kind === "enclosingField" && slot !== "constraint")
|
|
193
|
+
errors.push({ message: `${ctx}: enclosingField may only appear in constraints, not ${slot}.` });
|
|
194
|
+
if (e.kind === "prevIter" && slot !== "repeat.count" && slot !== "repeat.until")
|
|
195
|
+
errors.push({ message: `${ctx}: prevIter may only appear in repeat.count or repeat.until, not ${slot}.` });
|
|
196
|
+
if (pc === undefined)
|
|
197
|
+
return;
|
|
198
|
+
// §D11: leaf `ref`/`wireSize` existence check. Applies to body and
|
|
199
|
+
// constraint expressions (pc present). A target naming nothing declared
|
|
200
|
+
// anywhere in the document is a typo and a validation error.
|
|
201
|
+
if (pc.documentDeclaredIds !== undefined &&
|
|
202
|
+
(e.kind === "ref" || e.kind === "wireSize")) {
|
|
203
|
+
const id = e.kind === "ref" ? e.field : e.target;
|
|
204
|
+
const head = id.includes(".") ? id.slice(0, id.indexOf(".")) : id;
|
|
205
|
+
if (id.includes("#")) {
|
|
206
|
+
errors.push({ message: `${ctx}: '#'-qualified ids may not appear in expressions (repeat-indexed instances are not referenceable, §2/§10.4).` });
|
|
207
|
+
}
|
|
208
|
+
else if (id.includes(".") && pc.importPrefixes?.has(head)) {
|
|
209
|
+
// Import-qualified (e.g. addr.ipv4Addr.oct0): resolution is deferred to
|
|
210
|
+
// the import-resolving layer (§1.2); the core validator does not check it.
|
|
211
|
+
}
|
|
212
|
+
else if (!pc.documentDeclaredIds.has(id)) {
|
|
213
|
+
errors.push({ message: `${ctx}: ${e.kind === "ref" ? "ref" : "wireSize"} target "${id}" is not declared anywhere in the document (§11.1).` });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// §11.1: remaining/enclosingBits scope-provider placement. Constraints are
|
|
217
|
+
// evaluated over the fully-parsed env, not at a body position, so they are
|
|
218
|
+
// exempt from the scope-provider restriction.
|
|
219
|
+
if (slot !== "constraint") {
|
|
220
|
+
if (e.kind === "remaining" && !pc.remainingOk)
|
|
221
|
+
errors.push({ message: `${ctx}: 'remaining' used outside a scope-providing container with a defined byte budget (§11.1).` });
|
|
222
|
+
if (e.kind === "enclosingBits" && !pc.enclosingBitsOk)
|
|
223
|
+
errors.push({ message: `${ctx}: 'enclosingBits' used outside a scope-providing container that carries an injected bit budget (§11.1).` });
|
|
224
|
+
// §10.1/§11.1: forward-reference rules for wireSize and repeat-id ref.
|
|
225
|
+
if (e.kind === "wireSize") {
|
|
226
|
+
if (pc.openIds.has(e.target))
|
|
227
|
+
errors.push({ message: `${ctx}: wireSize target "${e.target}" is an enclosing/not-yet-closed container (§11.1).` });
|
|
228
|
+
else if (!pc.declaredIds.has(e.target))
|
|
229
|
+
errors.push({ message: `${ctx}: wireSize target "${e.target}" does not precede this expression in document order (§11.1).` });
|
|
230
|
+
}
|
|
231
|
+
// §10.1/§11.1: a leaf `ref` in a body slot may name only a field/container
|
|
232
|
+
// that PRECEDES it in document order. A ref to a repeat container id keeps
|
|
233
|
+
// its specific message; every other leaf ref (including a self-size ref, a
|
|
234
|
+
// ref to a later field, or a dotted ref-expanded target not yet closed) is
|
|
235
|
+
// caught by the general forward branch. The check is gated on
|
|
236
|
+
// `declaredExprIds` (document-order-so-far) and on the target EXISTING in
|
|
237
|
+
// `documentDeclaredIds` — a typo is already reported above as "not declared
|
|
238
|
+
// anywhere", so it must not also be reported here. Import-/`#`-qualified ids
|
|
239
|
+
// took their early-out branches above and are not re-flagged. Constraints
|
|
240
|
+
// are §10.1-exempt: `declaredExprIds` is undefined for them, so this branch
|
|
241
|
+
// is skipped.
|
|
242
|
+
if (e.kind === "ref" && pc.declaredExprIds !== undefined &&
|
|
243
|
+
pc.documentDeclaredIds !== undefined && !pc.declaredExprIds.has(e.field) &&
|
|
244
|
+
!e.field.includes("#") &&
|
|
245
|
+
!(e.field.includes(".") && pc.importPrefixes?.has(e.field.slice(0, e.field.indexOf("."))))) {
|
|
246
|
+
if (pc.repeatIds.has(e.field) && !pc.declaredIds.has(e.field)) {
|
|
247
|
+
errors.push({ message: `${ctx}: ref to repeat container id "${e.field}" precedes that repeat in document order (§11.1).` });
|
|
248
|
+
}
|
|
249
|
+
else if (pc.documentDeclaredIds.has(e.field) && !elementRefExempt?.has(e.field)) {
|
|
250
|
+
// §10.7 carve-out: a `repeat.count`/`repeat.count.until` ordinary ref
|
|
251
|
+
// to a field of the SAME repeat's element is NOT forward — it resolves
|
|
252
|
+
// to the just-completed iteration's value. `elementRefExempt` carries
|
|
253
|
+
// exactly those element-field ids (and only at those two call sites),
|
|
254
|
+
// so the repeat's own id and any later sibling still fall through to
|
|
255
|
+
// this error (excluded from the exempt set by construction).
|
|
256
|
+
errors.push({ message: `${ctx}: ref target "${e.field}" does not precede this expression in document order, or refers to its own container (a self-size ref); a body expression may reference only fields declared before it (§10.1/§11.1).` });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/* ------------------------------------------------------------------ *
|
|
263
|
+
* Types
|
|
264
|
+
* ------------------------------------------------------------------ */
|
|
265
|
+
function validateType(type, ctx, errors, pc) {
|
|
266
|
+
switch (type.kind) {
|
|
267
|
+
case "int":
|
|
268
|
+
if (!Number.isInteger(type.bits) || type.bits <= 0)
|
|
269
|
+
errors.push({ message: `${ctx}: int must have positive integer bits, got ${type.bits}.` });
|
|
270
|
+
return;
|
|
271
|
+
case "enum": {
|
|
272
|
+
if (!Number.isInteger(type.bits) || type.bits <= 0)
|
|
273
|
+
errors.push({ message: `${ctx}: enum must have positive integer bits, got ${type.bits}.` });
|
|
274
|
+
// `!Array.isArray`: a YAML list of labels is not a variants table — the
|
|
275
|
+
// schema's `variants: { type: object }` rejects arrays, so reject here
|
|
276
|
+
// too (typeof [] === "object" would otherwise let it through).
|
|
277
|
+
if (type.variants && typeof type.variants === "object" && !Array.isArray(type.variants)) {
|
|
278
|
+
for (const [k, v] of Object.entries(type.variants)) {
|
|
279
|
+
if (!DECIMAL_INT_RE.test(k))
|
|
280
|
+
errors.push({ message: `${ctx}: enum variant key "${k}" must be a non-negative decimal integer (§3).` });
|
|
281
|
+
if (typeof v === "object" && v !== null) {
|
|
282
|
+
const o = v;
|
|
283
|
+
for (const key of Object.keys(o)) {
|
|
284
|
+
// Mirrors the schema's EnumVariant `additionalProperties: false`
|
|
285
|
+
// so a typo'd annotation key cannot silently vanish (§3, §5.4).
|
|
286
|
+
if (!ENUM_VARIANT_KEYS.has(key))
|
|
287
|
+
errors.push({ message: `${ctx}: enum variant "${k}" has unknown key "${key}" (allowed: ${[...ENUM_VARIANT_KEYS].join(", ")}) (§3).` });
|
|
288
|
+
}
|
|
289
|
+
if (typeof o.label !== "string")
|
|
290
|
+
errors.push({ message: `${ctx}: enum variant "${k}" must have a string label (§3).` });
|
|
291
|
+
// Mirrors the schema's EnumVariant `doc` `type: string` (§3).
|
|
292
|
+
if (o.doc !== undefined && typeof o.doc !== "string")
|
|
293
|
+
errors.push({ message: `${ctx}: enum variant "${k}" doc must be a string (§3).` });
|
|
294
|
+
if (o.level !== undefined && !NORM_LEVELS.has(o.level))
|
|
295
|
+
errors.push({ message: `${ctx}: enum variant "${k}" has invalid level "${String(o.level)}" (must be must|should|may).` });
|
|
296
|
+
validateMeta(o.meta, `${ctx}: enum variant "${k}"`, errors);
|
|
297
|
+
}
|
|
298
|
+
else if (typeof v !== "string") {
|
|
299
|
+
errors.push({ message: `${ctx}: enum variant "${k}" must be a string label or an object with a label (§3).` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Mirrors the schema's `required: [kind, bits, variants]` so the two
|
|
305
|
+
// validation layers agree (§3). An empty `{}` table is fine.
|
|
306
|
+
errors.push({ message: `${ctx}: enum must have a variants object (§3).` });
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
case "bits":
|
|
311
|
+
if (!Number.isInteger(type.n) || type.n <= 0)
|
|
312
|
+
errors.push({ message: `${ctx}: bits must have positive integer n, got ${type.n}.` });
|
|
313
|
+
return;
|
|
314
|
+
case "bytes":
|
|
315
|
+
// §3 (D3): bytes.n is either an Expr or the delimiter form.
|
|
316
|
+
if (isBytesDelimitedShape(type.n)) {
|
|
317
|
+
const delim = type.n.delimiter;
|
|
318
|
+
// The delimiter form accepts only the `delimiter` key (mirrors schema
|
|
319
|
+
// BytesDelimited additionalProperties:false).
|
|
320
|
+
for (const key of Object.keys(type.n))
|
|
321
|
+
if (key !== "delimiter")
|
|
322
|
+
errors.push({ message: `${ctx}: bytes.n delimiter form accepts only the "delimiter" key (got "${key}") (§3).` });
|
|
323
|
+
if (!Array.isArray(delim) || delim.length < 1)
|
|
324
|
+
errors.push({ message: `${ctx}: bytes.n delimiter must be a non-empty array of byte integers (§3/§11.1).` });
|
|
325
|
+
else if (!delim.every((b) => Number.isInteger(b) && b >= 0 && b <= 255))
|
|
326
|
+
errors.push({ message: `${ctx}: bytes.n delimiter elements must be integers in 0–255 (§3/§11.1).` });
|
|
327
|
+
}
|
|
328
|
+
else if (!isValidExpr(type.n))
|
|
329
|
+
errors.push({ message: `${ctx}: bytes has a malformed length expression.` });
|
|
330
|
+
else
|
|
331
|
+
validateExprPlacement(type.n, "bytes.n", ctx, errors, pc);
|
|
332
|
+
return;
|
|
333
|
+
case "varint":
|
|
334
|
+
if (typeof type.encoding !== "string" || type.encoding.length === 0)
|
|
335
|
+
errors.push({ message: `${ctx}: varint encoding must be a non-empty string.` });
|
|
336
|
+
return;
|
|
337
|
+
case "berLength":
|
|
338
|
+
if (type.maxBytes !== undefined && (!Number.isInteger(type.maxBytes) || type.maxBytes < 1 || type.maxBytes > 5))
|
|
339
|
+
errors.push({ message: `${ctx}: berLength maxBytes must be 1–5, got ${type.maxBytes}.` });
|
|
340
|
+
return;
|
|
341
|
+
default:
|
|
342
|
+
errors.push({ message: `${ctx}: unknown type kind "${type.kind}".` });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* §8/§11.1 (D9): validate `checksumParams` width and the polynomial/initValue/
|
|
347
|
+
* finalXOR numeric forms. A bare integer is allowed only when it is a safe
|
|
348
|
+
* integer (≤ 2^53−1); a wider value MUST use the `^0x[0-9A-Fa-f]+$` hex string
|
|
349
|
+
* so its full 64-bit precision survives. A non-int/bits checksum field (e.g. a
|
|
350
|
+
* `bytes` checksum) requires an explicit `width` because its declared bit width
|
|
351
|
+
* is not unique.
|
|
352
|
+
*/
|
|
353
|
+
function validateChecksumParams(field, ctx, errors) {
|
|
354
|
+
const p = field.checksumParams;
|
|
355
|
+
for (const key of ["polynomial", "initValue", "finalXOR"]) {
|
|
356
|
+
const v = p[key];
|
|
357
|
+
if (v === undefined)
|
|
358
|
+
continue;
|
|
359
|
+
if (typeof v === "string") {
|
|
360
|
+
if (!HEX_PARAM_RE.test(v))
|
|
361
|
+
errors.push({ message: `${ctx}: checksumParams.${key} hex string "${v}" must match ^0x[0-9A-Fa-f]+$ (§8/§11.1).` });
|
|
362
|
+
}
|
|
363
|
+
else if (typeof v === "number") {
|
|
364
|
+
if (!Number.isInteger(v) || v < 0)
|
|
365
|
+
errors.push({ message: `${ctx}: checksumParams.${key} must be a non-negative integer or a 0x hex string (§8).` });
|
|
366
|
+
else if (!Number.isSafeInteger(v))
|
|
367
|
+
errors.push({ message: `${ctx}: checksumParams.${key} exceeds 2^53−1 and must be written as a ^0x[0-9A-Fa-f]+$ hex string to preserve precision (§8/§11.1).` });
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
errors.push({ message: `${ctx}: checksumParams.${key} must be a non-negative integer or a 0x hex string (§8).` });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// width: 1–64 if present; required when the field type lacks a single
|
|
374
|
+
// declared bit width (bytes/varint/berLength).
|
|
375
|
+
const typeHasDeclaredWidth = field.type.kind === "int" || field.type.kind === "bits" || field.type.kind === "enum";
|
|
376
|
+
if (p.width !== undefined) {
|
|
377
|
+
if (!Number.isInteger(p.width) || p.width < 1 || p.width > 64)
|
|
378
|
+
errors.push({ message: `${ctx}: checksumParams.width must be an integer in 1–64, got ${String(p.width)} (§8).` });
|
|
379
|
+
}
|
|
380
|
+
else if (!typeHasDeclaredWidth) {
|
|
381
|
+
errors.push({ message: `${ctx}: checksumParams on a field whose type has no single declared bit width (e.g. bytes) requires an explicit width (§8/§11.1).` });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Decode a mask given as a non-negative integer or a 0x hex string to BigInt (§12, D4). */
|
|
385
|
+
function decodeMask(mask) {
|
|
386
|
+
if (typeof mask === "number") {
|
|
387
|
+
if (!Number.isInteger(mask) || mask < 0)
|
|
388
|
+
return undefined;
|
|
389
|
+
return BigInt(mask);
|
|
390
|
+
}
|
|
391
|
+
if (typeof mask === "string" && HEX_PARAM_RE.test(mask))
|
|
392
|
+
return BigInt(mask);
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
/** Subfield keys (schema Subfield, `additionalProperties: false`, §12). */
|
|
396
|
+
const SUBFIELD_KEYS = new Set([
|
|
397
|
+
"id", "name", "mask", "doc", "values", "level", "category", "meta",
|
|
398
|
+
]);
|
|
399
|
+
/**
|
|
400
|
+
* §12/§11.1 (D4): validate `subfields`. Permitted only on `int` or a
|
|
401
|
+
* byte-aligned `bits` field (n a multiple of 8). Each `mask` must be a
|
|
402
|
+
* non-negative integer or a 0x hex string and fit within the parent's declared
|
|
403
|
+
* bit width (mask < 2^width), decoded at BigInt precision so masks above 53 bits
|
|
404
|
+
* are exact. Overlap / zero masks are a §11.4 lint, not a hard error.
|
|
405
|
+
*/
|
|
406
|
+
function validateSubfields(field, ctx, errors) {
|
|
407
|
+
const subs = field.subfields;
|
|
408
|
+
if (!Array.isArray(subs)) {
|
|
409
|
+
errors.push({ message: `${ctx}: subfields must be an array (§12).` });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const t = field.type;
|
|
413
|
+
const isInt = t.kind === "int";
|
|
414
|
+
const isByteAlignedBits = t.kind === "bits" && Number.isInteger(t.n) && t.n % 8 === 0;
|
|
415
|
+
if (!isInt && !isByteAlignedBits) {
|
|
416
|
+
errors.push({ message: `${ctx}: subfields are only allowed on an int field or a byte-aligned bits field (n a multiple of 8), §12/§11.1.` });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const width = isInt ? t.bits : t.n;
|
|
420
|
+
const limit = 1n << BigInt(width);
|
|
421
|
+
subs.forEach((sf, i) => {
|
|
422
|
+
const tag = `${ctx}: subfields[${i}]`;
|
|
423
|
+
if (typeof sf !== "object" || sf === null) {
|
|
424
|
+
errors.push({ message: `${tag} must be an object (§12).` });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
for (const key of Object.keys(sf))
|
|
428
|
+
if (!SUBFIELD_KEYS.has(key))
|
|
429
|
+
errors.push({ message: `${tag} has unknown key "${key}" (allowed: ${[...SUBFIELD_KEYS].join(", ")}) (§12).` });
|
|
430
|
+
if (typeof sf.id !== "string" || !ID_RE.test(sf.id))
|
|
431
|
+
errors.push({ message: `${tag}: id "${String(sf.id)}" must match ${ID_RE} (§12).` });
|
|
432
|
+
if (typeof sf.name !== "string")
|
|
433
|
+
errors.push({ message: `${tag}: name must be a string (§12).` });
|
|
434
|
+
const m = decodeMask(sf.mask);
|
|
435
|
+
if (m === undefined)
|
|
436
|
+
errors.push({ message: `${tag}: mask must be a non-negative integer or a ^0x[0-9A-Fa-f]+$ hex string (§12).` });
|
|
437
|
+
else if (m >= limit)
|
|
438
|
+
errors.push({ message: `${tag}: mask ${String(sf.mask)} does not fit within the field's declared ${width}-bit width (§12/§11.1).` });
|
|
439
|
+
if (sf.level !== undefined && !NORM_LEVELS.has(sf.level))
|
|
440
|
+
errors.push({ message: `${tag}: invalid level "${String(sf.level)}" (must be must|should|may) (§12).` });
|
|
441
|
+
if (sf.category !== undefined && !CATEGORY_TOKEN_SET.has(sf.category))
|
|
442
|
+
errors.push({ message: `${tag}: category "${String(sf.category)}" is not one of the nine category tokens (§5.1).` });
|
|
443
|
+
if (sf.doc !== undefined && typeof sf.doc !== "string")
|
|
444
|
+
errors.push({ message: `${tag}: doc must be a string (§12).` });
|
|
445
|
+
validateMeta(sf.meta, tag, errors);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function validateField(field, ctx, w) {
|
|
449
|
+
if (typeof field.id !== "string" || !ID_RE.test(field.id))
|
|
450
|
+
w.errors.push({ message: `${ctx}: field id "${String(field.id)}" must match ${ID_RE}.` });
|
|
451
|
+
if (typeof field.name !== "string")
|
|
452
|
+
w.errors.push({ message: `${ctx}: field "${field.id}" is missing a name.` });
|
|
453
|
+
if (!field.type) {
|
|
454
|
+
w.errors.push({ message: `${ctx}: field "${field.id}" is missing a type.` });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
validateType(field.type, `${ctx}/${field.id}`, w.errors, w.pc);
|
|
458
|
+
validateMeta(field.meta, `${ctx}/${field.id}`, w.errors);
|
|
459
|
+
// §5.1/§11.1: `category` is a closed nine-token set. An unknown token (e.g. a
|
|
460
|
+
// typo'd "checsum" or a not-yet-standard token) is a validation error, mirroring
|
|
461
|
+
// the schema's CategoryToken enum so the two validation layers agree.
|
|
462
|
+
if (field.category !== undefined && !CATEGORY_TOKEN_SET.has(field.category))
|
|
463
|
+
w.errors.push({ message: `${ctx}/${field.id}: category "${String(field.category)}" is not one of the nine category tokens (§5.1).` });
|
|
464
|
+
// §8/§11.1: `checksumParams` may only refine a CRC parameter model. Using it
|
|
465
|
+
// with a named non-CRC algorithm (`internet`, `adler32`) is a validation error
|
|
466
|
+
// — these algorithms have fixed internal parameters incompatible with the CRC set.
|
|
467
|
+
if (field.checksumParams !== undefined &&
|
|
468
|
+
field.checksumAlgorithm !== undefined &&
|
|
469
|
+
NON_CRC_CHECKSUM_ALGORITHMS.has(field.checksumAlgorithm))
|
|
470
|
+
w.errors.push({ message: `${ctx}/${field.id}: checksumParams cannot be used with the non-CRC algorithm "${field.checksumAlgorithm}" (§8/§11.1).` });
|
|
471
|
+
if (field.checksumParams !== undefined)
|
|
472
|
+
validateChecksumParams(field, `${ctx}/${field.id}`, w.errors);
|
|
473
|
+
if (field.subfields !== undefined)
|
|
474
|
+
validateSubfields(field, `${ctx}/${field.id}`, w.errors);
|
|
475
|
+
if (field.byteOrder !== undefined && field.byteOrder !== "BE" && field.byteOrder !== "LE")
|
|
476
|
+
w.errors.push({ message: `${ctx}/${field.id}: byteOrder must be 'BE' or 'LE'.` });
|
|
477
|
+
if (field.computedFrom !== undefined) {
|
|
478
|
+
if (field.computedFrom.kind !== "wireSize")
|
|
479
|
+
w.errors.push({ message: `${ctx}/${field.id}: computedFrom must be a wireSize expression.` });
|
|
480
|
+
else
|
|
481
|
+
validateExprPlacement(field.computedFrom, "computedFrom", `${ctx}/${field.id}`, w.errors, w.pc);
|
|
482
|
+
}
|
|
483
|
+
if (field.values !== undefined) {
|
|
484
|
+
if (!Array.isArray(field.values)) {
|
|
485
|
+
w.errors.push({ message: `${ctx}/${field.id}: values must be an array (§5.3).` });
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
field.values.forEach((ve, i) => {
|
|
489
|
+
const tag = `${ctx}/${field.id}: values[${i}]`;
|
|
490
|
+
// Guard against null/non-object entries (e.g. YAML `values: [~]`):
|
|
491
|
+
// the validator must report, not crash on, untrusted input.
|
|
492
|
+
if (typeof ve !== "object" || ve === null) {
|
|
493
|
+
w.errors.push({ message: `${tag} must be an object (§5.3).` });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Mirrors the schema's ValueEntry `additionalProperties: false` so a
|
|
497
|
+
// typo'd annotation key (e.g. "lable") cannot silently vanish (§5.3).
|
|
498
|
+
for (const key of Object.keys(ve)) {
|
|
499
|
+
if (!VALUE_ENTRY_KEYS.has(key))
|
|
500
|
+
w.errors.push({ message: `${tag} has unknown key "${key}" (allowed: ${[...VALUE_ENTRY_KEYS].join(", ")}) (§5.3).` });
|
|
501
|
+
}
|
|
502
|
+
const hasValue = ve.value !== undefined;
|
|
503
|
+
const hasRange = ve.range !== undefined;
|
|
504
|
+
const hasPattern = ve.pattern !== undefined;
|
|
505
|
+
const forms = (hasValue ? 1 : 0) + (hasRange ? 1 : 0) + (hasPattern ? 1 : 0);
|
|
506
|
+
if (forms !== 1)
|
|
507
|
+
w.errors.push({ message: `${tag} must set exactly one of 'value', 'range', or 'pattern' (§5.3).` });
|
|
508
|
+
// Values may be negative: signed int fields annotate negative codes (§5.3).
|
|
509
|
+
if (hasValue && !Number.isInteger(ve.value))
|
|
510
|
+
w.errors.push({ message: `${tag}: value must be an integer.` });
|
|
511
|
+
if (hasRange) {
|
|
512
|
+
const r = ve.range;
|
|
513
|
+
if (!Array.isArray(r) || r.length !== 2 ||
|
|
514
|
+
!Number.isInteger(r[0]) || !Number.isInteger(r[1]) ||
|
|
515
|
+
r[1] < r[0])
|
|
516
|
+
w.errors.push({ message: `${tag}: range must be [min, max] integers with min ≤ max.` });
|
|
517
|
+
}
|
|
518
|
+
if (hasPattern && (typeof ve.pattern !== "string" || !PATTERN_RE.test(ve.pattern)))
|
|
519
|
+
w.errors.push({ message: `${tag}: pattern must be a non-empty string of 0, 1, or x/X.` });
|
|
520
|
+
// Mirrors the schema's ValueEntry `name`/`label`/`doc` `type: string`
|
|
521
|
+
// so a YAML scalar that parses as a number (e.g. `name: 404`) is
|
|
522
|
+
// rejected by both validation layers (§5.3, §16.4).
|
|
523
|
+
if (ve.name !== undefined && typeof ve.name !== "string")
|
|
524
|
+
w.errors.push({ message: `${tag}: name must be a string (§5.3).` });
|
|
525
|
+
if (ve.label !== undefined && typeof ve.label !== "string")
|
|
526
|
+
w.errors.push({ message: `${tag}: label must be a string (§5.3).` });
|
|
527
|
+
if (ve.doc !== undefined && typeof ve.doc !== "string")
|
|
528
|
+
w.errors.push({ message: `${tag}: doc must be a string (§5.3).` });
|
|
529
|
+
if (ve.level !== undefined && !NORM_LEVELS.has(ve.level))
|
|
530
|
+
w.errors.push({ message: `${tag}: invalid level "${String(ve.level)}" (must be must|should|may).` });
|
|
531
|
+
validateMeta(ve.meta, tag, w.errors);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Run `body` with `id` marked open on the parse stack (§10.1 wireSize rule). */
|
|
537
|
+
function withOpen(w, id, body) {
|
|
538
|
+
if (w.pc === undefined || id === undefined) {
|
|
539
|
+
body();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const added = !w.pc.openIds.has(id);
|
|
543
|
+
if (added)
|
|
544
|
+
w.pc.openIds.add(id);
|
|
545
|
+
try {
|
|
546
|
+
body();
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
if (added)
|
|
550
|
+
w.pc.openIds.delete(id);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function validateGroup(g, ctx, w) {
|
|
554
|
+
validateMeta(g.meta, `${ctx}/${g.id}`, w.errors);
|
|
555
|
+
if (!Array.isArray(g.children))
|
|
556
|
+
w.errors.push({ message: `${ctx}: group "${g.id}" must have a children array.` });
|
|
557
|
+
else
|
|
558
|
+
withOpen(w, g.id, () => {
|
|
559
|
+
for (const child of g.children)
|
|
560
|
+
validateContainerCtx(child, `${ctx}/${g.id}`, w);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function validateRepeat(r, ctx, w) {
|
|
564
|
+
const sub = `${ctx}/${r.id}`;
|
|
565
|
+
// §10.7: a `repeat.count`/`repeat.count.until` ordinary `ref` to a field of
|
|
566
|
+
// this repeat's element resolves to the just-completed iteration's value and
|
|
567
|
+
// is exempt from the §10.1 forward-reference rule. Build the exempt set from
|
|
568
|
+
// the element subtree's referenceable ids (bare + dotted, exactly as a
|
|
569
|
+
// within-element ref would name them). Walking `element.fields` — NOT the
|
|
570
|
+
// repeat container — means this set does NOT include the repeat's own id, so
|
|
571
|
+
// a self-ref to the repeat id (or a forward ref to a non-element sibling)
|
|
572
|
+
// still errors. The set is passed ONLY to the count/until placement calls.
|
|
573
|
+
const elemIds = new Set();
|
|
574
|
+
if (w.pc && r.element && Array.isArray(r.element.fields))
|
|
575
|
+
collectDeclaredIds(r.element.fields, w.defs, "", elemIds, new Set(), 0);
|
|
576
|
+
if (r.count === "eos") {
|
|
577
|
+
/* ok */
|
|
578
|
+
}
|
|
579
|
+
else if (typeof r.count === "object" && "until" in r.count) {
|
|
580
|
+
// Mirrors schema RepeatCount until-form additionalProperties:false: the
|
|
581
|
+
// count object accepts only the `until` key.
|
|
582
|
+
for (const key of Object.keys(r.count))
|
|
583
|
+
if (key !== "until")
|
|
584
|
+
w.errors.push({ message: `${sub}: repeat count until-object accepts only the "until" key (got "${key}").` });
|
|
585
|
+
if (!isValidExpr(r.count.until))
|
|
586
|
+
w.errors.push({ message: `${sub}: repeat until has a malformed expression.` });
|
|
587
|
+
else
|
|
588
|
+
validateExprPlacement(r.count.until, "repeat.until", sub, w.errors, w.pc, elemIds);
|
|
589
|
+
}
|
|
590
|
+
else if (!isValidExpr(r.count)) {
|
|
591
|
+
w.errors.push({ message: `${sub}: repeat count has a malformed expression.` });
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
validateExprPlacement(r.count, "repeat.count", sub, w.errors, w.pc, elemIds);
|
|
595
|
+
}
|
|
596
|
+
if (!r.element || !Array.isArray(r.element.fields))
|
|
597
|
+
w.errors.push({ message: `${sub}: repeat is missing an element struct.` });
|
|
598
|
+
else
|
|
599
|
+
withOpen(w, r.id, () => validateStruct(r.element, sub, w));
|
|
600
|
+
}
|
|
601
|
+
function validateSwitch(s, ctx, w) {
|
|
602
|
+
const sub = `${ctx}/${s.id}`;
|
|
603
|
+
if (!isValidExpr(s.on))
|
|
604
|
+
w.errors.push({ message: `${sub}: switch has an invalid discriminator expression.` });
|
|
605
|
+
else
|
|
606
|
+
validateExprPlacement(s.on, "switch.on", sub, w.errors, w.pc);
|
|
607
|
+
if (!s.cases || typeof s.cases !== "object") {
|
|
608
|
+
w.errors.push({ message: `${sub}: switch is missing cases.` });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
withOpen(w, s.id, () => {
|
|
612
|
+
for (const [key, struct] of Object.entries(s.cases)) {
|
|
613
|
+
if (!SWITCH_KEY_RE.test(key)) {
|
|
614
|
+
w.errors.push({ message: `${sub}: invalid switch case key "${key}" (use decimal, "lo-hi", "a,b,c", or "_").` });
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
const range = /^(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(key);
|
|
618
|
+
if (range && Number(range[1]) > Number(range[2]))
|
|
619
|
+
w.errors.push({ message: `${sub}: invalid range switch case key "${key}" (lo must be <= hi).` });
|
|
620
|
+
}
|
|
621
|
+
validateStruct(struct, `${sub}/${key}`, w);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function validateEncrypted(e, ctx, w) {
|
|
626
|
+
const sub = `${ctx}/${e.id}`;
|
|
627
|
+
validateMeta(e.meta, sub, w.errors);
|
|
628
|
+
if (!e.plaintext || !Array.isArray(e.plaintext.fields))
|
|
629
|
+
w.errors.push({ message: `${sub}: encrypted container must have a plaintext struct.` });
|
|
630
|
+
if (e.wireBits !== undefined) {
|
|
631
|
+
if (!isValidExpr(e.wireBits))
|
|
632
|
+
w.errors.push({ message: `${sub}: encrypted wireBits is malformed.` });
|
|
633
|
+
else
|
|
634
|
+
validateExprPlacement(e.wireBits, "wireBits", sub, w.errors, w.pc);
|
|
635
|
+
}
|
|
636
|
+
// §5/§11.1 (D6): every `headerProtected` id must resolve to either (a) a
|
|
637
|
+
// direct leaf field of this encrypted container's plaintext, or (b) a field
|
|
638
|
+
// declared earlier in the SAME body in document order (a plaintext-external
|
|
639
|
+
// header field a header-protection scheme reorders/masks, e.g. QUIC's first
|
|
640
|
+
// byte and packet number). The "earlier same-body" set is exactly the
|
|
641
|
+
// document-order declaredIds tracked so far (w.pc). This is the single
|
|
642
|
+
// normative resolution set shared with normalize (walkEncrypted).
|
|
643
|
+
if (Array.isArray(e.headerProtected) && e.plaintext && Array.isArray(e.plaintext.fields)) {
|
|
644
|
+
const resolvable = new Set();
|
|
645
|
+
for (const c of e.plaintext.fields)
|
|
646
|
+
if (isField(c) && typeof c.id === "string")
|
|
647
|
+
resolvable.add(c.id);
|
|
648
|
+
if (w.pc !== undefined)
|
|
649
|
+
for (const id of w.pc.declaredIds)
|
|
650
|
+
resolvable.add(id);
|
|
651
|
+
for (const hp of e.headerProtected) {
|
|
652
|
+
if (typeof hp !== "string" || !resolvable.has(hp))
|
|
653
|
+
w.errors.push({ message: `${sub}: headerProtected id "${String(hp)}" resolves to neither a plaintext field of this encrypted container nor a field declared earlier in the same body (§5/§11.1).` });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (!e.plaintext)
|
|
657
|
+
return;
|
|
658
|
+
// §5/§11.1: an encrypted.plaintext with wireBits provides a bit budget; one
|
|
659
|
+
// without wireBits has no defined budget, so remaining/enclosingBits inside
|
|
660
|
+
// it are validation errors (regardless of any outer top-body budget).
|
|
661
|
+
const prevPc = w.pc;
|
|
662
|
+
if (prevPc !== undefined) {
|
|
663
|
+
const ok = e.wireBits !== undefined;
|
|
664
|
+
w.pc = { ...prevPc, remainingOk: ok, enclosingBitsOk: ok };
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
withOpen(w, e.id, () => validateStruct(e.plaintext, sub, w));
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
if (prevPc !== undefined)
|
|
671
|
+
w.pc = prevPc;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function validateBounded(b, ctx, w) {
|
|
675
|
+
const sub = `${ctx}/${b.id}`;
|
|
676
|
+
validateMeta(b.meta, sub, w.errors);
|
|
677
|
+
if (!isValidExpr(b.bytes))
|
|
678
|
+
w.errors.push({ message: `${sub}: bounded bytes is malformed.` });
|
|
679
|
+
else
|
|
680
|
+
validateExprPlacement(b.bytes, "bounded.bytes", sub, w.errors, w.pc);
|
|
681
|
+
if (!Array.isArray(b.fields)) {
|
|
682
|
+
w.errors.push({ message: `${sub}: bounded must have a fields array.` });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
// A `bounded` scope carries a byte budget, so `remaining` is well-defined
|
|
686
|
+
// inside it (§4). `enclosingBits` resolves to the nearest bit-budget provider,
|
|
687
|
+
// which the enclosing context already tracks.
|
|
688
|
+
const prevPc = w.pc;
|
|
689
|
+
if (prevPc !== undefined)
|
|
690
|
+
w.pc = { ...prevPc, remainingOk: true };
|
|
691
|
+
try {
|
|
692
|
+
withOpen(w, b.id, () => {
|
|
693
|
+
for (const child of b.fields)
|
|
694
|
+
validateContainerCtx(child, sub, w);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
if (prevPc !== undefined)
|
|
699
|
+
w.pc = prevPc;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function validateOptional(o, ctx, w) {
|
|
703
|
+
validateMeta(o.meta, ctx, w.errors);
|
|
704
|
+
if (!isValidExpr(o.when))
|
|
705
|
+
w.errors.push({ message: `${ctx}: optional has an invalid 'when' expression.` });
|
|
706
|
+
else
|
|
707
|
+
validateExprPlacement(o.when, "optional.when", ctx, w.errors, w.pc);
|
|
708
|
+
validateContainerCtx(o.container, ctx, w);
|
|
709
|
+
}
|
|
710
|
+
export function validateContainer(c, ctx) {
|
|
711
|
+
const w = { errors: [], defs: {}, inDef: false };
|
|
712
|
+
validateContainerCtx(c, ctx, w);
|
|
713
|
+
if (w.errors.length > 0)
|
|
714
|
+
throw new Error(w.errors[0].message);
|
|
715
|
+
}
|
|
716
|
+
/** The declarable id of a container, if any (used for document-order tracking). */
|
|
717
|
+
function containerId(c) {
|
|
718
|
+
if (isField(c))
|
|
719
|
+
return c.id;
|
|
720
|
+
switch (c.kind) {
|
|
721
|
+
case "group":
|
|
722
|
+
case "repeat":
|
|
723
|
+
case "switch":
|
|
724
|
+
case "encrypted":
|
|
725
|
+
case "bounded":
|
|
726
|
+
case "ref":
|
|
727
|
+
case "virtual":
|
|
728
|
+
return c.id;
|
|
729
|
+
case "optional":
|
|
730
|
+
case "align":
|
|
731
|
+
return c.id;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function validateContainerCtx(c, ctx, w) {
|
|
735
|
+
dispatchContainer(c, ctx, w);
|
|
736
|
+
// §10.1: register this container's id as declared/closed in document order so
|
|
737
|
+
// later siblings' wireSize/repeat-id references resolve, but not earlier ones.
|
|
738
|
+
// declaredExprIds additionally captures the richer subtree set (dotted +
|
|
739
|
+
// bare-tail + local-ref-expanded ids) for the §D11 leaf-ref forward check, in
|
|
740
|
+
// the SAME document-order position. Both registrations happen post-dispatch,
|
|
741
|
+
// so a container's own id is not yet present while its body expressions are
|
|
742
|
+
// validated (self-size ref correctly flagged).
|
|
743
|
+
if (w.pc) {
|
|
744
|
+
const id = containerId(c);
|
|
745
|
+
if (typeof id === "string")
|
|
746
|
+
w.pc.declaredIds.add(id);
|
|
747
|
+
if (w.pc.declaredExprIds !== undefined)
|
|
748
|
+
collectSubtreeDeclaredIds(c, w.defs, "", w.pc.declaredExprIds, new Set(), 0);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function dispatchContainer(c, ctx, w) {
|
|
752
|
+
if (isField(c)) {
|
|
753
|
+
validateField(c, ctx, w);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
switch (c.kind) {
|
|
757
|
+
case "group":
|
|
758
|
+
validateGroup(c, ctx, w);
|
|
759
|
+
return;
|
|
760
|
+
case "repeat":
|
|
761
|
+
validateRepeat(c, ctx, w);
|
|
762
|
+
return;
|
|
763
|
+
case "switch":
|
|
764
|
+
validateSwitch(c, ctx, w);
|
|
765
|
+
return;
|
|
766
|
+
case "encrypted":
|
|
767
|
+
validateEncrypted(c, ctx, w);
|
|
768
|
+
return;
|
|
769
|
+
case "bounded":
|
|
770
|
+
validateBounded(c, ctx, w);
|
|
771
|
+
return;
|
|
772
|
+
case "optional":
|
|
773
|
+
validateOptional(c, ctx, w);
|
|
774
|
+
return;
|
|
775
|
+
case "virtual":
|
|
776
|
+
if (typeof c.id !== "string" || !ID_RE.test(c.id))
|
|
777
|
+
w.errors.push({ message: `${ctx}: virtual id "${String(c.id)}" must match ${ID_RE}.` });
|
|
778
|
+
if (w.inDef)
|
|
779
|
+
w.errors.push({ message: `${ctx}: virtual field "${c.id}" is forbidden inside a defs struct.` });
|
|
780
|
+
if (!isValidExpr(c.expr))
|
|
781
|
+
w.errors.push({ message: `${ctx}: virtual "${c.id}" has a malformed expr.` });
|
|
782
|
+
else
|
|
783
|
+
validateExprPlacement(c.expr, "virtual.expr", ctx, w.errors, w.pc);
|
|
784
|
+
return;
|
|
785
|
+
case "align":
|
|
786
|
+
if (!Number.isInteger(c.to) || c.to < 1 || (c.to & (c.to - 1)) !== 0 || c.to % 8 !== 0)
|
|
787
|
+
w.errors.push({ message: `${ctx}: align 'to' must be a power of 2 and a multiple of 8, got ${c.to}.` });
|
|
788
|
+
if (c.fill !== undefined && (!Number.isInteger(c.fill) || c.fill < 0 || c.fill > 255))
|
|
789
|
+
w.errors.push({ message: `${ctx}: align 'fill' must be 0–255, got ${c.fill}.` });
|
|
790
|
+
return;
|
|
791
|
+
case "ref":
|
|
792
|
+
if (typeof c.ref !== "string" || c.ref.length === 0)
|
|
793
|
+
w.errors.push({ message: `${ctx}: ref container is missing 'ref'.` });
|
|
794
|
+
else if (!w.defs[c.ref] && !c.ref.includes("."))
|
|
795
|
+
w.errors.push({ message: `${ctx}: ref target "${c.ref}" not found in defs.` });
|
|
796
|
+
if (typeof c.id !== "string" || !ID_RE.test(c.id))
|
|
797
|
+
w.errors.push({ message: `${ctx}: ref container id "${String(c.id)}" must match ${ID_RE}.` });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function validateStruct(s, ctx, w) {
|
|
802
|
+
validateMeta(s.meta, `${ctx}/${s.id ?? "?"}`, w.errors);
|
|
803
|
+
if (!Array.isArray(s.fields)) {
|
|
804
|
+
w.errors.push({ message: `${ctx}/${s.id ?? "?"}: struct must have a fields array.` });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
for (const child of s.fields)
|
|
808
|
+
validateContainerCtx(child, `${ctx}/${s.id}`, w);
|
|
809
|
+
}
|
|
810
|
+
/* ------------------------------------------------------------------ *
|
|
811
|
+
* Ref cycle detection (§6, §11.1)
|
|
812
|
+
* ------------------------------------------------------------------ */
|
|
813
|
+
function refTargets(fields, out) {
|
|
814
|
+
for (const c of fields) {
|
|
815
|
+
if (isField(c))
|
|
816
|
+
continue;
|
|
817
|
+
switch (c.kind) {
|
|
818
|
+
case "ref":
|
|
819
|
+
out.push(c.ref);
|
|
820
|
+
break;
|
|
821
|
+
case "group":
|
|
822
|
+
refTargets(c.children, out);
|
|
823
|
+
break;
|
|
824
|
+
case "bounded":
|
|
825
|
+
refTargets(c.fields, out);
|
|
826
|
+
break;
|
|
827
|
+
case "optional":
|
|
828
|
+
refTargets([c.container], out);
|
|
829
|
+
break;
|
|
830
|
+
case "repeat":
|
|
831
|
+
refTargets(c.element.fields, out);
|
|
832
|
+
break;
|
|
833
|
+
case "encrypted":
|
|
834
|
+
refTargets(c.plaintext.fields, out);
|
|
835
|
+
break;
|
|
836
|
+
case "switch":
|
|
837
|
+
for (const arm of Object.values(c.cases))
|
|
838
|
+
refTargets(arm.fields, out);
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/** All repeat container ids reachable in `fields` (§10.1 repeat-id ref rule). */
|
|
844
|
+
function collectRepeatIds(fields, out) {
|
|
845
|
+
for (const c of fields) {
|
|
846
|
+
if (isField(c))
|
|
847
|
+
continue;
|
|
848
|
+
switch (c.kind) {
|
|
849
|
+
case "repeat":
|
|
850
|
+
out.add(c.id);
|
|
851
|
+
collectRepeatIds(c.element.fields, out);
|
|
852
|
+
break;
|
|
853
|
+
case "group":
|
|
854
|
+
collectRepeatIds(c.children, out);
|
|
855
|
+
break;
|
|
856
|
+
case "bounded":
|
|
857
|
+
collectRepeatIds(c.fields, out);
|
|
858
|
+
break;
|
|
859
|
+
case "optional":
|
|
860
|
+
collectRepeatIds([c.container], out);
|
|
861
|
+
break;
|
|
862
|
+
case "encrypted":
|
|
863
|
+
collectRepeatIds(c.plaintext.fields, out);
|
|
864
|
+
break;
|
|
865
|
+
case "switch":
|
|
866
|
+
for (const arm of Object.values(c.cases))
|
|
867
|
+
collectRepeatIds(arm.fields, out);
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* §D11: collect every id a body/constraint `ref`/`wireSize` may legally name.
|
|
874
|
+
* This includes: each authored container/field id (bare), each local-ref-
|
|
875
|
+
* expanded dotted id (`{ref.id}.{field.id}`, recursing through local defs with
|
|
876
|
+
* a one-level cap for recursive defs), and — to honour §6 nearest-preceding
|
|
877
|
+
* bare-id resolution — the bare tail segment of every expanded id (so a bare
|
|
878
|
+
* `oct0` referencing an expanded `src.oct0` resolves). Import-qualified ref
|
|
879
|
+
* targets are NOT expanded here; those are deferred to the import layer (§1.2)
|
|
880
|
+
* and detected via `importPrefixes` at the use site.
|
|
881
|
+
*/
|
|
882
|
+
function collectDeclaredIds(containers, defs, prefix, out, seenRefs, depth) {
|
|
883
|
+
if (depth > 64)
|
|
884
|
+
return;
|
|
885
|
+
for (const c of containers)
|
|
886
|
+
collectSubtreeDeclaredIds(c, defs, prefix, out, seenRefs, depth);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* §D11: emit every body/constraint-referenceable id contributed by a SINGLE
|
|
890
|
+
* container `c` and its subtree (full dotted id + bare tail segment, with local
|
|
891
|
+
* `ref` targets expanded once). Used both up-front to build `documentDeclaredIds`
|
|
892
|
+
* (via collectDeclaredIds) and incrementally — at each container's post-dispatch
|
|
893
|
+
* close point — to build the document-order `declaredExprIds` set the forward-
|
|
894
|
+
* reference check consults. Because the up-front and incremental builders share
|
|
895
|
+
* this helper, the two sets agree on exactly which ids exist; they differ only
|
|
896
|
+
* in WHEN an id appears (incrementally, an id appears only once its container has
|
|
897
|
+
* closed in document order), which is precisely the §10.1 forward-order signal.
|
|
898
|
+
*/
|
|
899
|
+
function collectSubtreeDeclaredIds(c, defs, prefix, out, seenRefs, depth) {
|
|
900
|
+
if (depth > 64)
|
|
901
|
+
return;
|
|
902
|
+
const add = (id) => {
|
|
903
|
+
const full = prefix ? `${prefix}.${id}` : id;
|
|
904
|
+
out.add(full);
|
|
905
|
+
out.add(id); // bare tail segment (nearest-preceding bare-id resolution, §6)
|
|
906
|
+
};
|
|
907
|
+
if (isField(c)) {
|
|
908
|
+
if (typeof c.id === "string")
|
|
909
|
+
add(c.id);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
switch (c.kind) {
|
|
913
|
+
case "virtual":
|
|
914
|
+
if (typeof c.id === "string")
|
|
915
|
+
add(c.id);
|
|
916
|
+
break;
|
|
917
|
+
case "group":
|
|
918
|
+
add(c.id);
|
|
919
|
+
collectDeclaredIds(c.children, defs, prefix, out, seenRefs, depth);
|
|
920
|
+
break;
|
|
921
|
+
case "bounded":
|
|
922
|
+
add(c.id);
|
|
923
|
+
collectDeclaredIds(c.fields, defs, prefix, out, seenRefs, depth);
|
|
924
|
+
break;
|
|
925
|
+
case "optional":
|
|
926
|
+
if (typeof c.id === "string")
|
|
927
|
+
add(c.id);
|
|
928
|
+
collectDeclaredIds([c.container], defs, prefix, out, seenRefs, depth);
|
|
929
|
+
break;
|
|
930
|
+
case "encrypted":
|
|
931
|
+
add(c.id);
|
|
932
|
+
collectDeclaredIds(c.plaintext.fields, defs, prefix, out, seenRefs, depth);
|
|
933
|
+
break;
|
|
934
|
+
case "repeat":
|
|
935
|
+
add(c.id);
|
|
936
|
+
collectDeclaredIds(c.element.fields, defs, prefix ? `${prefix}.${c.id}` : c.id, out, seenRefs, depth);
|
|
937
|
+
break;
|
|
938
|
+
case "switch":
|
|
939
|
+
add(c.id);
|
|
940
|
+
for (const arm of Object.values(c.cases))
|
|
941
|
+
collectDeclaredIds(arm.fields, defs, prefix, out, seenRefs, depth);
|
|
942
|
+
break;
|
|
943
|
+
case "align":
|
|
944
|
+
if (typeof c.id === "string")
|
|
945
|
+
add(c.id);
|
|
946
|
+
break;
|
|
947
|
+
case "ref": {
|
|
948
|
+
add(c.id);
|
|
949
|
+
if (c.ref.includes("."))
|
|
950
|
+
break; // import-qualified: defer to import layer
|
|
951
|
+
if (seenRefs.has(c.ref))
|
|
952
|
+
break; // recursive def: expand once
|
|
953
|
+
const def = defs[c.ref];
|
|
954
|
+
if (!def)
|
|
955
|
+
break;
|
|
956
|
+
collectDeclaredIds(def.fields, defs, prefix ? `${prefix}.${c.id}` : c.id, out, new Set([...seenRefs, c.ref]), depth + 1);
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/** True if the two arm-paths can never be selected simultaneously (§5). */
|
|
962
|
+
function mutuallyExclusive(a, b) {
|
|
963
|
+
const n = Math.min(a.length, b.length);
|
|
964
|
+
for (let i = 0; i < n; i++) {
|
|
965
|
+
if (a[i].switchId === b[i].switchId) {
|
|
966
|
+
if (a[i].key !== b[i].key)
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
// Diverged on different switches before any shared discriminating switch:
|
|
971
|
+
// they are not mutually exclusive through this position.
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* §2/§11.1: detect two declarations that would emit the SAME expanded id while
|
|
979
|
+
* both able to be present in the env at once. "Expanded id" here is the id of
|
|
980
|
+
* the emitted NormalizedField: the ref-prefix-joined id (group/optional/bounded/
|
|
981
|
+
* encrypted/align do NOT contribute to the prefix; only `ref` does). The `#N`
|
|
982
|
+
* repeat-index suffix is a runtime instance handle (§6) and is NOT part of this
|
|
983
|
+
* static id; a repeat container instead opens a distinct id namespace (its
|
|
984
|
+
* element-field ids cannot collide with non-repeat siblings), captured by
|
|
985
|
+
* threading the repeat id into the prefix path. Mutually-exclusive switch arms
|
|
986
|
+
* may legally reuse an id (§5); every other same-namespace duplicate — a flat
|
|
987
|
+
* sibling duplicate, a duplicated ref instantiation id, an in-arm duplicate, or
|
|
988
|
+
* an arm-vs-enclosing clash — is a validation error.
|
|
989
|
+
*
|
|
990
|
+
* SCOPE LIMITATION (mirrors detectRefCycles): only local-def `ref` targets are
|
|
991
|
+
* expanded. An import-qualified ref (a `ref` containing `.`) is not expanded
|
|
992
|
+
* here, so collisions inside imported defs are left to the import-resolving
|
|
993
|
+
* layer, which MUST re-run this check over the merged def tree.
|
|
994
|
+
*/
|
|
995
|
+
function detectExpandedIdCollisions(packet, defs, errors) {
|
|
996
|
+
// eid -> records seen so far. A new record collides with an existing one iff
|
|
997
|
+
// they are NOT mutually exclusive.
|
|
998
|
+
const seen = new Map();
|
|
999
|
+
const register = (eid, armPath, ctx) => {
|
|
1000
|
+
const prior = seen.get(eid);
|
|
1001
|
+
if (prior === undefined) {
|
|
1002
|
+
seen.set(eid, [{ armPath, ctx }]);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
for (const rec of prior) {
|
|
1006
|
+
if (!mutuallyExclusive(rec.armPath, armPath)) {
|
|
1007
|
+
errors.push({
|
|
1008
|
+
message: `expanded id "${eid}" is declared more than once where both declarations can be live at the same time (${rec.ctx} and ${ctx}); ids that share an expanded id must be in mutually-exclusive switch arms (§2/§11.1).`,
|
|
1009
|
+
});
|
|
1010
|
+
// Still record so further duplicates are reported against this one too.
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
prior.push({ armPath, ctx });
|
|
1014
|
+
};
|
|
1015
|
+
// prefix is the ref/repeat namespace path (joined with "."); armPath tracks
|
|
1016
|
+
// switch-arm selection. seenRefs guards recursive defs (expand at most once).
|
|
1017
|
+
const walk = (containers, prefix, armPath, ctx, seenRefs, depth) => {
|
|
1018
|
+
if (depth > 64)
|
|
1019
|
+
return; // recursive-def boundary (§6/§10.7)
|
|
1020
|
+
for (const c of containers) {
|
|
1021
|
+
const eidOf = (id) => (prefix ? `${prefix}.${id}` : id);
|
|
1022
|
+
if (isField(c)) {
|
|
1023
|
+
if (typeof c.id === "string")
|
|
1024
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
switch (c.kind) {
|
|
1028
|
+
case "virtual":
|
|
1029
|
+
if (typeof c.id === "string")
|
|
1030
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1031
|
+
break;
|
|
1032
|
+
case "group":
|
|
1033
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1034
|
+
walk(c.children, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
|
|
1035
|
+
break;
|
|
1036
|
+
case "bounded":
|
|
1037
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1038
|
+
walk(c.fields, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
|
|
1039
|
+
break;
|
|
1040
|
+
case "optional":
|
|
1041
|
+
walk([c.container], prefix, armPath, ctx, seenRefs, depth);
|
|
1042
|
+
break;
|
|
1043
|
+
case "encrypted":
|
|
1044
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1045
|
+
walk(c.plaintext.fields, prefix, armPath, `${ctx}/${c.id}`, seenRefs, depth);
|
|
1046
|
+
break;
|
|
1047
|
+
case "repeat":
|
|
1048
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1049
|
+
// The repeat opens a distinct namespace: its element fields are keyed
|
|
1050
|
+
// by the repeat id so they cannot collide with non-repeat siblings,
|
|
1051
|
+
// and each iteration is distinguished by the runtime #N suffix.
|
|
1052
|
+
walk(c.element.fields, eidOf(c.id), armPath, `${ctx}/${c.id}`, seenRefs, depth);
|
|
1053
|
+
break;
|
|
1054
|
+
case "switch":
|
|
1055
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1056
|
+
for (const [key, arm] of Object.entries(c.cases)) {
|
|
1057
|
+
walk(arm.fields, prefix, [...armPath, { switchId: c.id, key }], `${ctx}/${c.id}[${key}]`, seenRefs, depth);
|
|
1058
|
+
}
|
|
1059
|
+
break;
|
|
1060
|
+
case "ref": {
|
|
1061
|
+
register(eidOf(c.id), armPath, `${ctx}/${c.id}`);
|
|
1062
|
+
// Only expand local-def targets; import-qualified refs are deferred.
|
|
1063
|
+
if (c.ref.includes("."))
|
|
1064
|
+
break;
|
|
1065
|
+
if (seenRefs.has(c.ref))
|
|
1066
|
+
break; // recursive def: expand once
|
|
1067
|
+
const def = defs[c.ref];
|
|
1068
|
+
if (!def)
|
|
1069
|
+
break;
|
|
1070
|
+
walk(def.fields, eidOf(c.id), armPath, `${ctx}/${c.id}`, new Set([...seenRefs, c.ref]), depth + 1);
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case "align":
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
walk(packet.body ?? [], "", [], packet.name, new Set(), 0);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Detect circular references through non-recursive defs (§6/§11.1), direct or
|
|
1082
|
+
* indirect. SCOPE LIMITATION: this check only follows ref targets that resolve
|
|
1083
|
+
* to a local `def` (`defs[t]`). An import-qualified ref target (one containing a
|
|
1084
|
+
* `.`, accepted unconditionally in dispatchContainer because imports are not
|
|
1085
|
+
* resolved in this module) is NOT followed, so a cycle that passes through an
|
|
1086
|
+
* import boundary cannot be detected here. Import resolution is a tool-layer
|
|
1087
|
+
* concern; the tool layer that resolves imports MUST re-run cycle detection
|
|
1088
|
+
* over the merged def set to complete the §6 check for transitive import edges.
|
|
1089
|
+
*/
|
|
1090
|
+
function detectRefCycles(defs, errors) {
|
|
1091
|
+
const visiting = new Set();
|
|
1092
|
+
const done = new Set();
|
|
1093
|
+
const dfs = (name, stack) => {
|
|
1094
|
+
if (done.has(name))
|
|
1095
|
+
return;
|
|
1096
|
+
const def = defs[name];
|
|
1097
|
+
if (!def)
|
|
1098
|
+
return;
|
|
1099
|
+
if (def.recursive)
|
|
1100
|
+
return; // recursive defs are allowed to cycle (§6)
|
|
1101
|
+
if (visiting.has(name)) {
|
|
1102
|
+
errors.push({ message: `defs: circular reference through non-recursive def "${name}" (${[...stack, name].join(" → ")}).` });
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
visiting.add(name);
|
|
1106
|
+
const targets = [];
|
|
1107
|
+
refTargets(def.fields, targets);
|
|
1108
|
+
for (const t of targets) {
|
|
1109
|
+
if (defs[t])
|
|
1110
|
+
dfs(t, [...stack, name]);
|
|
1111
|
+
}
|
|
1112
|
+
visiting.delete(name);
|
|
1113
|
+
done.add(name);
|
|
1114
|
+
};
|
|
1115
|
+
for (const name of Object.keys(defs))
|
|
1116
|
+
dfs(name, []);
|
|
1117
|
+
}
|
|
1118
|
+
export function validatePacket(packet) {
|
|
1119
|
+
const errors = [];
|
|
1120
|
+
if (typeof packet.name !== "string" || packet.name.length === 0)
|
|
1121
|
+
errors.push({ message: "Packet must have a non-empty name." });
|
|
1122
|
+
const rowBits = packet.rendererHints?.rowBits ?? packet.rowBits;
|
|
1123
|
+
if (rowBits !== undefined && (!Number.isInteger(rowBits) || rowBits <= 0))
|
|
1124
|
+
errors.push({ message: `rowBits must be a positive integer, got ${String(rowBits)}.` });
|
|
1125
|
+
if (!Array.isArray(packet.body))
|
|
1126
|
+
errors.push({ message: "Packet must have a body array." });
|
|
1127
|
+
if (packet.byteOrder !== undefined && packet.byteOrder !== "BE" && packet.byteOrder !== "LE")
|
|
1128
|
+
errors.push({ message: `byteOrder must be 'BE' or 'LE', got "${String(packet.byteOrder)}".` });
|
|
1129
|
+
if (packet.version !== undefined && !/^\d+\.\d+$/.test(packet.version))
|
|
1130
|
+
errors.push({ message: `version must be "MAJOR.MINOR", got "${packet.version}".` });
|
|
1131
|
+
validateMeta(packet.meta, "packet", errors, PACKET_META_KEYS);
|
|
1132
|
+
// Imports: unique `as` prefix, valid format.
|
|
1133
|
+
if (packet.imports) {
|
|
1134
|
+
const seen = new Set();
|
|
1135
|
+
for (const imp of packet.imports) {
|
|
1136
|
+
if (typeof imp.source !== "string" || imp.source.length === 0)
|
|
1137
|
+
errors.push({ message: `imports: entry is missing a source.` });
|
|
1138
|
+
if (typeof imp.as !== "string" || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(imp.as))
|
|
1139
|
+
errors.push({ message: `imports: 'as' prefix "${String(imp.as)}" must match [a-zA-Z][a-zA-Z0-9_]*.` });
|
|
1140
|
+
else if (seen.has(imp.as))
|
|
1141
|
+
errors.push({ message: `imports: duplicate 'as' prefix "${imp.as}".` });
|
|
1142
|
+
else
|
|
1143
|
+
seen.add(imp.as);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const defs = packet.defs ?? {};
|
|
1147
|
+
detectRefCycles(defs, errors);
|
|
1148
|
+
// §2/§11.1: two declarations that emit the same expanded id while both can be
|
|
1149
|
+
// live at once (outside mutually-exclusive switch arms) are a validation error.
|
|
1150
|
+
detectExpandedIdCollisions(packet, defs, errors);
|
|
1151
|
+
// Body — single shared WalkCtx so document-order tracking (§10.1) spans the
|
|
1152
|
+
// whole body. The top-level body is a scope provider that carries both a byte
|
|
1153
|
+
// budget (`remaining`) and an injected bit budget (`enclosingBits`), §4.
|
|
1154
|
+
const repeatIds = new Set();
|
|
1155
|
+
collectRepeatIds(packet.body ?? [], repeatIds);
|
|
1156
|
+
// §D11: the set of ids a body/constraint ref/wireSize may name, and the
|
|
1157
|
+
// import `as` prefixes whose dotted refs are deferred to the import layer.
|
|
1158
|
+
const documentDeclaredIds = new Set();
|
|
1159
|
+
collectDeclaredIds(packet.body ?? [], defs, "", documentDeclaredIds, new Set(), 0);
|
|
1160
|
+
const importPrefixes = new Set((packet.imports ?? [])
|
|
1161
|
+
.map((imp) => imp.as)
|
|
1162
|
+
.filter((a) => typeof a === "string"));
|
|
1163
|
+
const pc = {
|
|
1164
|
+
remainingOk: true,
|
|
1165
|
+
enclosingBitsOk: true,
|
|
1166
|
+
declaredIds: new Set(),
|
|
1167
|
+
declaredExprIds: new Set(),
|
|
1168
|
+
openIds: new Set(),
|
|
1169
|
+
repeatIds,
|
|
1170
|
+
documentDeclaredIds,
|
|
1171
|
+
importPrefixes,
|
|
1172
|
+
};
|
|
1173
|
+
const bodyW = { errors, defs, inDef: false, pc };
|
|
1174
|
+
for (const c of packet.body ?? []) {
|
|
1175
|
+
validateContainerCtx(c, packet.name, bodyW);
|
|
1176
|
+
}
|
|
1177
|
+
// Defs bodies (virtual forbidden inside)
|
|
1178
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
1179
|
+
const w = { errors, defs, inDef: true };
|
|
1180
|
+
// §5.4/§6: defs structs may carry meta; validate its shape like every
|
|
1181
|
+
// other meta-bearing level (mirrors the schema's NamedStruct).
|
|
1182
|
+
validateMeta(def.meta, `defs/${name}`, errors);
|
|
1183
|
+
if (!Array.isArray(def.fields)) {
|
|
1184
|
+
errors.push({ message: `defs/${name}: struct must have a fields array.` });
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
for (const child of def.fields)
|
|
1188
|
+
validateContainerCtx(child, `defs/${name}`, w);
|
|
1189
|
+
}
|
|
1190
|
+
// Constraints
|
|
1191
|
+
for (const [i, con] of (packet.constraints ?? []).entries()) {
|
|
1192
|
+
// Mirrors the schema's Constraint `additionalProperties: false` so a
|
|
1193
|
+
// typo'd key (e.g. "leval" for "level") cannot silently demote a should
|
|
1194
|
+
// constraint to the default `must` (§9.1, §16.4).
|
|
1195
|
+
for (const key of Object.keys(con)) {
|
|
1196
|
+
if (!CONSTRAINT_KEYS.has(key))
|
|
1197
|
+
errors.push({ message: `constraints[${i}] has unknown key "${key}" (allowed: ${[...CONSTRAINT_KEYS].join(", ")}) (§9).` });
|
|
1198
|
+
}
|
|
1199
|
+
// §D11: constraints are exempt from forward-order (§10.1), but ref/wireSize
|
|
1200
|
+
// existence is still checked. Pass a pc that carries only the document id
|
|
1201
|
+
// set (the forward-order branches are skipped for slot==="constraint").
|
|
1202
|
+
const conPc = {
|
|
1203
|
+
remainingOk: true, enclosingBitsOk: true,
|
|
1204
|
+
declaredIds: new Set(), openIds: new Set(), repeatIds,
|
|
1205
|
+
documentDeclaredIds, importPrefixes,
|
|
1206
|
+
};
|
|
1207
|
+
if (!isValidExpr(con.lhs))
|
|
1208
|
+
errors.push({ message: `constraints[${i}]: malformed lhs.` });
|
|
1209
|
+
else
|
|
1210
|
+
validateExprPlacement(con.lhs, "constraint", `constraints[${i}]`, errors, conPc);
|
|
1211
|
+
if (!isValidExpr(con.rhs))
|
|
1212
|
+
errors.push({ message: `constraints[${i}]: malformed rhs.` });
|
|
1213
|
+
else
|
|
1214
|
+
validateExprPlacement(con.rhs, "constraint", `constraints[${i}]`, errors, conPc);
|
|
1215
|
+
if (con.doc !== undefined && typeof con.doc !== "string")
|
|
1216
|
+
errors.push({ message: `constraints[${i}]: doc must be a string (§9).` });
|
|
1217
|
+
if (con.level !== undefined && !NORM_LEVELS.has(con.level))
|
|
1218
|
+
errors.push({ message: `constraints[${i}]: invalid level "${String(con.level)}" (must be must|should|may).` });
|
|
1219
|
+
}
|
|
1220
|
+
return errors;
|
|
1221
|
+
}
|
|
1222
|
+
//# sourceMappingURL=validate.js.map
|