@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,410 @@
1
+ /**
2
+ * @file figma/fig-kiwi.ts — Kiwi schema + data decoder for the `.fig` door.
3
+ * @scope apps/studio/figma/fig-kiwi.ts
4
+ * @purpose Decode the two chunks of a `canvas.fig`: chunk[0] is the Kiwi
5
+ * SCHEMA the file carries for itself, chunk[1] is the DATA encoded
6
+ * against it. Ported from the documented reference implementation
7
+ * (`evanw/kiwi`) rather than depended on (DDR-221 D1).
8
+ *
9
+ * @invariant THE ATTACKER SUPPLIES THE SCHEMA, NOT JUST THE DATA. This is the
10
+ * property that makes a `.fig` unlike every other parser input in
11
+ * the repo, and the source of most controls below (DDR-221 A8):
12
+ * - decoded objects use `Object.create(null)`, because field
13
+ * names are schema-chosen and `o["__proto__"] = v` would
14
+ * otherwise give a node an attacker-supplied prototype and
15
+ * therefore phantom inherited fields (F3);
16
+ * - enum members decode to schema-chosen NAMES, so every value
17
+ * leaving here is UNTRUSTED text and must be bounded before it
18
+ * reaches a report (F1 — enforced at the sinks, not here);
19
+ * - root-type resolution is the CALLER's job and must be strict
20
+ * (F2 — see `findRootDefinition`).
21
+ *
22
+ * @invariant A LENGTH PREFIX IS A CLAIM ABOUT A BUFFER WE ALREADY HOLD. Every
23
+ * array length is bounded by the bytes actually remaining, so a
24
+ * `varuint` of 4 billion cannot allocate (DDR-221 D4).
25
+ *
26
+ * @invariant DEPENDENCY-FREE and side-effect-free. No `node:*`, no IO.
27
+ */
28
+
29
+ import { MAX_TREE_DEPTH } from './types.ts';
30
+
31
+ // ── Caps (DDR-221 D4, headroom measured in A1) ──────────────────────────────
32
+
33
+ /** Observed in the real schema: 627. */
34
+ export const MAX_DEFINITIONS = 8192;
35
+ /** Observed: 602 (on `NodeChange`). */
36
+ export const MAX_FIELDS_PER_DEF = 4096;
37
+ /** Observed: 38. */
38
+ export const MAX_IDENTIFIER_LEN = 256;
39
+ /** Bounds nested small arrays, which per-array caps alone do not. */
40
+ export const MAX_DECODED_VALUES = 5_000_000;
41
+ /** Absolute ceiling, on top of the remaining-bytes bound. */
42
+ export const MAX_ARRAY_LEN = 1_000_000;
43
+
44
+ export const KIND_ENUM = 0;
45
+ export const KIND_STRUCT = 1;
46
+ export const KIND_MESSAGE = 2;
47
+
48
+ /** Kiwi's builtin type ids are negative; non-negative indexes a definition. */
49
+ const TYPE_BOOL = -1;
50
+ const TYPE_BYTE = -2;
51
+ const TYPE_INT = -3;
52
+ const TYPE_UINT = -4;
53
+ const TYPE_FLOAT = -5;
54
+ const TYPE_STRING = -6;
55
+ const TYPE_INT64 = -7;
56
+ const TYPE_UINT64 = -8;
57
+
58
+ export class FigKiwiError extends Error {
59
+ constructor(message: string) {
60
+ super(message);
61
+ this.name = 'FigKiwiError';
62
+ }
63
+ }
64
+
65
+ export interface KiwiField {
66
+ name: string;
67
+ type: number;
68
+ isArray: boolean;
69
+ /** Enum member value, or MESSAGE field id. Always 0 for a STRUCT field. */
70
+ value: number;
71
+ }
72
+
73
+ export interface KiwiDefinition {
74
+ name: string;
75
+ kind: number;
76
+ fields: KiwiField[];
77
+ /** MESSAGE field lookup by id, built once instead of scanning 602 fields per read. */
78
+ byId?: Map<number, KiwiField>;
79
+ /** ENUM member lookup by value. */
80
+ byValue?: Map<number, string>;
81
+ }
82
+
83
+ export type KiwiSchema = KiwiDefinition[];
84
+
85
+ /** Bounds-checked cursor over the decoded chunk. */
86
+ class Reader {
87
+ offset = 0;
88
+ constructor(readonly bytes: Uint8Array) {}
89
+
90
+ get remaining(): number {
91
+ return this.bytes.length - this.offset;
92
+ }
93
+
94
+ byte(): number {
95
+ if (this.offset >= this.bytes.length) throw new FigKiwiError('unexpected end of Kiwi data');
96
+ return this.bytes[this.offset++];
97
+ }
98
+
99
+ /** LEB128. Refused past 5 bytes rather than shifted past the 32-bit width. */
100
+ varuint(): number {
101
+ let value = 0;
102
+ let shift = 0;
103
+ for (;;) {
104
+ const b = this.byte();
105
+ value |= (b & 0x7f) << shift;
106
+ shift += 7;
107
+ if ((b & 0x80) === 0) break;
108
+ if (shift > 28) throw new FigKiwiError('malformed varuint: more than 5 bytes');
109
+ }
110
+ return value >>> 0;
111
+ }
112
+
113
+ varint(): number {
114
+ const v = this.varuint();
115
+ return v & 1 ? ~(v >>> 1) : v >>> 1;
116
+ }
117
+
118
+ varuint64(): bigint {
119
+ let value = 0n;
120
+ let shift = 0n;
121
+ for (;;) {
122
+ const b = this.byte();
123
+ value |= BigInt(b & 0x7f) << shift;
124
+ shift += 7n;
125
+ if ((b & 0x80) === 0) break;
126
+ if (shift > 63n) throw new FigKiwiError('malformed varuint64: more than 10 bytes');
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /**
132
+ * Kiwi rotates a float's exponent into the low 8 bits so that zero and
133
+ * denormals encode as a single 0 byte. Getting the FRAMING right and this
134
+ * rotation wrong yields a stream that stays perfectly in sync while every
135
+ * coordinate decodes as 0 — see DDR-221 A4, which is why a fixture geometry
136
+ * assertion is a required test and not a nicety.
137
+ */
138
+ float(): number {
139
+ if (this.remaining >= 1 && this.bytes[this.offset] === 0) {
140
+ this.offset++;
141
+ return 0;
142
+ }
143
+ if (this.remaining < 4) throw new FigKiwiError('unexpected end of Kiwi data reading a float');
144
+ const b = this.bytes;
145
+ const o = this.offset;
146
+ let bits = (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
147
+ this.offset += 4;
148
+ bits = ((bits << 23) | (bits >>> 9)) >>> 0;
149
+ FLOAT_VIEW.setUint32(0, bits, true);
150
+ return FLOAT_VIEW.getFloat32(0, true);
151
+ }
152
+
153
+ /** NUL-terminated UTF-8. An unterminated run refuses rather than overreading. */
154
+ string(maxLen = MAX_IDENTIFIER_LEN * 64): string {
155
+ const start = this.offset;
156
+ const limit = Math.min(this.bytes.length, start + maxLen);
157
+ let end = start;
158
+ while (end < limit && this.bytes[end] !== 0) end++;
159
+ if (end >= limit) {
160
+ throw new FigKiwiError(
161
+ end === this.bytes.length ? 'unterminated Kiwi string' : 'Kiwi string exceeds its limit'
162
+ );
163
+ }
164
+ this.offset = end + 1;
165
+ return TEXT_DECODER.decode(this.bytes.subarray(start, end));
166
+ }
167
+ }
168
+
169
+ const FLOAT_VIEW = new DataView(new ArrayBuffer(4));
170
+ const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
171
+
172
+ function checkIdentifier(name: string, what: string): void {
173
+ if (name.length === 0) throw new FigKiwiError(`Kiwi ${what} has an empty name`);
174
+ if (name.length > MAX_IDENTIFIER_LEN) {
175
+ throw new FigKiwiError(
176
+ `Kiwi ${what} name is ${name.length} characters, over the ${MAX_IDENTIFIER_LEN} limit`
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Parse the schema chunk. Note that cyclic and self-referencing definitions are
183
+ * LEGITIMATE — `Message`, `NodeChange` and `MessageType` all self-reference in
184
+ * the real schema, so refusing cycles would refuse every real file (DDR-221
185
+ * A2). Recursion is bounded at decode time by depth instead.
186
+ */
187
+ export function parseKiwiSchema(bytes: Uint8Array): KiwiSchema {
188
+ const r = new Reader(bytes);
189
+ const count = r.varuint();
190
+ if (count > MAX_DEFINITIONS) {
191
+ throw new FigKiwiError(
192
+ `Kiwi schema declares ${count} definitions, over the ${MAX_DEFINITIONS} limit`
193
+ );
194
+ }
195
+
196
+ const defs: KiwiDefinition[] = [];
197
+ for (let i = 0; i < count; i++) {
198
+ const name = r.string();
199
+ checkIdentifier(name, 'definition');
200
+ const kind = r.byte();
201
+ if (kind !== KIND_ENUM && kind !== KIND_STRUCT && kind !== KIND_MESSAGE) {
202
+ throw new FigKiwiError(`Kiwi definition "${name}" has unknown kind ${kind}`);
203
+ }
204
+ const fieldCount = r.varuint();
205
+ if (fieldCount > MAX_FIELDS_PER_DEF) {
206
+ throw new FigKiwiError(
207
+ `Kiwi definition "${name}" declares ${fieldCount} fields, over the ${MAX_FIELDS_PER_DEF} limit`
208
+ );
209
+ }
210
+ const fields: KiwiField[] = [];
211
+ for (let f = 0; f < fieldCount; f++) {
212
+ const fieldName = r.string();
213
+ checkIdentifier(fieldName, 'field');
214
+ fields.push({
215
+ name: fieldName,
216
+ type: r.varint(),
217
+ isArray: r.byte() !== 0,
218
+ value: r.varuint(),
219
+ });
220
+ }
221
+ defs.push({ name, kind, fields });
222
+ }
223
+
224
+ if (r.remaining !== 0) {
225
+ throw new FigKiwiError(`Kiwi schema has ${r.remaining} trailing bytes`);
226
+ }
227
+
228
+ // Type indexes must resolve. A dangling index would otherwise surface as a
229
+ // confusing decode error deep inside the data chunk.
230
+ for (const def of defs) {
231
+ for (const field of def.fields) {
232
+ if (field.type >= 0 && field.type >= defs.length) {
233
+ throw new FigKiwiError(
234
+ `Kiwi field "${def.name}.${field.name}" references type index ${field.type}, out of range`
235
+ );
236
+ }
237
+ if (field.type < TYPE_UINT64) {
238
+ throw new FigKiwiError(
239
+ `Kiwi field "${def.name}.${field.name}" has unknown builtin type ${field.type}`
240
+ );
241
+ }
242
+ }
243
+ if (def.kind === KIND_MESSAGE) {
244
+ def.byId = new Map(def.fields.map((f) => [f.value, f]));
245
+ } else if (def.kind === KIND_ENUM) {
246
+ def.byValue = new Map(def.fields.map((f) => [f.value, f.name]));
247
+ }
248
+ }
249
+
250
+ return defs;
251
+ }
252
+
253
+ /**
254
+ * Resolve the document root STRICTLY (DDR-221 A8/F2).
255
+ *
256
+ * Locating the root by name alone is attacker-steerable: a hostile schema can
257
+ * omit `Message`, define two, or make it a STRUCT. Decoding the data against
258
+ * the wrong root does not necessarily fail — Kiwi MESSAGE framing is
259
+ * self-terminating — so it can yield a structurally valid, semantically WRONG
260
+ * tree, which is precisely the silent wrongness D3 exists to prevent.
261
+ */
262
+ export function findRootDefinition(
263
+ schema: KiwiSchema,
264
+ rootName: string,
265
+ arrayField: string,
266
+ elementName: string
267
+ ): number {
268
+ const matches: number[] = [];
269
+ for (let i = 0; i < schema.length; i++) {
270
+ if (schema[i].name === rootName) matches.push(i);
271
+ }
272
+ if (matches.length === 0) throw new FigKiwiError(`Kiwi schema has no "${rootName}" definition`);
273
+ if (matches.length > 1) {
274
+ throw new FigKiwiError(`Kiwi schema defines "${rootName}" ${matches.length} times`);
275
+ }
276
+
277
+ const index = matches[0];
278
+ const def = schema[index];
279
+ if (def.kind !== KIND_MESSAGE) {
280
+ throw new FigKiwiError(`Kiwi "${rootName}" is not a message`);
281
+ }
282
+ const field = def.fields.find((f) => f.name === arrayField);
283
+ if (!field?.isArray || field.type < 0) {
284
+ throw new FigKiwiError(`Kiwi "${rootName}" has no "${arrayField}" array`);
285
+ }
286
+ if (schema[field.type]?.name !== elementName) {
287
+ throw new FigKiwiError(`Kiwi "${rootName}.${arrayField}" is not an array of "${elementName}"`);
288
+ }
289
+ return index;
290
+ }
291
+
292
+ interface DecodeState {
293
+ values: number;
294
+ }
295
+
296
+ /**
297
+ * Decode the data chunk against the schema. Returns plain data: prototype-less
298
+ * objects, arrays, numbers, bigints, booleans and strings.
299
+ */
300
+ export function decodeKiwi(bytes: Uint8Array, schema: KiwiSchema, rootIndex: number): unknown {
301
+ const r = new Reader(bytes);
302
+ const state: DecodeState = { values: 0 };
303
+ const value = readValue(r, schema, rootIndex, 0, state);
304
+ if (r.remaining !== 0) {
305
+ throw new FigKiwiError(`Kiwi data has ${r.remaining} trailing bytes`);
306
+ }
307
+ return value;
308
+ }
309
+
310
+ function readValue(
311
+ r: Reader,
312
+ schema: KiwiSchema,
313
+ type: number,
314
+ depth: number,
315
+ state: DecodeState
316
+ ): unknown {
317
+ if (++state.values > MAX_DECODED_VALUES) {
318
+ throw new FigKiwiError(`Kiwi data exceeds the ${MAX_DECODED_VALUES}-value budget`);
319
+ }
320
+ if (depth > MAX_TREE_DEPTH) {
321
+ throw new FigKiwiError(`Kiwi data nests deeper than ${MAX_TREE_DEPTH} levels`);
322
+ }
323
+
324
+ if (type < 0) {
325
+ switch (type) {
326
+ case TYPE_BOOL:
327
+ return r.byte() !== 0;
328
+ case TYPE_BYTE:
329
+ return r.byte();
330
+ case TYPE_INT:
331
+ return r.varint();
332
+ case TYPE_UINT:
333
+ return r.varuint();
334
+ case TYPE_FLOAT:
335
+ return r.float();
336
+ case TYPE_STRING:
337
+ return r.string();
338
+ case TYPE_INT64:
339
+ case TYPE_UINT64:
340
+ return r.varuint64();
341
+ default:
342
+ throw new FigKiwiError(`unknown Kiwi builtin type ${type}`);
343
+ }
344
+ }
345
+
346
+ const def = schema[type];
347
+ if (!def) throw new FigKiwiError(`Kiwi type index ${type} is out of range`);
348
+
349
+ if (def.kind === KIND_ENUM) {
350
+ const raw = r.varuint();
351
+ // UNTRUSTED: the member NAME is schema-chosen (DDR-221 A8/F1). Unknown
352
+ // values keep a code-owned shape so nothing attacker-written can pose as a
353
+ // recognised member.
354
+ return def.byValue?.get(raw) ?? `UNKNOWN_${raw}`;
355
+ }
356
+
357
+ // `Object.create(null)`: field names are schema-chosen, and on a plain object
358
+ // `o["__proto__"] = v` would set this node's prototype to attacker data,
359
+ // giving it phantom inherited fields (DDR-221 A8/F3).
360
+ const out = Object.create(null) as Record<string, unknown>;
361
+
362
+ if (def.kind === KIND_STRUCT) {
363
+ for (const field of def.fields) {
364
+ out[field.name] = readField(r, schema, field, depth + 1, state);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ for (;;) {
370
+ const id = r.varuint();
371
+ if (id === 0) break;
372
+ const field = def.byId?.get(id);
373
+ if (!field) {
374
+ throw new FigKiwiError(`Kiwi message "${def.name}" has no field with id ${id}`);
375
+ }
376
+ out[field.name] = readField(r, schema, field, depth + 1, state);
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function readField(
382
+ r: Reader,
383
+ schema: KiwiSchema,
384
+ field: KiwiField,
385
+ depth: number,
386
+ state: DecodeState
387
+ ): unknown {
388
+ if (!field.isArray) return readValue(r, schema, field.type, depth, state);
389
+
390
+ const length = r.varuint();
391
+ if (length > MAX_ARRAY_LEN) {
392
+ throw new FigKiwiError(
393
+ `Kiwi array "${field.name}" declares ${length} elements, over the ${MAX_ARRAY_LEN} limit`
394
+ );
395
+ }
396
+ // A length prefix is a claim about a buffer we already hold. Even a
397
+ // zero-byte element type cannot produce more elements than bytes remain,
398
+ // because every element consumes at least the byte that terminates it.
399
+ if (length > r.remaining) {
400
+ throw new FigKiwiError(
401
+ `Kiwi array "${field.name}" declares ${length} elements with only ${r.remaining} bytes left`
402
+ );
403
+ }
404
+
405
+ const items = new Array(length);
406
+ for (let i = 0; i < length; i++) {
407
+ items[i] = readValue(r, schema, field.type, depth, state);
408
+ }
409
+ return items;
410
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @file figma/fig-zip.ts — the narrow ZIP reader behind the local `.fig` door.
3
+ * @scope apps/studio/figma/fig-zip.ts
4
+ * @purpose A `.fig`/`.jam` is a ZIP wrapping `canvas.fig` + `meta.json` +
5
+ * `images/` (DDR-221 § Context). This reads exactly that archive
6
+ * and REFUSES everything else.
7
+ *
8
+ * @invariant DEPENDENCY-FREE. `node:zlib` only. `jszip` is already an
9
+ * apps/studio dep and is deliberately NOT used (DDR-221 D2): it is
10
+ * absent from the ROOT package.json, so reaching for it would make
11
+ * `--fig` desktop-app-only on npm exactly the way `oxc-parser`
12
+ * does for `--explode`.
13
+ *
14
+ * @invariant NOT A GENERAL ZIP IMPLEMENTATION, on purpose. No zip64, no
15
+ * encryption, no data descriptors, no multi-disk, no
16
+ * central-directory recovery. One known producer writes these
17
+ * files; refusing every shape it does not emit is the fail-loud
18
+ * posture (DDR-221 D3), not a gap.
19
+ *
20
+ * @invariant AN ENTRY NAME IS A LOOKUP KEY, NEVER A PATH (DDR-221 D6). No
21
+ * value from this module is ever passed to `path.join`. The
22
+ * traversal check below is defence in depth, not the reason
23
+ * zip-slip cannot bite.
24
+ */
25
+
26
+ import { crc32, inflateRawSync } from 'node:zlib';
27
+
28
+ // ── Caps (DDR-221 D4) ───────────────────────────────────────────────────────
29
+ // Measured baseline: the committed fixtures are 49 858 B / 64 574 B with 4
30
+ // entries each, and a legitimate entry compresses ~2.5x.
31
+
32
+ /** The user picked this file; generous on purpose. */
33
+ export const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
34
+ /** `images/` can be large. 4 observed. */
35
+ export const MAX_ARCHIVE_ENTRIES = 4096;
36
+ /** ~80x the measured 2.5x. */
37
+ export const MAX_ENTRY_RATIO = 200;
38
+ /** Refuse a single entry claiming more than this, before inflating a byte. */
39
+ export const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
40
+
41
+ const EOCD_SIG = 0x06054b50;
42
+ const CD_SIG = 0x02014b50;
43
+ const LFH_SIG = 0x04034b50;
44
+ const EOCD_MIN = 22;
45
+ /** ZIP's comment length field is 16-bit, so the EOCD starts within 64 KiB + 22. */
46
+ const EOCD_SEARCH = 0xffff + EOCD_MIN;
47
+
48
+ const METHOD_STORE = 0;
49
+ const METHOD_DEFLATE = 8;
50
+
51
+ const FLAG_ENCRYPTED = 0x0001;
52
+ const FLAG_STRONG_ENCRYPTION = 0x0040;
53
+ // Bit 3 (0x0008, "data descriptor") is deliberately absent: it is ALLOWED, and
54
+ // Figma sets it. See the note at the flag check below for why it is harmless.
55
+
56
+ /** The 32-bit sentinel that means "the real value is in a zip64 extra field". */
57
+ const ZIP64_SENTINEL = 0xffffffff;
58
+
59
+ export class FigZipError extends Error {
60
+ constructor(message: string) {
61
+ super(message);
62
+ this.name = 'FigZipError';
63
+ }
64
+ }
65
+
66
+ export interface FigZipEntry {
67
+ /**
68
+ * UNTRUSTED. Attacker-chosen text from the archive. Never a path — callers
69
+ * match it against a literal, and it is bounded before it reaches any report.
70
+ */
71
+ name: string;
72
+ compressedSize: number;
73
+ uncompressedSize: number;
74
+ method: number;
75
+ crc: number;
76
+ localHeaderOffset: number;
77
+ }
78
+
79
+ /** A parsed central directory. Entry BYTES are read lazily, per entry. */
80
+ export interface FigZip {
81
+ entries: FigZipEntry[];
82
+ get(name: string): Uint8Array | undefined;
83
+ has(name: string): boolean;
84
+ }
85
+
86
+ function u16(b: Uint8Array, o: number): number {
87
+ return b[o] | (b[o + 1] << 8);
88
+ }
89
+
90
+ function u32(b: Uint8Array, o: number): number {
91
+ return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
92
+ }
93
+
94
+ /**
95
+ * Reject a name that could be read as a path by a future caller who forgets the
96
+ * lookup-key rule. Also rejects the NUL that would truncate it in a C-string
97
+ * context, and the control characters that would corrupt a report line.
98
+ */
99
+ function isSafeEntryName(name: string): boolean {
100
+ if (name.length === 0 || name.length > 512) return false;
101
+ if (name.startsWith('/') || name.startsWith('\\')) return false;
102
+ if (name.includes('\\')) return false;
103
+ if (/(^|\/)\.\.(\/|$)/.test(name)) return false;
104
+ if (/^[A-Za-z]:/.test(name)) return false;
105
+ // Control characters would corrupt a report line; refuse rather than strip.
106
+ for (let i = 0; i < name.length; i++) {
107
+ const c = name.charCodeAt(i);
108
+ if (c < 0x20 || c === 0x7f) return false;
109
+ }
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Find the End Of Central Directory record. Scans backwards over the comment
115
+ * field. A truncated or comment-forged archive refuses rather than guessing.
116
+ */
117
+ function findEocd(buf: Uint8Array): number {
118
+ const start = Math.max(0, buf.length - EOCD_SEARCH);
119
+ for (let i = buf.length - EOCD_MIN; i >= start; i--) {
120
+ if (u32(buf, i) !== EOCD_SIG) continue;
121
+ // Only accept it if the declared comment length actually reaches the end,
122
+ // so a `PK\x05\x06` sequence inside compressed data cannot pose as the EOCD.
123
+ if (i + EOCD_MIN + u16(buf, i + 20) === buf.length) return i;
124
+ }
125
+ throw new FigZipError('not a ZIP archive: no end-of-central-directory record');
126
+ }
127
+
128
+ /**
129
+ * Parse the central directory of a `.fig`/`.jam`. Entry bytes are inflated on
130
+ * demand by `get()`, so opening a large archive costs the directory only.
131
+ */
132
+ export function readFigZip(buf: Uint8Array): FigZip {
133
+ if (buf.length > MAX_ARCHIVE_BYTES) {
134
+ throw new FigZipError(
135
+ `archive is ${buf.length} bytes, over the ${MAX_ARCHIVE_BYTES}-byte limit`
136
+ );
137
+ }
138
+ if (buf.length < EOCD_MIN) throw new FigZipError('not a ZIP archive: too short');
139
+
140
+ const eocd = findEocd(buf);
141
+ const diskNum = u16(buf, eocd + 4);
142
+ const cdDisk = u16(buf, eocd + 6);
143
+ const count = u16(buf, eocd + 10);
144
+ const cdSize = u32(buf, eocd + 12);
145
+ const cdOffset = u32(buf, eocd + 16);
146
+
147
+ if (diskNum !== 0 || cdDisk !== 0) {
148
+ throw new FigZipError('multi-disk ZIP archives are not supported');
149
+ }
150
+ if (count === ZIP64_SENTINEL || cdSize === ZIP64_SENTINEL || cdOffset === ZIP64_SENTINEL) {
151
+ throw new FigZipError('zip64 archives are not supported');
152
+ }
153
+ if (count > MAX_ARCHIVE_ENTRIES) {
154
+ throw new FigZipError(
155
+ `archive declares ${count} entries, over the ${MAX_ARCHIVE_ENTRIES} limit`
156
+ );
157
+ }
158
+ if (cdOffset + cdSize > buf.length) {
159
+ throw new FigZipError('central directory extends past the end of the archive');
160
+ }
161
+
162
+ const entries: FigZipEntry[] = [];
163
+ let o = cdOffset;
164
+ for (let i = 0; i < count; i++) {
165
+ if (o + 46 > buf.length || u32(buf, o) !== CD_SIG) {
166
+ throw new FigZipError(`central directory entry ${i} is malformed`);
167
+ }
168
+ const flags = u16(buf, o + 8);
169
+ const method = u16(buf, o + 10);
170
+ const crc = u32(buf, o + 16);
171
+ const compressedSize = u32(buf, o + 20);
172
+ const uncompressedSize = u32(buf, o + 24);
173
+ const nameLen = u16(buf, o + 28);
174
+ const extraLen = u16(buf, o + 30);
175
+ const commentLen = u16(buf, o + 32);
176
+ const localHeaderOffset = u32(buf, o + 42);
177
+
178
+ if (flags & (FLAG_ENCRYPTED | FLAG_STRONG_ENCRYPTION)) {
179
+ throw new FigZipError('encrypted ZIP entries are not supported');
180
+ }
181
+ // FLAG_DATA_DESCRIPTOR is ALLOWED, and Figma's exporter does set it
182
+ // (measured on both committed fixtures — DDR-221 D3's first draft refused
183
+ // it from documentation and would have rejected every real file). It only
184
+ // means the LOCAL header's crc/sizes are zeroed and the real values trail
185
+ // the data. Harmless here: sizes and CRC are read from the central
186
+ // directory below, and the local header is consulted ONLY for its
187
+ // name/extra lengths to locate the data offset.
188
+ if (compressedSize === ZIP64_SENTINEL || uncompressedSize === ZIP64_SENTINEL) {
189
+ throw new FigZipError('zip64 entry sizes are not supported');
190
+ }
191
+ if (method !== METHOD_STORE && method !== METHOD_DEFLATE) {
192
+ throw new FigZipError(`unsupported ZIP compression method ${method}`);
193
+ }
194
+ if (o + 46 + nameLen > buf.length) throw new FigZipError('entry name runs past the archive');
195
+
196
+ const name = new TextDecoder('utf-8', { fatal: false }).decode(
197
+ buf.subarray(o + 46, o + 46 + nameLen)
198
+ );
199
+ if (!isSafeEntryName(name)) {
200
+ // Deliberately not echoed — it is attacker-chosen text (DDR-221 A8/F1).
201
+ throw new FigZipError(`archive entry ${i} has an unsafe name`);
202
+ }
203
+
204
+ // The declared size is a CLAIM. Refuse the claim before inflating anything;
205
+ // `get()` then verifies the delivery against it (DDR-221 D4).
206
+ if (uncompressedSize > MAX_ENTRY_BYTES) {
207
+ throw new FigZipError(
208
+ `entry "${name}" declares ${uncompressedSize} bytes, over the ${MAX_ENTRY_BYTES}-byte limit`
209
+ );
210
+ }
211
+ if (compressedSize > 0 && uncompressedSize / compressedSize > MAX_ENTRY_RATIO) {
212
+ throw new FigZipError(
213
+ `entry "${name}" declares a ${Math.round(uncompressedSize / compressedSize)}:1 compression ratio, over the ${MAX_ENTRY_RATIO}:1 limit`
214
+ );
215
+ }
216
+
217
+ entries.push({ name, compressedSize, uncompressedSize, method, crc, localHeaderOffset });
218
+ o += 46 + nameLen + extraLen + commentLen;
219
+ }
220
+
221
+ const byName = new Map(entries.map((e) => [e.name, e]));
222
+
223
+ function get(name: string): Uint8Array | undefined {
224
+ const e = byName.get(name);
225
+ if (!e) return undefined;
226
+
227
+ const lfh = e.localHeaderOffset;
228
+ if (lfh + 30 > buf.length || u32(buf, lfh) !== LFH_SIG) {
229
+ throw new FigZipError(`entry "${e.name}" has a malformed local header`);
230
+ }
231
+ // The local header's own name/extra lengths, NOT the central directory's:
232
+ // they are allowed to differ, and trusting the wrong one misreads the data
233
+ // offset.
234
+ const start = lfh + 30 + u16(buf, lfh + 26) + u16(buf, lfh + 28);
235
+ const end = start + e.compressedSize;
236
+ if (end > buf.length)
237
+ throw new FigZipError(`entry "${e.name}" runs past the end of the archive`);
238
+
239
+ const raw = buf.subarray(start, end);
240
+ let out: Uint8Array;
241
+ if (e.method === METHOD_STORE) {
242
+ out = raw;
243
+ } else {
244
+ // maxOutputLength is enforced BY the decompressor, so a bomb never
245
+ // allocates (DDR-221 D4, measured). +1 makes an over-long output trip
246
+ // the codec rather than pass a size check by exactly matching.
247
+ try {
248
+ out = inflateRawSync(raw, { maxOutputLength: e.uncompressedSize + 1 });
249
+ } catch (err) {
250
+ const code = (err as { code?: string }).code;
251
+ if (code === 'ERR_BUFFER_TOO_LARGE') {
252
+ throw new FigZipError(`entry "${e.name}" inflates past its declared size`);
253
+ }
254
+ throw new FigZipError(`entry "${e.name}" is not valid deflate data`);
255
+ }
256
+ }
257
+
258
+ if (out.length !== e.uncompressedSize) {
259
+ throw new FigZipError(
260
+ `entry "${e.name}" inflated to ${out.length} bytes, not the declared ${e.uncompressedSize}`
261
+ );
262
+ }
263
+ if (crc32(out) !== e.crc) {
264
+ throw new FigZipError(`entry "${e.name}" failed its CRC32 check`);
265
+ }
266
+ return out;
267
+ }
268
+
269
+ return { entries, get, has: (name: string) => byName.has(name) };
270
+ }