@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
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
// normalize.ts — walks the Container tree into a flat NormalizedField list.
|
|
2
|
+
//
|
|
3
|
+
// Implements the §10 processing model: seed phase (§10.2) then a forward,
|
|
4
|
+
// single-pass parse that injects context-dependent expression values
|
|
5
|
+
// (remaining/enclosingBits/wireSize/prevIter) into the env as it goes.
|
|
6
|
+
import { evalExprOr, exprContains, enclosingBitsEnvKey, prevIterEnvKey, remainingEnvKey, wireSizeEnvKey } from "./expr.js";
|
|
7
|
+
import { isField } from "./utils.js";
|
|
8
|
+
export function berLenEnvKey(fieldId) {
|
|
9
|
+
return `__berLen__${fieldId}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Decoder-injected wire byte length of a delimiter-terminated `bytes` field
|
|
13
|
+
* (§3/§10.7, D3). Keyed by the field's fully-qualified id under a dedicated
|
|
14
|
+
* namespace so it never collides with env[id] (the field's value slot) or any
|
|
15
|
+
* other injection key. With no injection (static preview) the length is unknown
|
|
16
|
+
* and the field lays out as 0 bytes.
|
|
17
|
+
*/
|
|
18
|
+
export function bytesDelimLenEnvKey(qid) {
|
|
19
|
+
return `__bytesDelimLen__${qid}`;
|
|
20
|
+
}
|
|
21
|
+
/** True if a `bytes.n` is the delimiter form rather than an Expr (§3, D3). */
|
|
22
|
+
export function isBytesDelimited(n) {
|
|
23
|
+
return typeof n === "object" && n !== null && !Array.isArray(n) && "delimiter" in n &&
|
|
24
|
+
Array.isArray(n.delimiter);
|
|
25
|
+
}
|
|
26
|
+
/** Internal bit-accumulator mirror of wireSizeEnvKey (§4 sub-byte rounding). */
|
|
27
|
+
function wireSizeBitsEnvKey(target) {
|
|
28
|
+
return `__wireSizeBits__${target}`;
|
|
29
|
+
}
|
|
30
|
+
/** Decoder-injected wire bit-width of a varint field (distinct from its value). */
|
|
31
|
+
export function varintBitsEnvKey(fieldId) {
|
|
32
|
+
return `__varintBits__${fieldId}`;
|
|
33
|
+
}
|
|
34
|
+
/** Maximum recursive-def expansion depth (decoder is the real authority, §6). */
|
|
35
|
+
const MAX_REF_DEPTH = 64;
|
|
36
|
+
/**
|
|
37
|
+
* Static wire bit-width of a wire type. For decoder-determined widths
|
|
38
|
+
* (`varint`, delimiter-terminated `bytes`), the width is looked up in `env`
|
|
39
|
+
* under a key derived from `fieldId`. The CALLER must pass the same id used at
|
|
40
|
+
* injection: for fields expanded inside a `ref`/`repeat` that is the qualified
|
|
41
|
+
* id (`{ref.id}.{field.id}#N`), which the normalize walk threads through emit().
|
|
42
|
+
* Calling this helper directly with a bare id for a qualified field yields 0
|
|
43
|
+
* (unknown width) — resolve such fields via the full `normalize()` walk.
|
|
44
|
+
*/
|
|
45
|
+
export function typeBits(type, env, fieldId) {
|
|
46
|
+
switch (type.kind) {
|
|
47
|
+
case "int":
|
|
48
|
+
case "enum":
|
|
49
|
+
return type.bits;
|
|
50
|
+
case "bits":
|
|
51
|
+
return type.n;
|
|
52
|
+
case "bytes":
|
|
53
|
+
// Delimiter-terminated bytes have a decoder-determined length
|
|
54
|
+
// injected under a qualified key; typeBits has only the bare id, so the
|
|
55
|
+
// qualified lookup happens in emit(). Without the qid the static layout is
|
|
56
|
+
// 0 bytes (unknown length, §3/§10.7, D3).
|
|
57
|
+
if (isBytesDelimited(type.n)) {
|
|
58
|
+
if (fieldId !== undefined) {
|
|
59
|
+
const v = env.get(bytesDelimLenEnvKey(fieldId));
|
|
60
|
+
if (v !== undefined)
|
|
61
|
+
return Math.max(0, Math.trunc(v)) * 8;
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
return Math.max(0, Math.trunc(evalExprOr(type.n, env))) * 8;
|
|
66
|
+
case "varint":
|
|
67
|
+
// §3/§10: the decoder injects the varint's wire bit-width under a key
|
|
68
|
+
// distinct from the field's value slot (env[fieldId] holds the decoded
|
|
69
|
+
// value, not its width). With no injection the static layout yields 0.
|
|
70
|
+
if (fieldId !== undefined) {
|
|
71
|
+
const v = env.get(varintBitsEnvKey(fieldId));
|
|
72
|
+
if (v !== undefined)
|
|
73
|
+
return v;
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
case "berLength":
|
|
77
|
+
if (fieldId !== undefined) {
|
|
78
|
+
const v = env.get(berLenEnvKey(fieldId));
|
|
79
|
+
if (v !== undefined)
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
return 8;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/* ------------------------------------------------------------------ *
|
|
86
|
+
* Seeding (§10.2): const wins over defaultValue; recursive.
|
|
87
|
+
* ------------------------------------------------------------------ */
|
|
88
|
+
function seedDefaults(containers, env, defs, depth = 0, activeRecursive = new Set()) {
|
|
89
|
+
if (depth > MAX_REF_DEPTH)
|
|
90
|
+
return;
|
|
91
|
+
for (const c of containers) {
|
|
92
|
+
if (isField(c)) {
|
|
93
|
+
const seed = c.const ?? c.defaultValue;
|
|
94
|
+
if (seed !== undefined && !env.has(c.id))
|
|
95
|
+
env.set(c.id, seed);
|
|
96
|
+
}
|
|
97
|
+
else if (c.kind === "group") {
|
|
98
|
+
seedDefaults(c.children, env, defs, depth, activeRecursive);
|
|
99
|
+
}
|
|
100
|
+
else if (c.kind === "bounded") {
|
|
101
|
+
seedDefaults(c.fields, env, defs, depth, activeRecursive);
|
|
102
|
+
}
|
|
103
|
+
else if (c.kind === "encrypted") {
|
|
104
|
+
seedDefaults(c.plaintext.fields, env, defs, depth, activeRecursive);
|
|
105
|
+
}
|
|
106
|
+
else if (c.kind === "optional") {
|
|
107
|
+
seedDefaults([c.container], env, defs, depth, activeRecursive);
|
|
108
|
+
}
|
|
109
|
+
else if (c.kind === "repeat") {
|
|
110
|
+
seedDefaults(c.element.fields, env, defs, depth, activeRecursive);
|
|
111
|
+
}
|
|
112
|
+
else if (c.kind === "switch") {
|
|
113
|
+
for (const arm of Object.values(c.cases))
|
|
114
|
+
seedDefaults(arm.fields, env, defs, depth, activeRecursive);
|
|
115
|
+
}
|
|
116
|
+
else if (c.kind === "ref") {
|
|
117
|
+
const def = defs[c.ref];
|
|
118
|
+
if (!def)
|
|
119
|
+
continue;
|
|
120
|
+
// §10.2 rule 3: a recursive `ref` (a ref re-entering a def already being
|
|
121
|
+
// seeded) is an unresolved boundary — seed only the directly-declared
|
|
122
|
+
// fields of the def body, not recursively-expanded instances.
|
|
123
|
+
if (activeRecursive.has(c.ref))
|
|
124
|
+
continue;
|
|
125
|
+
const nextActive = def.recursive ? new Set([...activeRecursive, c.ref]) : activeRecursive;
|
|
126
|
+
seedDefaults(def.fields, env, defs, depth + 1, nextActive);
|
|
127
|
+
}
|
|
128
|
+
// virtual / align seed nothing
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Inject remaining/enclosingBits for the innermost budgeted scope (§4). */
|
|
132
|
+
function injectScopeBudget(state) {
|
|
133
|
+
let frame;
|
|
134
|
+
for (let i = state.scopeStack.length - 1; i >= 0; i--) {
|
|
135
|
+
const f = state.scopeStack[i];
|
|
136
|
+
if (f.budgetBits !== undefined) {
|
|
137
|
+
frame = f;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (frame === undefined) {
|
|
142
|
+
state.env.delete(remainingEnvKey());
|
|
143
|
+
state.env.delete(enclosingBitsEnvKey());
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
// §4 Sub-byte rounding: `remaining` counts whole bytes left in the scope.
|
|
147
|
+
// Compute it from the raw bit gap so it stays consistent when budgetBits is
|
|
148
|
+
// not a whole number of bytes (e.g. an encrypted scope with a sub-byte
|
|
149
|
+
// wireBits): `floor((budget - consumed) / 8)`. This avoids the prior
|
|
150
|
+
// floor(budget/8) - ceil(consumed/8) form, which double-penalised a partial
|
|
151
|
+
// trailing budget byte against a mid-byte cursor and under-reported by one.
|
|
152
|
+
const consumedBits = state.offset - frame.startOffset;
|
|
153
|
+
const remainingBytes = Math.max(0, Math.floor((frame.budgetBits - consumedBits) / 8));
|
|
154
|
+
state.env.set(remainingEnvKey(), remainingBytes);
|
|
155
|
+
state.env.set(enclosingBitsEnvKey(), frame.budgetBits);
|
|
156
|
+
return frame;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* §4/§11.2: `remaining`/`enclosingBits` used in a top-level `body` expression
|
|
160
|
+
* when the decoder has not injected the total packet size is a runtime error.
|
|
161
|
+
* The validator already rejects these primitives inside an
|
|
162
|
+
* `encrypted.plaintext` without `wireBits`, and a `bounded` always carries a
|
|
163
|
+
* budget, so the only way to reach an expression referencing them with no
|
|
164
|
+
* budgeted scope frame at runtime is the top-level body with no `totalBits`
|
|
165
|
+
* injected. Distinguish that from "no scope provider at all" and raise, rather
|
|
166
|
+
* than silently resolving to 0.
|
|
167
|
+
*/
|
|
168
|
+
function guardScopeBudget(state, frame, expr) {
|
|
169
|
+
if (frame !== undefined)
|
|
170
|
+
return;
|
|
171
|
+
if (!exprContains(expr, (e) => e.kind === "remaining" || e.kind === "enclosingBits"))
|
|
172
|
+
return;
|
|
173
|
+
throw new Error("normalize: 'remaining'/'enclosingBits' used at the top-level body but the decoder did not inject the total packet size (pass totalBits) (§4/§11.2).");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Evaluate an expression with scope budget freshly injected (§4, §10.3).
|
|
177
|
+
*
|
|
178
|
+
* `peek` (§10.6) is NOT injected here: normalize is a static layout pass with
|
|
179
|
+
* no underlying wire buffer, so it cannot read the stream. A `switch.on`,
|
|
180
|
+
* `optional.when`, or `repeat.count` driven by `peek` therefore requires the
|
|
181
|
+
* decoder to pre-seed the corresponding `peekEnvKey(offset, bits)` entry in the
|
|
182
|
+
* env before calling normalize; with no such injection `peek` evaluates to 0
|
|
183
|
+
* (§11.3 "peek reads past available data → 0"), selecting the disc=0 path. This
|
|
184
|
+
* is the documented decoder hand-off for the in-walk evaluation paths.
|
|
185
|
+
*
|
|
186
|
+
* The §10.6 scope-clamp and context-relative current-position semantics (a
|
|
187
|
+
* peek that would read past the innermost scope-providing container's remaining
|
|
188
|
+
* budget yields 0 even if bytes exist beyond the boundary; the current position
|
|
189
|
+
* is the first bit of the switch/optional, the first element for fixed
|
|
190
|
+
* `repeat.count`, or the first bit after the last byte of the just-completed
|
|
191
|
+
* iteration for `repeat.count.until`) CANNOT be enforced here — this package
|
|
192
|
+
* never sees the underlying buffer. The decoder MUST apply that clamp and
|
|
193
|
+
* context-relative origin when seeding each `peekEnvKey(offset, bits)`; the
|
|
194
|
+
* library cannot verify it.
|
|
195
|
+
*/
|
|
196
|
+
function evalIn(state, expr) {
|
|
197
|
+
const frame = injectScopeBudget(state);
|
|
198
|
+
// §4/§11.2: top-level `remaining`/`enclosingBits` with no injected total size
|
|
199
|
+
// is a runtime error (not a silent 0).
|
|
200
|
+
guardScopeBudget(state, frame, expr);
|
|
201
|
+
// §11.2 scopes the mid-byte error specifically to sizing a `bytes` field; that
|
|
202
|
+
// guard lives in `emit`. `remaining` in a non-sizing slot (optional.when,
|
|
203
|
+
// switch.on, repeat.count, bounded.bytes) is spec-legal even mid-byte, so no
|
|
204
|
+
// blanket mid-byte throw here.
|
|
205
|
+
return evalExprOr(expr, state.env);
|
|
206
|
+
}
|
|
207
|
+
function repeatSuffix(state) {
|
|
208
|
+
return state.repeatIndexStack.length > 0 ? `#${state.repeatIndexStack.join("_")}` : "";
|
|
209
|
+
}
|
|
210
|
+
/** Fully-qualified id for a container/field (prefix + id + repeat suffix). */
|
|
211
|
+
function qualify(state, id) {
|
|
212
|
+
const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
|
|
213
|
+
return `${prefix}${id}${repeatSuffix(state)}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Record a container/field wire footprint for `wireSize` (§4). Stored under
|
|
217
|
+
* the fully-qualified id (so ref/group/repeat-iteration entries never collide),
|
|
218
|
+
* and additionally accumulated under the unqualified id while inside a repeat
|
|
219
|
+
* so `wireSize(target)` yields the aggregate across iterations.
|
|
220
|
+
*
|
|
221
|
+
* Footprints are tracked in BITS and only floored to a byte count at the point
|
|
222
|
+
* the byte-valued wireSize key is written, so adjacent sub-byte fields (e.g.
|
|
223
|
+
* two nibbles) are not each truncated to 0 bytes (§4 sub-byte rounding).
|
|
224
|
+
*
|
|
225
|
+
* NOTE on duplicate bare ids (§10.1): outside a repeat the unqualified key is
|
|
226
|
+
* overwritten on every occurrence, so when the same id appears more than once
|
|
227
|
+
* in document order (two sibling `ref`s to one def, or a field id reused across
|
|
228
|
+
* switch arms), `wireSize(id)` reflects the LAST occurrence walked before the
|
|
229
|
+
* consuming expression. The validator only approves a `wireSize(target)` whose
|
|
230
|
+
* `target` is already declared/closed and precedes the expression in document
|
|
231
|
+
* order (validate.ts §11.1), so a forward expression resolves the most recent
|
|
232
|
+
* preceding instance. Authors who need a specific instance must give that
|
|
233
|
+
* instance a unique id; a bare id shared by multiple instances resolves to the
|
|
234
|
+
* nearest preceding one, not a sum. (Inside a repeat the bare id deliberately
|
|
235
|
+
* accumulates the per-iteration aggregate, see below.)
|
|
236
|
+
*/
|
|
237
|
+
function recordWireSize(state, id, bits) {
|
|
238
|
+
const qid = qualify(state, id);
|
|
239
|
+
state.env.set(wireSizeBitsEnvKey(qid), bits);
|
|
240
|
+
state.env.set(wireSizeEnvKey(qid), Math.floor(bits / 8));
|
|
241
|
+
if (state.repeatIndexStack.length > 0) {
|
|
242
|
+
const prevBits = state.env.get(wireSizeBitsEnvKey(id)) ?? 0;
|
|
243
|
+
const totalBits = prevBits + bits;
|
|
244
|
+
state.env.set(wireSizeBitsEnvKey(id), totalBits);
|
|
245
|
+
state.env.set(wireSizeEnvKey(id), Math.floor(totalBits / 8));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
state.env.set(wireSizeBitsEnvKey(id), bits);
|
|
249
|
+
state.env.set(wireSizeEnvKey(id), Math.floor(bits / 8));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Apply walk-context attribution shared by every emitted NormalizedField:
|
|
254
|
+
* switch-arm key, repeat index, and enclosing-group identity/provenance (§5,
|
|
255
|
+
* §5.4). Used by emit() and by the wire-view encrypted blob so both paths
|
|
256
|
+
* agree on attribution.
|
|
257
|
+
*/
|
|
258
|
+
function applyWalkContext(state, nf) {
|
|
259
|
+
// §5: every field within a selected switch arm carries the arm key, whether
|
|
260
|
+
// a direct child or nested inside a group/optional/bounded/repeat/ref.
|
|
261
|
+
if (state.switchCase !== undefined)
|
|
262
|
+
nf.switchCase = state.switchCase;
|
|
263
|
+
if (state.repeatIndexStack.length > 0)
|
|
264
|
+
nf.repeatIndex = state.repeatIndexStack[state.repeatIndexStack.length - 1];
|
|
265
|
+
if (state.groupStack.length > 0) {
|
|
266
|
+
const top = state.groupStack[state.groupStack.length - 1];
|
|
267
|
+
const indexTag = state.repeatIndexStack.length > 0 ? state.repeatIndexStack.join("_") : null;
|
|
268
|
+
nf.groupId = indexTag !== null ? `${top.id}#${indexTag}` : top.id;
|
|
269
|
+
nf.groupName = top.name;
|
|
270
|
+
// §5.4: groupMeta is the meta of the NEAREST enclosing group that defines
|
|
271
|
+
// one (innermost wins; an outer group's meta is the fallback), so
|
|
272
|
+
// nested-group provenance still reaches every leaf when only the outer
|
|
273
|
+
// group carries meta.
|
|
274
|
+
for (let i = state.groupStack.length - 1; i >= 0; i--) {
|
|
275
|
+
const frame = state.groupStack[i];
|
|
276
|
+
if (frame.meta !== undefined) {
|
|
277
|
+
nf.groupMeta = frame.meta;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function emit(state, field, path) {
|
|
284
|
+
const frame = injectScopeBudget(state);
|
|
285
|
+
// §4/§11.2: sizing a `bytes` field from `remaining` while the cursor is
|
|
286
|
+
// mid-byte is a runtime error.
|
|
287
|
+
if (field.type.kind === "bytes" && !isBytesDelimited(field.type.n)) {
|
|
288
|
+
const nExpr = field.type.n;
|
|
289
|
+
// §4/§11.2: top-level `remaining`/`enclosingBits` with no injected total.
|
|
290
|
+
guardScopeBudget(state, frame, nExpr);
|
|
291
|
+
if (state.offset % 8 !== 0 && exprContains(nExpr, (e) => e.kind === "remaining"))
|
|
292
|
+
throw new Error(`normalize: 'remaining' sizes bytes field "${field.id}" while the cursor is mid-byte (offset ${state.offset} bits); insert an 'align' first (§11.2).`);
|
|
293
|
+
}
|
|
294
|
+
const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
|
|
295
|
+
const suffix = repeatSuffix(state);
|
|
296
|
+
const id = `${prefix}${field.id}${suffix}`;
|
|
297
|
+
// §3/§10.7 (D3): a delimiter-terminated `bytes` length is injected under the
|
|
298
|
+
// qualified id; without injection the static layout is 0 bytes (unknown).
|
|
299
|
+
const bits = (field.type.kind === "bytes" && isBytesDelimited(field.type.n))
|
|
300
|
+
? Math.max(0, Math.trunc(state.env.get(bytesDelimLenEnvKey(id)) ?? 0)) * 8
|
|
301
|
+
: typeBits(field.type, state.env, field.id);
|
|
302
|
+
const nf = {
|
|
303
|
+
id,
|
|
304
|
+
name: field.name,
|
|
305
|
+
bits,
|
|
306
|
+
absoluteBitOffset: state.offset,
|
|
307
|
+
originalContainerPath: path,
|
|
308
|
+
...(field.category !== undefined ? { category: field.category } : {}),
|
|
309
|
+
...(field.doc !== undefined ? { doc: field.doc } : {}),
|
|
310
|
+
// §5.3/§5.4: value dictionary and RFC provenance ride through to the
|
|
311
|
+
// normalized/layout output so LSP and renderers can surface them.
|
|
312
|
+
...(field.values !== undefined ? { values: field.values } : {}),
|
|
313
|
+
...(field.meta !== undefined ? { meta: field.meta } : {}),
|
|
314
|
+
// §12 (D4): mask-addressed subfields ride through for LSP/codegen value decode.
|
|
315
|
+
...(field.subfields !== undefined ? { subfields: field.subfields } : {}),
|
|
316
|
+
// §8: checksum binding rides through so codegen/LSP can read the algorithm,
|
|
317
|
+
// covered fields, pseudo-header, and CRC parameters (width included).
|
|
318
|
+
...(field.checksumAlgorithm !== undefined ? { checksumAlgorithm: field.checksumAlgorithm } : {}),
|
|
319
|
+
...(field.checksumCovers !== undefined ? { checksumCovers: field.checksumCovers } : {}),
|
|
320
|
+
...(field.checksumPseudoHeader !== undefined ? { checksumPseudoHeader: field.checksumPseudoHeader } : {}),
|
|
321
|
+
...(field.checksumParams !== undefined ? { checksumParams: field.checksumParams } : {}),
|
|
322
|
+
};
|
|
323
|
+
applyWalkContext(state, nf);
|
|
324
|
+
if (state.encryptedStack.length > 0) {
|
|
325
|
+
const top = state.encryptedStack[state.encryptedStack.length - 1];
|
|
326
|
+
nf.encryptedParentId = top.parentId;
|
|
327
|
+
nf.encryptedContextNote = top.contextNote;
|
|
328
|
+
for (const frame of state.encryptedStack) {
|
|
329
|
+
if (frame.headerProtected.has(field.id)) {
|
|
330
|
+
nf.headerProtected = true;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (field.byteOrder)
|
|
336
|
+
nf.byteOrder = field.byteOrder;
|
|
337
|
+
state.out.push(nf);
|
|
338
|
+
state.env.set(id, state.env.get(id) ?? state.env.get(field.id) ?? field.const ?? field.defaultValue ?? 0);
|
|
339
|
+
state.offset += bits;
|
|
340
|
+
// Record wire footprint for wireSize (parse-direction; §4). Tracked in bits.
|
|
341
|
+
recordWireSize(state, field.id, bits);
|
|
342
|
+
}
|
|
343
|
+
function walkContainer(c, path, state) {
|
|
344
|
+
if (isField(c)) {
|
|
345
|
+
emit(state, c, path);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
switch (c.kind) {
|
|
349
|
+
case "group":
|
|
350
|
+
walkGroup(c, path, state);
|
|
351
|
+
return;
|
|
352
|
+
case "repeat":
|
|
353
|
+
walkRepeat(c, path, state);
|
|
354
|
+
return;
|
|
355
|
+
case "switch":
|
|
356
|
+
walkSwitch(c, path, state);
|
|
357
|
+
return;
|
|
358
|
+
case "encrypted":
|
|
359
|
+
walkEncrypted(c, path, state);
|
|
360
|
+
return;
|
|
361
|
+
case "bounded":
|
|
362
|
+
walkBounded(c, path, state);
|
|
363
|
+
return;
|
|
364
|
+
case "align":
|
|
365
|
+
walkAlign(c, state);
|
|
366
|
+
return;
|
|
367
|
+
case "virtual":
|
|
368
|
+
walkVirtual(c, path, state);
|
|
369
|
+
return;
|
|
370
|
+
case "optional": {
|
|
371
|
+
const test = evalIn(state, c.when);
|
|
372
|
+
if (test !== 0)
|
|
373
|
+
walkContainer(c.container, path, state);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
case "ref":
|
|
377
|
+
walkRef(c, path, state);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function walkVirtual(v, path, state) {
|
|
382
|
+
const value = evalIn(state, v.expr);
|
|
383
|
+
// Expressions reference the bare id; also record the qualified id so virtuals
|
|
384
|
+
// inside a ref expansion / repeat iteration stay distinguishable.
|
|
385
|
+
state.env.set(v.id, value);
|
|
386
|
+
const qid = qualify(state, v.id);
|
|
387
|
+
if (qid !== v.id)
|
|
388
|
+
state.env.set(qid, value);
|
|
389
|
+
// Zero-width; recorded as a virtual normalized field for tooling. The
|
|
390
|
+
// authored name/doc ride through for LSP hover (§5). The walk path is
|
|
391
|
+
// recorded like every other emitted field so (a) an LSP can trace the
|
|
392
|
+
// virtual to its source container, and (b) a virtual inside a group does
|
|
393
|
+
// not split the group's consecutive run in the layout collapse (§5.4).
|
|
394
|
+
const nf = {
|
|
395
|
+
id: qid,
|
|
396
|
+
name: v.name ?? v.id,
|
|
397
|
+
bits: 0,
|
|
398
|
+
absoluteBitOffset: state.offset,
|
|
399
|
+
originalContainerPath: path,
|
|
400
|
+
virtual: true,
|
|
401
|
+
...(v.doc !== undefined ? { doc: v.doc } : {}),
|
|
402
|
+
};
|
|
403
|
+
applyWalkContext(state, nf);
|
|
404
|
+
state.out.push(nf);
|
|
405
|
+
}
|
|
406
|
+
function walkAlign(a, state) {
|
|
407
|
+
// Round up to next whole byte first, then to the `to` boundary (§5).
|
|
408
|
+
const byteAligned = Math.ceil(state.offset / 8) * 8;
|
|
409
|
+
let target = byteAligned;
|
|
410
|
+
if (a.to > 0) {
|
|
411
|
+
const rem = byteAligned % a.to;
|
|
412
|
+
if (rem !== 0)
|
|
413
|
+
target = byteAligned + (a.to - rem);
|
|
414
|
+
}
|
|
415
|
+
let padBits = target - state.offset;
|
|
416
|
+
// §5: inside a `bounded` scope, padding exceeding the authored byte budget
|
|
417
|
+
// is a runtime error; at the top-level/decoder-injected end it caps to what
|
|
418
|
+
// remains (SCTP last chunk). NOTE: top-level align capping requires the
|
|
419
|
+
// decoder to inject `totalBits` (which pushes a budgeted top frame). Without
|
|
420
|
+
// an injected total there is no defined end-of-data, so the loop finds no
|
|
421
|
+
// budgeted frame and the align advances unbounded — consistent with
|
|
422
|
+
// `remaining` being undefined at the top level without injection (§4).
|
|
423
|
+
for (let i = state.scopeStack.length - 1; i >= 0; i--) {
|
|
424
|
+
const f = state.scopeStack[i];
|
|
425
|
+
if (f.budgetBits !== undefined) {
|
|
426
|
+
const avail = f.budgetBits - (state.offset - f.startOffset);
|
|
427
|
+
if (padBits > avail) {
|
|
428
|
+
if (f.kind === "bounded")
|
|
429
|
+
throw new Error(`normalize: align to ${a.to} requires ${padBits} padding bits but only ${Math.max(0, avail)} remain in the bounded scope (§5/§11.2).`);
|
|
430
|
+
padBits = Math.max(0, avail);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
state.offset += padBits;
|
|
436
|
+
}
|
|
437
|
+
function walkRef(r, path, state) {
|
|
438
|
+
if (state.refDepth >= MAX_REF_DEPTH)
|
|
439
|
+
return;
|
|
440
|
+
const def = state.defs[r.ref];
|
|
441
|
+
if (!def)
|
|
442
|
+
return;
|
|
443
|
+
const sub = `${path}/${r.id}`;
|
|
444
|
+
const prevPrefix = state.idPrefix;
|
|
445
|
+
state.idPrefix = state.idPrefix ? `${state.idPrefix}.${r.id}` : r.id;
|
|
446
|
+
state.refDepth++;
|
|
447
|
+
const startOffset = state.offset;
|
|
448
|
+
for (const child of def.fields)
|
|
449
|
+
walkContainer(child, sub, state);
|
|
450
|
+
// wireSize is keyed by the qualified id from the parent scope, so restore
|
|
451
|
+
// the prefix before recording (the ref id lives in the enclosing scope).
|
|
452
|
+
state.idPrefix = prevPrefix;
|
|
453
|
+
recordWireSize(state, r.id, state.offset - startOffset);
|
|
454
|
+
state.refDepth--;
|
|
455
|
+
}
|
|
456
|
+
function walkGroup(g, path, state) {
|
|
457
|
+
const sub = `${path}/${g.id}`;
|
|
458
|
+
const prev = state.groupStack;
|
|
459
|
+
state.groupStack = [...prev, { id: g.id, name: g.name ?? g.id, ...(g.meta !== undefined ? { meta: g.meta } : {}) }];
|
|
460
|
+
const startOffset = state.offset;
|
|
461
|
+
for (const child of g.children)
|
|
462
|
+
walkContainer(child, sub, state);
|
|
463
|
+
recordWireSize(state, g.id, state.offset - startOffset);
|
|
464
|
+
state.groupStack = prev;
|
|
465
|
+
}
|
|
466
|
+
function walkBounded(b, path, state) {
|
|
467
|
+
const sub = `${path}/${b.id}`;
|
|
468
|
+
const budgetBits = Math.max(0, Math.trunc(evalIn(state, b.bytes))) * 8;
|
|
469
|
+
const startOffset = state.offset;
|
|
470
|
+
state.scopeStack.push({ startOffset, budgetBits, kind: "bounded" });
|
|
471
|
+
for (const child of b.fields)
|
|
472
|
+
walkContainer(child, sub, state);
|
|
473
|
+
state.scopeStack.pop();
|
|
474
|
+
// §5: over-consuming an authored `bounded` budget is a runtime error (mirrors
|
|
475
|
+
// the over-budget `align` rule); under-consumption advances to the scope end.
|
|
476
|
+
const consumed = state.offset - startOffset;
|
|
477
|
+
if (consumed > budgetBits)
|
|
478
|
+
throw new Error(`normalize: bounded scope "${b.id}" over-consumed: children used ${consumed} bits but the budget is ${budgetBits} bits (§5/§11.2).`);
|
|
479
|
+
if (consumed < budgetBits)
|
|
480
|
+
state.offset = startOffset + budgetBits;
|
|
481
|
+
recordWireSize(state, b.id, state.offset - startOffset);
|
|
482
|
+
}
|
|
483
|
+
function walkRepeat(r, path, state) {
|
|
484
|
+
const sub = `${path}/${r.id}`;
|
|
485
|
+
const count = resolveRepeatCount(r, state);
|
|
486
|
+
const startOffset = state.offset;
|
|
487
|
+
const prevStack = state.repeatIndexStack;
|
|
488
|
+
const prefix = state.idPrefix ? `${state.idPrefix}.` : "";
|
|
489
|
+
// Element fields whose value participates in prevIter injection (§10.4).
|
|
490
|
+
// Collect ALL leaf field ids reachable in the element — including fields
|
|
491
|
+
// nested inside a group/switch/ref/optional — not just the direct children,
|
|
492
|
+
// so a `repeat.until`/`prevIter` referencing a grouped field still resolves
|
|
493
|
+
// (§10.4). Each descriptor carries the ref-prefix path it is emitted under
|
|
494
|
+
// (groups/switch/optional/bounded do not extend the id prefix; only `ref`
|
|
495
|
+
// does), so the injection probe matches the actually-emitted id.
|
|
496
|
+
const elementFields = collectElementFields(r.element.fields, state.defs);
|
|
497
|
+
// Snapshot prevIter keys so the repeat does not leak stale previous-iteration
|
|
498
|
+
// values into sibling/enclosing containers (§4, §10.4).
|
|
499
|
+
const savedPrevIter = new Map();
|
|
500
|
+
for (const child of elementFields)
|
|
501
|
+
savedPrevIter.set(prevIterEnvKey(child.id), state.env.get(prevIterEnvKey(child.id)));
|
|
502
|
+
// Reset per-target wireSize accumulators so a fresh aggregate is built (§4).
|
|
503
|
+
// Clear ALL container ids reachable in the element — not just the direct
|
|
504
|
+
// fields — so a nested group/switch/ref inside the element does not leak its
|
|
505
|
+
// aggregate from a prior sibling repeat that shares the same id.
|
|
506
|
+
const aggregateIds = collectAggregateIds(r.element.fields);
|
|
507
|
+
for (const aid of aggregateIds) {
|
|
508
|
+
state.env.delete(wireSizeEnvKey(aid));
|
|
509
|
+
state.env.delete(wireSizeBitsEnvKey(aid));
|
|
510
|
+
}
|
|
511
|
+
// §4/§10.2: first iteration sees each field's seeded value (const ?? default).
|
|
512
|
+
for (const child of elementFields) {
|
|
513
|
+
const seed = child.const ?? child.defaultValue;
|
|
514
|
+
if (seed !== undefined)
|
|
515
|
+
state.env.set(prevIterEnvKey(child.id), seed);
|
|
516
|
+
else
|
|
517
|
+
state.env.delete(prevIterEnvKey(child.id));
|
|
518
|
+
}
|
|
519
|
+
for (let i = 0; i < count; i++) {
|
|
520
|
+
state.repeatIndexStack = [...prevStack, i];
|
|
521
|
+
const innerPath = `${sub}[${i}]`;
|
|
522
|
+
// Inject prevIter values from the just-completed iteration (§10.4). The
|
|
523
|
+
// emitted id is fully qualified (prefix + id + repeat suffix), so probe the
|
|
524
|
+
// same form — otherwise prevIter never resolves inside a ref expansion.
|
|
525
|
+
if (i > 0) {
|
|
526
|
+
const prevSuffix = `#${[...prevStack, i - 1].join("_")}`;
|
|
527
|
+
for (const child of elementFields) {
|
|
528
|
+
const childPrefix = child.prefixPath ? `${child.prefixPath}.` : "";
|
|
529
|
+
const prevId = `${prefix}${childPrefix}${child.id}${prevSuffix}`;
|
|
530
|
+
const v = state.env.get(prevId);
|
|
531
|
+
if (v !== undefined)
|
|
532
|
+
state.env.set(prevIterEnvKey(child.id), v);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const child of r.element.fields)
|
|
536
|
+
walkContainer(child, innerPath, state);
|
|
537
|
+
}
|
|
538
|
+
state.repeatIndexStack = prevStack;
|
|
539
|
+
// Restore prevIter keys so later expressions do not read leftover values.
|
|
540
|
+
for (const [key, value] of savedPrevIter) {
|
|
541
|
+
if (value === undefined)
|
|
542
|
+
state.env.delete(key);
|
|
543
|
+
else
|
|
544
|
+
state.env.set(key, value);
|
|
545
|
+
}
|
|
546
|
+
// §10.7: populate env[repeat.id] with the completed iteration count so a
|
|
547
|
+
// `ref` to the repeat id resolves uniformly for fixed/until/eos forms.
|
|
548
|
+
state.env.set(qualify(state, r.id), count);
|
|
549
|
+
state.env.set(r.id, count);
|
|
550
|
+
recordWireSize(state, r.id, state.offset - startOffset);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Collect every leaf field reachable in a repeat element — recursing into
|
|
554
|
+
* group/switch/ref/optional/bounded containers — so prevIter seeding and
|
|
555
|
+
* injection cover nested fields, not only direct children (§10.4). Only `ref`
|
|
556
|
+
* extends the emitted id prefix; other containers leave it unchanged.
|
|
557
|
+
*/
|
|
558
|
+
function collectElementFields(fields, defs, prefixPath = "", out = [], seenRefs = new Set()) {
|
|
559
|
+
for (const c of fields) {
|
|
560
|
+
if (isField(c)) {
|
|
561
|
+
out.push({ id: c.id, prefixPath, ...(c.const !== undefined ? { const: c.const } : {}), ...(c.defaultValue !== undefined ? { defaultValue: c.defaultValue } : {}) });
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
switch (c.kind) {
|
|
565
|
+
case "group":
|
|
566
|
+
collectElementFields(c.children, defs, prefixPath, out, seenRefs);
|
|
567
|
+
break;
|
|
568
|
+
case "bounded":
|
|
569
|
+
collectElementFields(c.fields, defs, prefixPath, out, seenRefs);
|
|
570
|
+
break;
|
|
571
|
+
case "optional":
|
|
572
|
+
collectElementFields([c.container], defs, prefixPath, out, seenRefs);
|
|
573
|
+
break;
|
|
574
|
+
case "repeat": /* inner repeat fields belong to that repeat's iterations */ break;
|
|
575
|
+
case "encrypted":
|
|
576
|
+
collectElementFields(c.plaintext.fields, defs, prefixPath, out, seenRefs);
|
|
577
|
+
break;
|
|
578
|
+
case "switch":
|
|
579
|
+
for (const arm of Object.values(c.cases))
|
|
580
|
+
collectElementFields(arm.fields, defs, prefixPath, out, seenRefs);
|
|
581
|
+
break;
|
|
582
|
+
case "ref": {
|
|
583
|
+
if (seenRefs.has(c.ref))
|
|
584
|
+
break; // guard against recursive defs
|
|
585
|
+
const def = defs[c.ref];
|
|
586
|
+
if (!def)
|
|
587
|
+
break;
|
|
588
|
+
const nextPrefix = prefixPath ? `${prefixPath}.${c.id}` : c.id;
|
|
589
|
+
collectElementFields(def.fields, defs, nextPrefix, out, new Set([...seenRefs, c.ref]));
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
// align/virtual contribute no prevIter source
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
function collectAggregateIds(fields, out = []) {
|
|
598
|
+
for (const c of fields) {
|
|
599
|
+
if (isField(c)) {
|
|
600
|
+
out.push(c.id);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
switch (c.kind) {
|
|
604
|
+
case "group":
|
|
605
|
+
out.push(c.id);
|
|
606
|
+
collectAggregateIds(c.children, out);
|
|
607
|
+
break;
|
|
608
|
+
case "bounded":
|
|
609
|
+
out.push(c.id);
|
|
610
|
+
collectAggregateIds(c.fields, out);
|
|
611
|
+
break;
|
|
612
|
+
case "switch":
|
|
613
|
+
out.push(c.id);
|
|
614
|
+
for (const arm of Object.values(c.cases))
|
|
615
|
+
collectAggregateIds(arm.fields, out);
|
|
616
|
+
break;
|
|
617
|
+
case "repeat":
|
|
618
|
+
out.push(c.id);
|
|
619
|
+
collectAggregateIds(c.element.fields, out);
|
|
620
|
+
break;
|
|
621
|
+
case "encrypted":
|
|
622
|
+
out.push(c.id);
|
|
623
|
+
collectAggregateIds(c.plaintext.fields, out);
|
|
624
|
+
break;
|
|
625
|
+
case "ref":
|
|
626
|
+
out.push(c.id);
|
|
627
|
+
break;
|
|
628
|
+
case "optional":
|
|
629
|
+
collectAggregateIds([c.container], out);
|
|
630
|
+
break;
|
|
631
|
+
// align/virtual record no wireSize footprint
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
function resolveRepeatCount(r, state) {
|
|
637
|
+
// §10.7: the decoder injects the completed iteration count at the repeat's
|
|
638
|
+
// id. When the repeat lives inside a ref expansion or an outer repeat, each
|
|
639
|
+
// runtime instance has a distinct qualified id (prefix + id + repeat suffix),
|
|
640
|
+
// so prefer the per-instance qualified key before falling back to the bare
|
|
641
|
+
// id — otherwise sibling/iteration instances all read iteration 0's count.
|
|
642
|
+
const injectedCount = () => {
|
|
643
|
+
const q = state.env.get(qualify(state, r.id));
|
|
644
|
+
if (q !== undefined)
|
|
645
|
+
return q;
|
|
646
|
+
return state.env.get(r.id);
|
|
647
|
+
};
|
|
648
|
+
if (r.count === "eos") {
|
|
649
|
+
// §10.7/§11.3: the decoder MUST inject the completed iteration count at the
|
|
650
|
+
// repeat id (qualified per instance); with no injection (static layout
|
|
651
|
+
// preview) the normalize phase yields zero iterations.
|
|
652
|
+
const injected = injectedCount();
|
|
653
|
+
if (injected !== undefined)
|
|
654
|
+
return Math.max(0, Math.trunc(injected));
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
if (typeof r.count === "object" && "until" in r.count) {
|
|
658
|
+
// The `until` termination depends on per-iteration field values the decoder
|
|
659
|
+
// observes while streaming; like `eos`, the count is supplied at the repeat
|
|
660
|
+
// id (qualified per instance). With no injection this yields zero iterations.
|
|
661
|
+
return Math.max(0, Math.trunc(injectedCount() ?? 0));
|
|
662
|
+
}
|
|
663
|
+
return Math.max(0, Math.trunc(evalIn(state, r.count)));
|
|
664
|
+
}
|
|
665
|
+
function walkSwitch(s, path, state) {
|
|
666
|
+
const sub = `${path}/${s.id}`;
|
|
667
|
+
const disc = Math.trunc(evalIn(state, s.on));
|
|
668
|
+
const arm = selectArm(s.cases, disc);
|
|
669
|
+
if (!arm)
|
|
670
|
+
return;
|
|
671
|
+
const startOffset = state.offset;
|
|
672
|
+
// Thread the arm key through the whole arm subtree so nested fields (inside a
|
|
673
|
+
// group/optional/bounded/repeat/ref) keep their case attribution (§5).
|
|
674
|
+
const prevSwitchCase = state.switchCase;
|
|
675
|
+
state.switchCase = arm.key;
|
|
676
|
+
try {
|
|
677
|
+
for (const child of arm.struct.fields)
|
|
678
|
+
walkContainer(child, sub, state);
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
if (prevSwitchCase === undefined)
|
|
682
|
+
delete state.switchCase;
|
|
683
|
+
else
|
|
684
|
+
state.switchCase = prevSwitchCase;
|
|
685
|
+
}
|
|
686
|
+
recordWireSize(state, s.id, state.offset - startOffset);
|
|
687
|
+
}
|
|
688
|
+
/** Match in order: exact → list → range → "_" (§5). */
|
|
689
|
+
export function selectArm(cases, disc) {
|
|
690
|
+
const exact = cases[String(disc)];
|
|
691
|
+
if (exact)
|
|
692
|
+
return { key: String(disc), struct: exact };
|
|
693
|
+
// list keys "a,b,c"
|
|
694
|
+
for (const [key, struct] of Object.entries(cases)) {
|
|
695
|
+
if (key.includes(",")) {
|
|
696
|
+
const vals = key.split(",").map((s) => s.trim());
|
|
697
|
+
if (vals.includes(String(disc)))
|
|
698
|
+
return { key, struct };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// range keys "lo-hi"
|
|
702
|
+
for (const [key, struct] of Object.entries(cases)) {
|
|
703
|
+
// Match the validator's canonical range grammar exactly (no leading
|
|
704
|
+
// zeros), so selectArm and the validator agree on which keys are ranges.
|
|
705
|
+
const m = /^(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(key);
|
|
706
|
+
if (m) {
|
|
707
|
+
const lo = Number(m[1]);
|
|
708
|
+
const hi = Number(m[2]);
|
|
709
|
+
// A reversed range (lo > hi) matches nothing and is rejected by the
|
|
710
|
+
// validator (§5); guard here so it never silently masks the `_` arm.
|
|
711
|
+
if (lo > hi)
|
|
712
|
+
throw new Error(`selectArm: invalid reversed range key "${key}" (lo > hi).`);
|
|
713
|
+
if (disc >= lo && disc <= hi)
|
|
714
|
+
return { key, struct };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (cases["_"])
|
|
718
|
+
return { key: "_", struct: cases["_"] };
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* §5 (D6): tag plaintext-external header-protected fields. A headerProtected id
|
|
723
|
+
* that names a field declared earlier in the same body (not a plaintext field)
|
|
724
|
+
* is tagged on the already-emitted NormalizedField. Matching is by exact
|
|
725
|
+
* emitted id (a top-level/direct header field emits with id === its bare id),
|
|
726
|
+
* so a ref-expanded leaf with the same tail is NOT over-matched. Plaintext-
|
|
727
|
+
* internal ids are tagged separately during the plaintext walk (emit, via the
|
|
728
|
+
* encryptedStack frame). Same set in both views for fields emitted as leaves.
|
|
729
|
+
*/
|
|
730
|
+
function tagExternalHeaderProtected(e, state) {
|
|
731
|
+
if (!e.headerProtected || e.headerProtected.length === 0)
|
|
732
|
+
return;
|
|
733
|
+
const plaintextIds = new Set();
|
|
734
|
+
for (const c of e.plaintext.fields)
|
|
735
|
+
if (isField(c))
|
|
736
|
+
plaintextIds.add(c.id);
|
|
737
|
+
for (const hp of e.headerProtected) {
|
|
738
|
+
if (plaintextIds.has(hp))
|
|
739
|
+
continue; // plaintext-internal: handled in emit()
|
|
740
|
+
for (const nf of state.out) {
|
|
741
|
+
if (nf.id === hp) {
|
|
742
|
+
nf.headerProtected = true;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function walkEncrypted(e, path, state) {
|
|
749
|
+
const sub = `${path}/${e.id}`;
|
|
750
|
+
tagExternalHeaderProtected(e, state);
|
|
751
|
+
if (state.viewMode === "wire") {
|
|
752
|
+
const bits = e.wireBits !== undefined
|
|
753
|
+
? Math.max(0, Math.trunc(evalIn(state, e.wireBits)))
|
|
754
|
+
: sumPlaintextBits(e, state);
|
|
755
|
+
const nf = {
|
|
756
|
+
// Same qualification as emit(): ref prefix + repeat suffix, so repeat
|
|
757
|
+
// iterations / sibling ref expansions never collide on the blob id (§5).
|
|
758
|
+
id: qualify(state, e.id),
|
|
759
|
+
name: e.name ?? e.id,
|
|
760
|
+
bits,
|
|
761
|
+
absoluteBitOffset: state.offset,
|
|
762
|
+
originalContainerPath: sub,
|
|
763
|
+
...(e.category !== undefined ? { category: e.category } : {}),
|
|
764
|
+
...(e.doc !== undefined ? { doc: e.doc } : {}),
|
|
765
|
+
// §5.4: the encrypted region's RFC provenance rides on the wire-view
|
|
766
|
+
// blob, same as field.meta in emit().
|
|
767
|
+
...(e.meta !== undefined ? { meta: e.meta } : {}),
|
|
768
|
+
encrypted: true,
|
|
769
|
+
...(e.contextNote !== undefined ? { encryptedContextNote: e.contextNote } : {}),
|
|
770
|
+
};
|
|
771
|
+
// Switch-arm / repeat / group attribution, identical to emit() (§5, §5.4).
|
|
772
|
+
applyWalkContext(state, nf);
|
|
773
|
+
if (state.encryptedStack.length > 0)
|
|
774
|
+
nf.encryptedParentId = state.encryptedStack[state.encryptedStack.length - 1].parentId;
|
|
775
|
+
state.out.push(nf);
|
|
776
|
+
state.offset += bits;
|
|
777
|
+
// §4: record the encrypted container's wire footprint so a later
|
|
778
|
+
// `wireSize(e.id)` resolves to its byte size rather than 0.
|
|
779
|
+
recordWireSize(state, e.id, bits);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const budgetBits = e.wireBits !== undefined
|
|
783
|
+
? Math.max(0, Math.trunc(evalIn(state, e.wireBits)))
|
|
784
|
+
: undefined;
|
|
785
|
+
const startOffset = state.offset;
|
|
786
|
+
const frame = {
|
|
787
|
+
parentId: e.id,
|
|
788
|
+
contextNote: e.contextNote ?? "",
|
|
789
|
+
headerProtected: new Set(e.headerProtected ?? []),
|
|
790
|
+
};
|
|
791
|
+
state.encryptedStack.push(frame);
|
|
792
|
+
state.scopeStack.push({ startOffset, kind: "encrypted", ...(budgetBits !== undefined ? { budgetBits } : {}) });
|
|
793
|
+
try {
|
|
794
|
+
for (const child of e.plaintext.fields)
|
|
795
|
+
walkContainer(child, sub, state);
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
state.scopeStack.pop();
|
|
799
|
+
state.encryptedStack.pop();
|
|
800
|
+
}
|
|
801
|
+
// §5: when wireBits gives the ciphertext footprint, the plaintext children's
|
|
802
|
+
// bit sum need not match it (AEAD ciphertext+tag differs from the plaintext
|
|
803
|
+
// layout). Mirror walkBounded: over-consuming the wireBits budget is a runtime
|
|
804
|
+
// error, and under-consumption snaps the cursor to the budget end so every
|
|
805
|
+
// following field gets the correct absoluteBitOffset.
|
|
806
|
+
if (budgetBits !== undefined) {
|
|
807
|
+
const consumed = state.offset - startOffset;
|
|
808
|
+
if (consumed > budgetBits)
|
|
809
|
+
throw new Error(`normalize: encrypted scope "${e.id}" over-consumed: plaintext used ${consumed} bits but wireBits is ${budgetBits} bits (§5/§11.2).`);
|
|
810
|
+
if (consumed < budgetBits)
|
|
811
|
+
state.offset = startOffset + budgetBits;
|
|
812
|
+
}
|
|
813
|
+
// §4: record the encrypted container's wire footprint for wireSize.
|
|
814
|
+
recordWireSize(state, e.id, state.offset - startOffset);
|
|
815
|
+
}
|
|
816
|
+
function sumPlaintextBits(e, parent) {
|
|
817
|
+
// Use an isolated env copy: this is a throwaway size probe laid out at
|
|
818
|
+
// offset 0 with an empty id prefix, so emit/walkVirtual writes (field
|
|
819
|
+
// values, wireSize keys, virtual values) must NOT leak back into the live
|
|
820
|
+
// parent env, where a later sibling could read the polluted footprint.
|
|
821
|
+
const tmpEnv = new Map(parent.env);
|
|
822
|
+
const tmp = {
|
|
823
|
+
out: [], env: tmpEnv, offset: 0, viewMode: "wire", defs: parent.defs,
|
|
824
|
+
encryptedStack: [], scopeStack: [], groupStack: [], repeatIndexStack: [],
|
|
825
|
+
idPrefix: "", refDepth: parent.refDepth,
|
|
826
|
+
};
|
|
827
|
+
for (const child of e.plaintext.fields)
|
|
828
|
+
walkContainer(child, e.plaintext.id, tmp);
|
|
829
|
+
return tmp.offset;
|
|
830
|
+
}
|
|
831
|
+
export function normalize(packet, env = new Map(), opts = {}) {
|
|
832
|
+
const localEnv = new Map(env);
|
|
833
|
+
const defs = packet.defs ?? {};
|
|
834
|
+
seedDefaults(packet.body, localEnv, defs);
|
|
835
|
+
const state = {
|
|
836
|
+
out: [],
|
|
837
|
+
env: localEnv,
|
|
838
|
+
offset: 0,
|
|
839
|
+
viewMode: opts.viewMode ?? "wire",
|
|
840
|
+
defs,
|
|
841
|
+
encryptedStack: [],
|
|
842
|
+
scopeStack: [{ startOffset: 0, kind: "top", ...(opts.totalBits !== undefined ? { budgetBits: opts.totalBits } : {}) }],
|
|
843
|
+
groupStack: [],
|
|
844
|
+
repeatIndexStack: [],
|
|
845
|
+
idPrefix: "",
|
|
846
|
+
refDepth: 0,
|
|
847
|
+
};
|
|
848
|
+
for (const c of packet.body)
|
|
849
|
+
walkContainer(c, packet.name, state);
|
|
850
|
+
return { fields: state.out, totalBits: state.offset };
|
|
851
|
+
}
|
|
852
|
+
export function initialEnv(packet) {
|
|
853
|
+
const env = new Map();
|
|
854
|
+
seedDefaults(packet.body, env, packet.defs ?? {});
|
|
855
|
+
return env;
|
|
856
|
+
}
|
|
857
|
+
//# sourceMappingURL=normalize.js.map
|