circuitjson-toolkit 1.2.0 → 1.3.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.
@@ -2,6 +2,7 @@ import { CircuitJsonValidationProof } from '../context/CircuitJsonValidationProo
2
2
  import { CircuitJsonReadOnlyDocument } from '../context/CircuitJsonReadOnlyDocument.mjs'
3
3
  import { ProtectedExtensionBinaryBoundary } from '../context/ProtectedExtensionBinaryBoundary.mjs'
4
4
  import { RuntimeProxyBoundary } from '../contracts/RuntimeProxyBoundary.mjs'
5
+ import { WorkerResultShape } from './WorkerResultShape.mjs'
5
6
 
6
7
  const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(
7
8
  ArrayBuffer.prototype,
@@ -56,6 +57,11 @@ const MAX_REQUEST_DEPTH = 256
56
57
  const MAX_REQUEST_VALUES = 100_000
57
58
  const MAX_RESULT_BYTES = 250_000_000
58
59
  const MAX_RESULT_VALUES = 2_000_000
60
+ const MAX_DOCUMENT_RESULT_VALUES = 5_000_000
61
+ // Project documents retain the single-result ceiling independently while this
62
+ // aggregate ceiling bounds total worker traversal regardless of document count.
63
+ const MAX_PROJECT_RESULT_VALUES = 8_000_000
64
+ const MAX_PROJECT_RESULT_BYTES = 256 * 1024 * 1024
59
65
  const TYPED_ARRAYS = new Map(
60
66
  [
61
67
  Int8Array,
@@ -166,14 +172,28 @@ export class WorkerRequestData {
166
172
  const state = {
167
173
  bytes: 0,
168
174
  copyBinary: limits.copyBinary,
175
+ documentAccounted: null,
176
+ documentBytes: null,
177
+ documentValues: null,
169
178
  maxBytes: limits.bytes,
179
+ maxDocumentBytes: limits.bytes,
180
+ maxDocumentValues: MAX_DOCUMENT_RESULT_VALUES,
181
+ maxUnscopedBytes: limits.bytes,
182
+ maxUnscopedValues: limits.values,
170
183
  maxValues: limits.values,
171
184
  output: limits.output,
172
185
  prepared: new WeakMap(),
186
+ projectDocuments: null,
187
+ projectResult: false,
188
+ reaccountValues: 0,
173
189
  strictDescriptors: limits.strictDescriptors,
174
190
  transfer: { items: [], seen: new Set() },
175
191
  transferInput: limits.transferInput,
176
192
  trustProof: limits.trustProof,
193
+ unscopedAccounted: null,
194
+ unscopedBytes: 0,
195
+ unscopedIncomplete: new WeakSet(),
196
+ unscopedValues: 0,
177
197
  values: 0,
178
198
  visiting: new WeakSet()
179
199
  }
@@ -223,8 +243,26 @@ export class WorkerRequestData {
223
243
  if (state.visiting.has(value)) {
224
244
  throw new TypeError('Worker request data must not be cyclic.')
225
245
  }
226
- if (state.prepared.has(value)) return state.prepared.get(value)
246
+ if (state.prepared.has(value)) {
247
+ const accounted = WorkerRequestData.#accountedScope(state)
248
+ if (accounted && !accounted.has(value)) {
249
+ WorkerRequestData.#accountPreparedValue(value, state, depth)
250
+ } else if (
251
+ accounted === state.unscopedAccounted &&
252
+ state.unscopedIncomplete.delete(value)
253
+ ) {
254
+ // The documents array itself was already charged unscoped.
255
+ WorkerRequestData.#accountPreparedContainer(
256
+ value,
257
+ state,
258
+ depth,
259
+ false
260
+ )
261
+ }
262
+ return state.prepared.get(value)
263
+ }
227
264
  WorkerRequestData.#reserveValues(state, 1)
265
+ WorkerRequestData.#accountedScope(state)?.add(value)
228
266
  state.visiting.add(value)
229
267
  try {
230
268
  const bufferLength = WorkerRequestData.#bufferLength(value)
@@ -305,18 +343,32 @@ export class WorkerRequestData {
305
343
  'Worker requests may contain only plain data objects.'
306
344
  )
307
345
  }
308
- const keys =
309
- state.output && !state.strictDescriptors
310
- ? Reflect.ownKeys(descriptors).filter(
311
- (key) =>
312
- typeof key === 'string' && descriptors[key].enumerable
313
- )
314
- : Reflect.ownKeys(descriptors)
315
- const documentResult =
346
+ const keys = WorkerResultShape.keys(
347
+ descriptors,
348
+ state.output,
349
+ state.strictDescriptors
350
+ )
351
+ const projectResult =
352
+ depth === 0 &&
353
+ state.output &&
354
+ WorkerResultShape.project(descriptors, keys)
355
+ if (projectResult) {
356
+ state.maxBytes = MAX_PROJECT_RESULT_BYTES
357
+ state.maxValues = MAX_PROJECT_RESULT_VALUES
358
+ state.projectResult = true
359
+ state.unscopedAccounted = new WeakSet([value])
360
+ state.unscopedBytes = state.bytes
361
+ state.unscopedValues = state.values
362
+ }
363
+ const canonicalDocumentResult =
364
+ state.output && WorkerResultShape.document(descriptors, keys)
365
+ if (depth === 0 && canonicalDocumentResult) {
366
+ state.maxValues = MAX_DOCUMENT_RESULT_VALUES
367
+ }
368
+ const provenDocumentResult =
316
369
  state.output &&
317
370
  state.trustProof &&
318
- WorkerRequestData.#dataValue(descriptors.schema) ===
319
- 'ecad-toolkit.document.v1' &&
371
+ canonicalDocumentResult &&
320
372
  CircuitJsonValidationProof.has(value)
321
373
  WorkerRequestData.#reserveValues(state, keys.length)
322
374
  const prepared = prototype === null ? Object.create(null) : {}
@@ -330,13 +382,19 @@ export class WorkerRequestData {
330
382
  configurable: true,
331
383
  enumerable: true,
332
384
  value:
333
- documentResult && key === 'model'
334
- ? WorkerRequestData.#documentModel(descriptors[key])
335
- : WorkerRequestData.#descriptorValue(
385
+ projectResult && key === 'documents'
386
+ ? WorkerRequestData.#projectDocuments(
336
387
  descriptors[key],
337
388
  state,
338
389
  depth
339
- ),
390
+ )
391
+ : provenDocumentResult && key === 'model'
392
+ ? WorkerRequestData.#documentModel(descriptors[key])
393
+ : WorkerRequestData.#descriptorValue(
394
+ descriptors[key],
395
+ state,
396
+ depth
397
+ ),
340
398
  writable: true
341
399
  })
