@1agh/maude 0.58.3 → 0.59.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.
Files changed (67) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +868 -214
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. package/package.json +8 -8
@@ -0,0 +1,617 @@
1
+ /**
2
+ * @file figma/fig-decode.ts — the local `.fig` / `.jam` ingestion door.
3
+ * @scope apps/studio/figma/fig-decode.ts
4
+ * @purpose ZIP → `canvas.fig` → container → the two decompressors → Kiwi →
5
+ * a REST-SHAPED raw tree → `normalizeDocument(raw, {origin:'fig'})`.
6
+ * The second door onto the tree the translators already consume
7
+ * (DDR-221). No network, no token, no SSRF: the whole door is local.
8
+ *
9
+ * @invariant EMIT REST-SHAPED RAW, DO NOT BUILD A NormalizedDocument (DDR-221
10
+ * A5). Handing raw to the EXISTING normalizer makes the node/depth
11
+ * caps and the prototype-pollution guard literally the same code as
12
+ * the REST door, rather than a parallel implementation that drifts.
13
+ *
14
+ * @invariant FRAMING ERRORS REFUSE THE FILE; VOCABULARY GAPS DEGRADE AND
15
+ * REPORT (DDR-221 D3). An unknown prelude or container version is
16
+ * a refusal — never a best-effort decode, because a design importer
17
+ * that guesses produces wrong geometry that looks right.
18
+ *
19
+ * @invariant EVERY SCHEMA-SOURCED STRING IS ATTACKER-CHOSEN (DDR-221 A8/F1).
20
+ * Enum member names come from the file's own schema, so node
21
+ * `type` is attacker-controlled. Anything reaching a report goes
22
+ * through `reportToken` + a count, never verbatim — `attrValue`
23
+ * is NOT enough, it maps rejected characters to spaces and a
24
+ * bounded label can still read as prose.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+ import { inflateRawSync, zstdDecompressSync } from 'node:zlib';
29
+
30
+ import { decodeKiwi, findRootDefinition, type KiwiSchema, parseKiwiSchema } from './fig-kiwi.ts';
31
+ import { FigZipError, readFigZip } from './fig-zip.ts';
32
+ import { reportToken } from './sanitize.ts';
33
+ import {
34
+ KNOWN_NODE_TYPES,
35
+ MAX_NODE_COUNT,
36
+ type NormalizedDocument,
37
+ normalizeDocument,
38
+ } from './types.ts';
39
+
40
+ // ── Container constants (measured on the committed fixtures) ────────────────
41
+
42
+ /** The 8-byte ASCII prelude is the ONLY editor discriminator. */
43
+ const PRELUDES: Record<string, 'design' | 'board'> = {
44
+ 'fig-kiwi': 'design',
45
+ 'fig-jam.': 'board',
46
+ };
47
+
48
+ /**
49
+ * Known container versions. An unknown one REFUSES (DDR-221 D3) — this is the
50
+ * decision most likely to annoy someone the first time Figma ships 107, and the
51
+ * trade is deliberate: the message names the version, the schema-hash alarm is
52
+ * designed to see it coming, and the REST door still works.
53
+ */
54
+ export const KNOWN_CONTAINER_VERSIONS: ReadonlySet<number> = new Set([106]);
55
+
56
+ /** ~1 500x the measured 42 KB. */
57
+ export const MAX_CANVAS_FIG_BYTES = 64 * 1024 * 1024;
58
+ /** 2 observed. */
59
+ export const MAX_CHUNKS = 8;
60
+ /** ~117x the measured 71 777 B. */
61
+ export const MAX_SCHEMA_BYTES = 8 * 1024 * 1024;
62
+ /** ~1 000x the measured 67 207 B. */
63
+ export const MAX_DATA_BYTES = 64 * 1024 * 1024;
64
+
65
+ const CANVAS_ENTRY = 'canvas.fig';
66
+ const META_ENTRY = 'meta.json';
67
+
68
+ export class FigDecodeError extends Error {
69
+ constructor(message: string) {
70
+ super(message);
71
+ this.name = 'FigDecodeError';
72
+ }
73
+ }
74
+
75
+ export interface FigContainer {
76
+ surface: 'design' | 'board';
77
+ prelude: string;
78
+ version: number;
79
+ schema: Uint8Array;
80
+ data: Uint8Array;
81
+ /**
82
+ * `sha256` of the COMPRESSED schema chunk. Stable and shared across editor
83
+ * types, so a change is an early drift warning that fires before anything
84
+ * breaks (DDR-221 D8).
85
+ */
86
+ schemaSha256: string;
87
+ }
88
+
89
+ export interface FigDecodeReport {
90
+ containerVersion: number;
91
+ schemaSha256: string;
92
+ /** From `meta.json`. Makes a dated fixture corpus self-labelling. */
93
+ exportedAt?: string;
94
+ nodeCount: number;
95
+ /**
96
+ * Node types the file carried that `FigmaNodeType` has no member for.
97
+ * `reportToken`-bounded: one lowercase token, never spaces (DDR-221 A8/F1).
98
+ */
99
+ unmappedTypes: Array<{ type: string; count: number }>;
100
+ /** Nodes dropped because Figma marks them internal (e.g. the hidden canvas). */
101
+ internalNodesSkipped: number;
102
+ }
103
+
104
+ export interface FigDecodeResult {
105
+ document: NormalizedDocument;
106
+ report: FigDecodeReport;
107
+ }
108
+
109
+ function u32le(b: Uint8Array, o: number): number {
110
+ return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
111
+ }
112
+
113
+ function decompress(chunk: Uint8Array, kind: 'schema' | 'data', max: number): Uint8Array {
114
+ try {
115
+ // maxOutputLength is enforced BY the codec, so a bomb never allocates
116
+ // (DDR-221 D4, measured: a 28 533:1 zstd bomb throws instead of expanding).
117
+ return kind === 'schema'
118
+ ? inflateRawSync(chunk, { maxOutputLength: max })
119
+ : zstdDecompressSync(chunk, { maxOutputLength: max });
120
+ } catch (err) {
121
+ if ((err as { code?: string }).code === 'ERR_BUFFER_TOO_LARGE') {
122
+ throw new FigDecodeError(`the ${kind} chunk decompresses past the ${max}-byte limit`);
123
+ }
124
+ throw new FigDecodeError(
125
+ kind === 'schema'
126
+ ? 'the schema chunk is not raw-deflate data'
127
+ : 'the data chunk is not zstd data'
128
+ );
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Read `canvas.fig`'s framing. Every failure here refuses the file and names
134
+ * the observed value, so diagnosis is one line rather than an afternoon.
135
+ */
136
+ export function readFigContainer(bytes: Uint8Array): FigContainer {
137
+ if (bytes.length > MAX_CANVAS_FIG_BYTES) {
138
+ throw new FigDecodeError(
139
+ `canvas.fig is ${bytes.length} bytes, over the ${MAX_CANVAS_FIG_BYTES}-byte limit`
140
+ );
141
+ }
142
+ if (bytes.length < 12)
143
+ throw new FigDecodeError('canvas.fig is too short to be a Figma container');
144
+
145
+ const prelude = new TextDecoder('latin1').decode(bytes.subarray(0, 8));
146
+ const surface = PRELUDES[prelude];
147
+ if (!surface) {
148
+ // The observed bytes are attacker-chosen; hex keeps them inert in a report.
149
+ const hex = Array.from(bytes.subarray(0, 8), (b) => b.toString(16).padStart(2, '0')).join(' ');
150
+ throw new FigDecodeError(`unrecognised Figma container prelude (bytes: ${hex})`);
151
+ }
152
+
153
+ const version = u32le(bytes, 8);
154
+ if (!KNOWN_CONTAINER_VERSIONS.has(version)) {
155
+ throw new FigDecodeError(
156
+ `unsupported Figma container version ${version} (known: ${[...KNOWN_CONTAINER_VERSIONS].join(', ')}). ` +
157
+ 'Import this file over the Figma API instead.'
158
+ );
159
+ }
160
+
161
+ const chunks: Uint8Array[] = [];
162
+ let offset = 12;
163
+ while (offset + 4 <= bytes.length) {
164
+ const len = u32le(bytes, offset);
165
+ offset += 4;
166
+ if (offset + len > bytes.length) {
167
+ throw new FigDecodeError(
168
+ `chunk ${chunks.length} declares ${len} bytes past the end of canvas.fig`
169
+ );
170
+ }
171
+ chunks.push(bytes.subarray(offset, offset + len));
172
+ offset += len;
173
+ if (chunks.length > MAX_CHUNKS) {
174
+ throw new FigDecodeError(`canvas.fig declares more than ${MAX_CHUNKS} chunks`);
175
+ }
176
+ }
177
+
178
+ if (chunks.length !== 2) {
179
+ throw new FigDecodeError(`canvas.fig has ${chunks.length} chunks, expected exactly 2`);
180
+ }
181
+ if (offset !== bytes.length) {
182
+ throw new FigDecodeError(`canvas.fig has ${bytes.length - offset} trailing bytes`);
183
+ }
184
+
185
+ // The zstd magic is checked up front so a swapped chunk order refuses with a
186
+ // clear message instead of a confusing inflate failure.
187
+ const d = chunks[1];
188
+ if (!(d.length >= 4 && d[0] === 0x28 && d[1] === 0xb5 && d[2] === 0x2f && d[3] === 0xfd)) {
189
+ throw new FigDecodeError('the data chunk is missing its zstd magic');
190
+ }
191
+
192
+ return {
193
+ surface,
194
+ prelude,
195
+ version,
196
+ schema: decompress(chunks[0], 'schema', MAX_SCHEMA_BYTES),
197
+ data: decompress(chunks[1], 'data', MAX_DATA_BYTES),
198
+ schemaSha256: createHash('sha256').update(chunks[0]).digest('hex'),
199
+ };
200
+ }
201
+
202
+ // ── Tree reconstruction ─────────────────────────────────────────────────────
203
+
204
+ interface Matrix {
205
+ m00: number;
206
+ m01: number;
207
+ m02: number;
208
+ m10: number;
209
+ m11: number;
210
+ m12: number;
211
+ }
212
+
213
+ const IDENTITY: Matrix = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 };
214
+
215
+ function num(v: unknown): number | undefined {
216
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
217
+ }
218
+
219
+ function str(v: unknown): string | undefined {
220
+ return typeof v === 'string' ? v : undefined;
221
+ }
222
+
223
+ function obj(v: unknown): Record<string, unknown> | undefined {
224
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
225
+ ? (v as Record<string, unknown>)
226
+ : undefined;
227
+ }
228
+
229
+ /**
230
+ * ABSENT is not the same as NaN. A missing `transform` legitimately means the
231
+ * identity, but a component that is PRESENT and non-finite is a crafted or
232
+ * corrupt file: defaulting it to the identity yields plausible-but-wrong
233
+ * geometry with no error signal, which is the exact failure D3 exists to
234
+ * prevent (and the same shape as the A4 float trap). Refuse instead.
235
+ */
236
+ function component(v: unknown, fallback: number, name: string): number {
237
+ if (v === undefined) return fallback;
238
+ const n = num(v);
239
+ if (n === undefined)
240
+ throw new FigDecodeError(`transform component ${name} is not a finite number`);
241
+ return n;
242
+ }
243
+
244
+ function matrixOf(v: unknown): Matrix {
245
+ const m = obj(v);
246
+ if (!m) return IDENTITY;
247
+ return {
248
+ m00: component(m.m00, 1, 'm00'),
249
+ m01: component(m.m01, 0, 'm01'),
250
+ m02: component(m.m02, 0, 'm02'),
251
+ m10: component(m.m10, 0, 'm10'),
252
+ m11: component(m.m11, 1, 'm11'),
253
+ m12: component(m.m12, 0, 'm12'),
254
+ };
255
+ }
256
+
257
+ function compose(p: Matrix, c: Matrix): Matrix {
258
+ return {
259
+ m00: p.m00 * c.m00 + p.m01 * c.m10,
260
+ m01: p.m00 * c.m01 + p.m01 * c.m11,
261
+ m02: p.m00 * c.m02 + p.m01 * c.m12 + p.m02,
262
+ m10: p.m10 * c.m00 + p.m11 * c.m10,
263
+ m11: p.m10 * c.m01 + p.m11 * c.m11,
264
+ m12: p.m10 * c.m02 + p.m11 * c.m12 + p.m12,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * `.fig` geometry is a parent-relative affine transform plus a size; REST
270
+ * reports an absolute axis-aligned box. Composing down the parent chain is one
271
+ * of the two places the two doors can legitimately disagree, so it is also one
272
+ * of the two things the Tier-2 differential exists to check (DDR-221 A3).
273
+ */
274
+ function absoluteBox(m: Matrix, w: number, h: number) {
275
+ const xs = [m.m02, m.m00 * w + m.m02, m.m01 * h + m.m02, m.m00 * w + m.m01 * h + m.m02];
276
+ const ys = [m.m12, m.m10 * w + m.m12, m.m11 * h + m.m12, m.m10 * w + m.m11 * h + m.m12];
277
+ const x = Math.min(...xs);
278
+ const y = Math.min(...ys);
279
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
280
+ }
281
+
282
+ function guidOf(v: unknown): string | undefined {
283
+ const g = obj(v);
284
+ if (!g) return undefined;
285
+ const s = num(g.sessionID);
286
+ const l = num(g.localID);
287
+ return s === undefined || l === undefined ? undefined : `${s}:${l}`;
288
+ }
289
+
290
+ /**
291
+ * Map ONE decoded `NodeChange` onto REST-shaped fields.
292
+ *
293
+ * Reads a HARDCODED list of field names and never iterates the decoded
294
+ * object's own keys (DDR-221 A8/F5) — field names come from the attacker's
295
+ * schema, so a generic copy would let them choose which REST field each value
296
+ * lands in (`absoluteBoundingBox`, `characters`, `children`).
297
+ */
298
+ function toRestNode(change: Record<string, unknown>, absolute: Matrix): Record<string, unknown> {
299
+ const size = obj(change.size);
300
+ const w = num(size?.x) ?? 0;
301
+ const h = num(size?.y) ?? 0;
302
+
303
+ const raw: Record<string, unknown> = {
304
+ id: guidOf(change.guid),
305
+ type: str(change.type),
306
+ name: str(change.name) ?? '',
307
+ visible: change.visible !== false,
308
+ absoluteBoundingBox: absoluteBox(absolute, w, h),
309
+ };
310
+
311
+ const opacity = num(change.opacity);
312
+ if (opacity !== undefined) raw.opacity = opacity;
313
+ const blend = str(change.blendMode);
314
+ if (blend) raw.blendMode = blend;
315
+ const cornerRadius = num(change.cornerRadius);
316
+ if (cornerRadius !== undefined) raw.cornerRadius = cornerRadius;
317
+ const strokeWeight = num(change.strokeWeight);
318
+ if (strokeWeight !== undefined) raw.strokeWeight = strokeWeight;
319
+ if (Array.isArray(change.fillPaints)) raw.fills = change.fillPaints;
320
+ if (Array.isArray(change.strokePaints)) raw.strokes = change.strokePaints;
321
+ if (Array.isArray(change.effects)) raw.effects = change.effects;
322
+
323
+ // Auto-layout. `.fig` calls it `stack*`; REST calls it `layout*`.
324
+ const stackMode = str(change.stackMode);
325
+ if (stackMode === 'HORIZONTAL' || stackMode === 'VERTICAL') {
326
+ raw.layoutMode = stackMode;
327
+ const spacing = num(change.stackSpacing);
328
+ if (spacing !== undefined) raw.itemSpacing = spacing;
329
+ const hPad = num(change.stackHorizontalPadding);
330
+ const vPad = num(change.stackVerticalPadding);
331
+ if (hPad !== undefined) {
332
+ raw.paddingLeft = hPad;
333
+ raw.paddingRight = hPad;
334
+ }
335
+ if (vPad !== undefined) {
336
+ raw.paddingTop = vPad;
337
+ raw.paddingBottom = vPad;
338
+ }
339
+ const primary = str(change.stackPrimaryAlignItems);
340
+ if (primary) raw.primaryAxisAlignItems = primary;
341
+ const counter = str(change.stackCounterAlignItems);
342
+ if (counter) raw.counterAxisAlignItems = counter;
343
+ }
344
+
345
+ // Text.
346
+ const textData = obj(change.textData);
347
+ const characters = str(textData?.characters);
348
+ if (characters !== undefined) raw.characters = characters;
349
+ const fontSize = num(change.fontSize);
350
+ const fontName = obj(change.fontName);
351
+ const align = str(change.textAlignHorizontal);
352
+ if (fontSize !== undefined || fontName || align) {
353
+ raw.style = {
354
+ fontSize,
355
+ fontFamily: str(fontName?.family),
356
+ fontPostScriptName: str(fontName?.postscript),
357
+ textAlignHorizontal: align,
358
+ };
359
+ }
360
+
361
+ // FigJam.
362
+ const shapeType = str(change.shapeWithTextType);
363
+ if (shapeType) raw.shapeType = shapeType;
364
+ // REST wraps an endpoint as `{ endpointNodeId }` (lowercase d) and the
365
+ // normalizer unwraps it; `.fig` carries a GUID struct under `endpointNodeID`
366
+ // (uppercase D). Emit REST's shape so the SHARED normalizer resolves it —
367
+ // handing it a bare string silently yields an unbound connector.
368
+ const start = guidOf(obj(change.connectorStart)?.endpointNodeID);
369
+ if (start) raw.connectorStart = { endpointNodeId: start };
370
+ const end = guidOf(obj(change.connectorEnd)?.endpointNodeID);
371
+ if (end) raw.connectorEnd = { endpointNodeId: end };
372
+ const startCap = str(change.connectorStartCap);
373
+ if (startCap) raw.connectorStartCap = startCap;
374
+ const endCap = str(change.connectorEndCap);
375
+ if (endCap) raw.connectorEndCap = endCap;
376
+ const lineStyle = str(change.connectorLineStyle);
377
+ if (lineStyle) raw.connectorLineType = lineStyle;
378
+
379
+ return raw;
380
+ }
381
+
382
+ interface RebuildResult {
383
+ root: Record<string, unknown>;
384
+ unmapped: Map<string, number>;
385
+ internalSkipped: number;
386
+ nodeCount: number;
387
+ }
388
+
389
+ /**
390
+ * Rebuild the tree from `parentIndex`. A `.fig` is a FLAT `nodeChanges[]`, not
391
+ * a nested document (DDR-221 A3), and this reconstruction is a SECOND recursion
392
+ * that the Kiwi decode-depth cap does not bound (A8/F4) — hence the explicit
393
+ * cycle, duplicate, orphan and single-root controls here.
394
+ */
395
+ function rebuildTree(changes: unknown[]): RebuildResult {
396
+ if (changes.length > MAX_NODE_COUNT) {
397
+ throw new FigDecodeError(
398
+ `file carries ${changes.length} nodes, over the ${MAX_NODE_COUNT} limit. Import a specific frame instead.`
399
+ );
400
+ }
401
+
402
+ const byId = new Map<string, Record<string, unknown>>();
403
+ const order: string[] = [];
404
+
405
+ for (const entry of changes) {
406
+ const change = obj(entry);
407
+ if (!change) throw new FigDecodeError('a node change is not an object');
408
+ const id = guidOf(change.guid);
409
+ if (!id) throw new FigDecodeError('a node change has no usable guid');
410
+ if (byId.has(id)) throw new FigDecodeError(`duplicate node guid ${id}`);
411
+ // Internal-only nodes are NOT removed here. Dropping them before parentage
412
+ // is resolved would orphan their children and refuse a legitimate file —
413
+ // the fixtures' internal canvas happens to be childless, which is why this
414
+ // looked safe. They are pruned as whole subtrees during the walk instead.
415
+ byId.set(id, change);
416
+ order.push(id);
417
+ }
418
+
419
+ const childIds = new Map<string, string[]>();
420
+ const roots: string[] = [];
421
+ for (const id of order) {
422
+ const change = byId.get(id) as Record<string, unknown>;
423
+ const parentIndex = obj(change.parentIndex);
424
+ const parent = guidOf(parentIndex?.guid);
425
+ if (parent === undefined) {
426
+ roots.push(id);
427
+ continue;
428
+ }
429
+ if (parent === id) throw new FigDecodeError(`node ${id} is its own parent`);
430
+ if (!byId.has(parent)) {
431
+ // Not silently dropped: an orphan means we misread the file or the file
432
+ // is crafted, and either way a partial tree is the wrong outcome.
433
+ throw new FigDecodeError(`node ${id} references parent ${parent}, which is not in the file`);
434
+ }
435
+ const siblings = childIds.get(parent);
436
+ if (siblings) siblings.push(id);
437
+ else childIds.set(parent, [id]);
438
+ }
439
+
440
+ if (roots.length !== 1) {
441
+ throw new FigDecodeError(`file has ${roots.length} root nodes, expected exactly 1`);
442
+ }
443
+
444
+ // Fractional-index ordering. Plain comparison, not `localeCompare`: the
445
+ // strings are attacker-controlled and locale collation on 20k items is both
446
+ // slow and locale-dependent.
447
+ for (const siblings of childIds.values()) {
448
+ siblings.sort((a, b) => {
449
+ const pa = str(obj(byId.get(a)?.parentIndex)?.position) ?? '';
450
+ const pb = str(obj(byId.get(b)?.parentIndex)?.position) ?? '';
451
+ return pa < pb ? -1 : pa > pb ? 1 : 0;
452
+ });
453
+ }
454
+
455
+ const unmapped = new Map<string, number>();
456
+ const onStack = new Set<string>();
457
+ let nodeCount = 0;
458
+ let internalSkipped = 0;
459
+
460
+ /** Count a pruned internal subtree so the report says how much was dropped. */
461
+ const countSubtree = (id: string, seen: Set<string>): number => {
462
+ if (seen.has(id)) return 0;
463
+ seen.add(id);
464
+ let n = 1;
465
+ for (const kid of childIds.get(id) ?? []) n += countSubtree(kid, seen);
466
+ return n;
467
+ };
468
+
469
+ const build = (id: string, parentAbsolute: Matrix, depth: number): Record<string, unknown> => {
470
+ // Cycle detection. A→B→A would otherwise recurse until the stack dies.
471
+ if (onStack.has(id)) throw new FigDecodeError(`node parentage forms a cycle at ${id}`);
472
+ onStack.add(id);
473
+
474
+ const change = byId.get(id) as Record<string, unknown>;
475
+ const absolute = compose(parentAbsolute, matrixOf(change.transform));
476
+ const node = toRestNode(change, absolute);
477
+ nodeCount++;
478
+
479
+ const type = node.type;
480
+ if (typeof type !== 'string' || !KNOWN_NODE_TYPES.has(type)) {
481
+ // Vocabulary gap: degrade and report (D3). The raw string is left in
482
+ // place so the SHARED normalizer maps it to 'UNKNOWN' — this door does
483
+ // not get its own opinion about the vocabulary (A5). Only the REPORT
484
+ // label is produced here, and it is BOUNDED because the type name comes
485
+ // from the attacker's schema (A8/F1).
486
+ // `reportToken`, NOT `attrValue`: attrValue maps rejected characters to
487
+ // SPACES, so a 32-char bound still yields readable prose — measured, the
488
+ // test for this control failed against attrValue first. One token, no
489
+ // spaces, or the fixed word `unrecognized`.
490
+ const label = reportToken(typeof type === 'string' ? type : '');
491
+ unmapped.set(label, (unmapped.get(label) ?? 0) + 1);
492
+ }
493
+
494
+ const kids = childIds.get(id);
495
+ if (kids?.length) {
496
+ const kept: Array<Record<string, unknown>> = [];
497
+ for (const kid of kids) {
498
+ // Prune Figma's internal-only nodes as WHOLE SUBTREES here, where the
499
+ // parentage is already resolved, rather than dropping them up front.
500
+ if (byId.get(kid)?.internalOnly === true) {
501
+ internalSkipped += countSubtree(kid, new Set());
502
+ continue;
503
+ }
504
+ kept.push(build(kid, absolute, depth + 1));
505
+ }
506
+ if (kept.length > 0) node.children = kept;
507
+ }
508
+
509
+ onStack.delete(id);
510
+ return node;
511
+ };
512
+
513
+ if (byId.get(roots[0])?.internalOnly === true) {
514
+ throw new FigDecodeError('the document root is marked internal-only');
515
+ }
516
+ const root = build(roots[0], IDENTITY, 0);
517
+ return { root, unmapped, internalSkipped, nodeCount };
518
+ }
519
+
520
+ // ── The door ────────────────────────────────────────────────────────────────
521
+
522
+ export interface DecodeFigOptions {
523
+ /** Charset-validated upstream. Provenance only — never derived from the file. */
524
+ fileKey: string;
525
+ }
526
+
527
+ /**
528
+ * Decode a `.fig` / `.jam` archive into the normalized tree both doors share.
529
+ */
530
+ export function decodeFigArchive(archive: Uint8Array, opts: DecodeFigOptions): FigDecodeResult {
531
+ // Both the directory parse AND the lazy per-entry read can refuse, and a
532
+ // caller of this door should only ever have to catch FigDecodeError. Wrapping
533
+ // just the first call let a FigZipError escape from `get()` — caught by the
534
+ // fuzz corpus, which is exactly the class of leak it exists to find.
535
+ let zip: ReturnType<typeof readFigZip>;
536
+ let canvas: Uint8Array | undefined;
537
+ try {
538
+ zip = readFigZip(archive);
539
+ canvas = zip.get(CANVAS_ENTRY);
540
+ } catch (err) {
541
+ if (err instanceof FigZipError) throw new FigDecodeError(err.message);
542
+ throw err;
543
+ }
544
+
545
+ if (!canvas) throw new FigDecodeError(`archive has no ${CANVAS_ENTRY} entry`);
546
+
547
+ const container = readFigContainer(canvas);
548
+
549
+ let schema: KiwiSchema;
550
+ let message: Record<string, unknown> | undefined;
551
+ try {
552
+ schema = parseKiwiSchema(container.schema);
553
+ const rootIndex = findRootDefinition(schema, 'Message', 'nodeChanges', 'NodeChange');
554
+ message = obj(decodeKiwi(container.data, schema, rootIndex));
555
+ } catch (err) {
556
+ throw new FigDecodeError(`could not decode canvas.fig: ${(err as Error).message}`);
557
+ }
558
+
559
+ const changes = message?.nodeChanges;
560
+ if (!Array.isArray(changes) || changes.length === 0) {
561
+ throw new FigDecodeError('canvas.fig carries no node changes');
562
+ }
563
+
564
+ const { root, unmapped, internalSkipped, nodeCount } = rebuildTree(changes);
565
+
566
+ const document = normalizeDocument(root, {
567
+ fileKey: opts.fileKey,
568
+ surface: container.surface,
569
+ origin: 'fig',
570
+ });
571
+
572
+ return {
573
+ document,
574
+ report: {
575
+ containerVersion: container.version,
576
+ schemaSha256: container.schemaSha256,
577
+ exportedAt: readExportedAt(safeEntry(zip, META_ENTRY)),
578
+ nodeCount,
579
+ unmappedTypes: [...unmapped.entries()]
580
+ .map(([type, count]) => ({ type, count }))
581
+ .sort((a, b) => b.count - a.count),
582
+ internalNodesSkipped: internalSkipped,
583
+ },
584
+ };
585
+ }
586
+
587
+ /**
588
+ * `meta.json` is optional and its bytes are lazily verified, so a corrupt entry
589
+ * must not turn a decodable document into a hard failure — nor let a
590
+ * FigZipError escape this door's error type.
591
+ */
592
+ function safeEntry(zip: ReturnType<typeof readFigZip>, name: string): Uint8Array | undefined {
593
+ try {
594
+ return zip.get(name);
595
+ } catch {
596
+ return undefined;
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Read `exported_at` (Tier 4 needs it) and DELIBERATELY DROP `file_name`, which
602
+ * sits in the same object: DDR-216 D7 forbids recording the Figma file NAME
603
+ * anywhere an agent later reads (DDR-221 A6). The REST door never had it to
604
+ * hand; this one must decline it explicitly.
605
+ */
606
+ function readExportedAt(metaBytes: Uint8Array | undefined): string | undefined {
607
+ if (!metaBytes) return undefined;
608
+ try {
609
+ const meta = obj(JSON.parse(new TextDecoder().decode(metaBytes)));
610
+ const exportedAt = str(meta?.exported_at);
611
+ if (!exportedAt) return undefined;
612
+ // Bounded and charset-checked: it is untrusted text from the archive.
613
+ return /^[0-9TZ:.-]{1,32}$/.test(exportedAt) ? exportedAt : undefined;
614
+ } catch {
615
+ return undefined;
616
+ }
617
+ }