342
400
  }
@@ -353,40 +411,113 @@ export class WorkerRequestData {
353
411
  * @returns {any[]} Prepared array.
354
412
  */
355
413
  static #prepareArray(value, prototype, descriptors, state, depth) {
356
- const keys = Reflect.ownKeys(descriptors)
357
- const length = WorkerRequestData.#dataValue(descriptors.length)
358
- const visibleKeys =
359
- state.output && !state.strictDescriptors
360
- ? keys.filter(
361
- (key) =>
362
- key !== 'length' &&
363
- typeof key === 'string' &&
364
- descriptors[key].enumerable
414
+ const length = WorkerResultShape.arrayLength(
415
+ prototype,
416
+ descriptors,
417
+ state.output,
418
+ state.strictDescriptors
419
+ )
420
+ WorkerRequestData.#reserveValues(state, length)
421
+ const prepared = new Array(length)
422
+ state.prepared.set(value, prepared)
423
+ const projectDocuments = state.projectDocuments === value
424
+ for (let index = 0; index < length; index += 1) {
425
+ prepared[index] = projectDocuments
426
+ ? WorkerRequestData.#projectDocument(
427
+ descriptors[String(index)],
428
+ state,
429
+ depth
365
430
  )
366
- : keys
367
- if (
368
- prototype !== Array.prototype ||
369
- !Number.isSafeInteger(length) ||
370
- length < 0 ||
371
- (state.output && !state.strictDescriptors
372
- ? visibleKeys.length !== length
373
- : keys.length !== length + 1)
374
- ) {
431
+ : WorkerRequestData.#descriptorValue(
432
+ descriptors[String(index)],
433
+ state,
434
+ depth
435
+ )
436
+ }
437
+ return prepared
438
+ }
439
+
440
+ /** @param {PropertyDescriptor | undefined} descriptor Descriptor. @param {Record<string, any>} state State. @param {number} depth Depth. @returns {unknown} Documents. */
441
+ static #projectDocuments(descriptor, state, depth) {
442
+ const value = WorkerRequestData.#descriptorDataValue(descriptor, state)
443
+ const reused = state.prepared.has(value)
444
+ state.projectDocuments = value
445
+ try {
446
+ const prepared = WorkerRequestData.#prepareValue(
447
+ value,
448
+ state,
449
+ depth + 1
450
+ )
451
+ if (reused) {
452
+ WorkerRequestData.#accountProjectDocuments(
453
+ value,
454
+ state,
455
+ depth + 1
456
+ )
457
+ } else {
458
+ // Descendants were charged only to their document scopes.
459
+ state.unscopedIncomplete.add(value)
460
+ }
461
+ return prepared
462
+ } finally {
463
+ state.projectDocuments = null
464
+ }
465
+ }
466
+
467
+ /** @param {object} value Documents. @param {Record<string, any>} state State. @param {number} depth Depth. @returns {void} */
468
+ static #accountProjectDocuments(value, state, depth) {
469
+ let prototype
470
+ let descriptors
471
+ try {
472
+ prototype = Object.getPrototypeOf(value)
473
+ descriptors = Object.getOwnPropertyDescriptors(value)
474
+ } catch {
375
475
  throw new TypeError(
376
- 'Worker request arrays must be bounded, dense, and plain.'
476
+ 'Worker request data could not be inspected safely.'
377
477
  )
378
478
  }
379
- WorkerRequestData.#reserveValues(state, length)
380
- const prepared = new Array(length)
381
- state.prepared.set(value, prepared)
479
+ const length = WorkerResultShape.arrayLength(
480
+ prototype,
481
+ descriptors,
482
+ state.output,
483
+ state.strictDescriptors
484
+ )
382
485
  for (let index = 0; index < length; index += 1) {
383
- prepared[index] = WorkerRequestData.#descriptorValue(
486
+ WorkerRequestData.#projectDocument(
384
487
  descriptors[String(index)],
385
488
  state,
386
489
  depth
387
490
  )
388
491
  }
389
- return prepared
492
+ }
493
+
494
+ /**
495
+ * Prepares one project document under the normal single-result ceiling.
496
+ * @param {PropertyDescriptor | undefined} descriptor Document descriptor.
497
+ * @param {Record<string, any>} state Traversal state.
498
+ * @param {number} depth Parent depth.
499
+ * @returns {unknown} Prepared document.
500
+ */
501
+ static #projectDocument(descriptor, state, depth) {
502
+ const previousMaxDocumentValues = state.maxDocumentValues
503
+ const document = WorkerRequestData.#dataValue(descriptor)
504
+ state.maxDocumentValues = WorkerResultShape.documentCandidate(
505
+ document,
506
+ state.strictDescriptors
507
+ )
508
+ ? MAX_DOCUMENT_RESULT_VALUES
509
+ : state.maxUnscopedValues
510
+ state.documentAccounted = new WeakSet()
511
+ state.documentBytes = 0
512
+ state.documentValues = 0
513
+ try {
514
+ return WorkerRequestData.#descriptorValue(descriptor, state, depth)
515
+ } finally {
516
+ state.documentAccounted = null
517
+ state.documentBytes = null
518
+ state.documentValues = null
519
+ state.maxDocumentValues = previousMaxDocumentValues
520
+ }
390
521
  }
391
522
 
392
523
  /**
@@ -397,24 +528,30 @@ export class WorkerRequestData {
397
528
  * @returns {unknown} Prepared value.
398
529
  */
399
530
  static #descriptorValue(descriptor, state, depth) {
531
+ return WorkerRequestData.#prepareValue(
532
+ WorkerRequestData.#descriptorDataValue(descriptor, state),
533
+ state,
534
+ depth + 1
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Reads one enumerable descriptor through trusted internal boundaries only.
540
+ * @param {PropertyDescriptor | undefined} descriptor Descriptor candidate.
541
+ * @param {Record<string, any>} state Traversal state.
542
+ * @returns {unknown} Descriptor data value.
543
+ */
544
+ static #descriptorDataValue(descriptor, state) {
400
545
  if (state.trustProof) {
401
546
  const protectedData =
402
547
  CircuitJsonReadOnlyDocument.readProtectedAssetData(descriptor)
403
548
  if (protectedData.trusted) {
404
- return WorkerRequestData.#prepareValue(
405
- protectedData.value,
406
- state,
407
- depth + 1
408
- )
549
+ return protectedData.value
409
550
  }
410
551
  const protectedExtensionBinary =
411
552
  ProtectedExtensionBinaryBoundary.read(descriptor)
412
553
  if (protectedExtensionBinary.trusted) {
413
- return WorkerRequestData.#prepareValue(
414
- protectedExtensionBinary.value,
415
- state,
416
- depth + 1
417
- )
554
+ return protectedExtensionBinary.value
418
555
  }
419
556
  }
420
557
  if (
@@ -426,8 +563,138 @@ export class WorkerRequestData {
426
563
  'Worker requests may contain only enumerable data properties.'
427
564
  )
428
565
  }
429
- return WorkerRequestData.#prepareValue(
430
- descriptor.value,
566
+ return descriptor.value
567
+ }
568
+
569
+ /**
570
+ * Charges one previously prepared graph to the active project document.
571
+ * @param {unknown} value Reused prepared value.
572
+ * @param {Record<string, any>} state Traversal state.
573
+ * @param {number} depth Container depth.
574
+ * @returns {void}
575
+ */
576
+ static #accountPreparedValue(value, state, depth) {
577
+ const type = typeof value
578
+ if (
579
+ value === null ||
580
+ ['undefined', 'boolean', 'number', 'bigint'].includes(type)
581
+ ) {
582
+ return
583
+ }
584
+ if (type === 'string') {
585
+ WorkerRequestData.#reserveAccountedBytes(state, value.length * 2)
586
+ return
587
+ }
588
+ if (type !== 'object') {
589
+ throw new TypeError(
590
+ 'Worker requests may contain only clone-safe data.'
591
+ )
592
+ }
593
+ RuntimeProxyBoundary.assert(value, 'Worker request data')
594
+ if (depth > MAX_REQUEST_DEPTH) {
595
+ throw new TypeError('Worker request data is nested too deeply.')
596
+ }
597
+ const accounted = WorkerRequestData.#accountedScope(state)
598
+ if (accounted.has(value)) return
599
+ accounted.add(value)
600
+ WorkerRequestData.#reserveAccountedValues(state, 1)
601
+ const bufferLength = WorkerRequestData.#bufferLength(value)
602
+ if (bufferLength !== null) {
603
+ WorkerRequestData.#reserveAccountedBytes(state, bufferLength)
604
+ return
605
+ }
606
+ const sharedLength = WorkerRequestData.#sharedBufferLength(value)
607
+ if (sharedLength !== null) {
608
+ WorkerRequestData.#reserveAccountedBytes(state, sharedLength)
609
+ return
610
+ }
611
+ const view = WorkerRequestData.#view(value)
612
+ if (view) {
613
+ WorkerRequestData.#reserveAccountedBytes(state, view.byteLength)
614
+ return
615
+ }
616
+ WorkerRequestData.#accountPreparedContainer(value, state, depth)
617
+ }
618
+
619
+ /** @param {object} value Container. @param {Record<string, any>} state State. @param {number} depth Depth. @param {boolean} [accountEntries] Whether to charge its own entries. @returns {void} */
620
+ static #accountPreparedContainer(
621
+ value,
622
+ state,
623
+ depth,
624
+ accountEntries = true
625
+ ) {
626
+ let prototype
627
+ let descriptors
628
+ try {
629
+ prototype = Object.getPrototypeOf(value)
630
+ descriptors = Object.getOwnPropertyDescriptors(value)
631
+ } catch {
632
+ throw new TypeError(
633
+ 'Worker request data could not be inspected safely.'
634
+ )
635
+ }
636
+ if (Array.isArray(value)) {
637
+ const length = WorkerResultShape.arrayLength(
638
+ prototype,
639
+ descriptors,
640
+ state.output,
641
+ state.strictDescriptors
642
+ )
643
+ if (accountEntries) {
644
+ WorkerRequestData.#reserveAccountedValues(state, length)
645
+ }
646
+ for (let index = 0; index < length; index += 1) {
647
+ WorkerRequestData.#accountDescriptor(
648
+ descriptors[String(index)],
649
+ state,
650
+ depth
651
+ )
652
+ }
653
+ return
654
+ }
655
+ if (prototype !== Object.prototype && prototype !== null) {
656
+ throw new TypeError(
657
+ 'Worker requests may contain only plain data objects.'
658
+ )
659
+ }
660
+ const keys = WorkerResultShape.keys(
661
+ descriptors,
662
+ state.output,
663
+ state.strictDescriptors
664
+ )
665
+ const provenDocumentResult =
666
+ state.output &&
667
+ state.trustProof &&
668
+ WorkerResultShape.document(descriptors, keys) &&
669
+ CircuitJsonValidationProof.has(value)
670
+ if (accountEntries) {
671
+ WorkerRequestData.#reserveAccountedValues(state, keys.length)
672
+ }
673
+ for (const key of keys) {
674
+ if (typeof key !== 'string') {
675
+ throw new TypeError('Worker request keys must be strings.')
676
+ }
677
+ WorkerRequestData.#reserveAccountedBytes(state, key.length * 2)
678
+ if (!(provenDocumentResult && key === 'model')) {
679
+ WorkerRequestData.#accountDescriptor(
680
+ descriptors[key],
681
+ state,
682
+ depth
683
+ )
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Charges one reused descriptor to the active document.
690
+ * @param {PropertyDescriptor | undefined} descriptor Descriptor candidate.
691
+ * @param {Record<string, any>} state Traversal state.
692
+ * @param {number} depth Parent depth.
693
+ * @returns {void}
694
+ */
695
+ static #accountDescriptor(descriptor, state, depth) {
696
+ WorkerRequestData.#accountPreparedValue(
697
+ WorkerRequestData.#descriptorDataValue(descriptor, state),
431
698
  state,
432
699
  depth + 1
433
700
  )
@@ -624,27 +891,106 @@ export class WorkerRequestData {
624
891
  transfer.items.push(value)
625
892
  }
626
893
 
627
- /** @param {{ bytes: number }} state State. @param {number} count Bytes. */
894
+ /** @param {Record<string, any>} state State. @returns {WeakSet<object> | null} Active identities. */
895
+ static #accountedScope(state) {
896
+ if (state.documentAccounted) return state.documentAccounted
897
+ return state.projectResult ? state.unscopedAccounted : null
898
+ }
899
+
900
+ /** @param {{ documentBytes: number | null, maxDocumentBytes: number, maxUnscopedBytes: number, projectResult: boolean, unscopedBytes: number }} state State. @param {number} count Bytes. */
901
+ static #reserveAccountedBytes(state, count) {
902
+ const invalid = !Number.isSafeInteger(count) || count < 0
903
+ if (state.documentBytes !== null) {
904
+ if (
905
+ invalid ||
906
+ state.documentBytes + count > state.maxDocumentBytes
907
+ ) {
908
+ throw new TypeError(
909
+ 'Worker request data exceeds its byte limit.'
910
+ )
911
+ }
912
+ state.documentBytes += count
913
+ return
914
+ }
915
+ if (
916
+ invalid ||
917
+ !state.projectResult ||
918
+ state.unscopedBytes + count > state.maxUnscopedBytes
919
+ ) {
920
+ throw new TypeError('Worker request data exceeds its byte limit.')
921
+ }
922
+ state.unscopedBytes += count
923
+ }
924
+
925
+ /** @param {{ documentValues: number | null, maxDocumentValues: number, maxUnscopedValues: number, maxValues: number, projectResult: boolean, reaccountValues: number, unscopedValues: number }} state State. @param {number} count Values. */
926
+ static #reserveAccountedValues(state, count) {
927
+ const invalid =
928
+ !Number.isSafeInteger(count) ||
929
+ count < 0 ||
930
+ state.reaccountValues + count > state.maxValues
931
+ if (state.documentValues !== null) {
932
+ if (
933
+ invalid ||
934
+ state.documentValues + count > state.maxDocumentValues
935
+ ) {
936
+ throw new TypeError('Worker request data is too large.')
937
+ }
938
+ state.documentValues += count
939
+ state.reaccountValues += count
940
+ return
941
+ }
942
+ if (
943
+ invalid ||
944
+ !state.projectResult ||
945
+ state.unscopedValues + count > state.maxUnscopedValues
946
+ ) {
947
+ throw new TypeError('Worker request data is too large.')
948
+ }
949
+ state.unscopedValues += count
950
+ state.reaccountValues += count
951
+ }
952
+
953
+ /** @param {{ bytes: number, documentBytes: number | null, maxBytes: number, maxDocumentBytes: number, maxUnscopedBytes: number, projectResult: boolean, unscopedBytes: number }} state State. @param {number} count Bytes. */
628
954
  static #reserveBytes(state, count) {
955
+ const documentBytes = state.documentBytes
629
956
  if (
630
957
  !Number.isSafeInteger(count) ||
631
958
  count < 0 ||
632
- state.bytes + count > state.maxBytes
959
+ state.bytes + count > state.maxBytes ||
960
+ (documentBytes !== null
961
+ ? documentBytes + count > state.maxDocumentBytes
962
+ : state.projectResult &&
963
+ state.unscopedBytes + count > state.maxUnscopedBytes)
633
964
  ) {
634
965
  throw new TypeError('Worker request data exceeds its byte limit.')
635
966
  }
636
967
  state.bytes += count
968
+ if (documentBytes !== null) {
969
+ state.documentBytes += count
970
+ } else if (state.projectResult) {
971
+ state.unscopedBytes += count
972
+ }
637
973
  }
638
974
 
639
- /** @param {{ values: number }} state State. @param {number} count Values. */
975
+ /** @param {{ documentValues: number | null, maxDocumentValues: number, maxUnscopedValues: number, maxValues: number, projectResult: boolean, unscopedValues: number, values: number }} state State. @param {number} count Values. */
640
976
  static #reserveValues(state, count) {
977
+ const documentValues = state.documentValues
641
978
  if (
642
979
  !Number.isSafeInteger(count) ||
643
980
  count < 0 ||
644
- state.values + count > state.maxValues
981
+ state.values + count > state.maxValues ||
982
+ (documentValues !== null
983
+ ? documentValues + count > state.maxDocumentValues
984
+ : state.projectResult &&
985
+ state.unscopedValues + count > state.maxUnscopedValues)
645
986
  ) {
646
987
  throw new TypeError('Worker request data is too large.')
647
988
  }
648
989
  state.values += count
990
+ if (documentValues !== null) {
991
+ state.documentValues += count
992
+ } else if (state.projectResult) {
993
+ state.unscopedValues += count
994
+ }
649
995
  }
650
996
  }