circuitjson-toolkit 1.0.16 → 1.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.
Files changed (155) hide show
  1. package/AGENTS.md +15 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +267 -107
  4. package/docs/api.md +501 -96
  5. package/docs/capabilities.md +70 -0
  6. package/docs/migration/behaviors.md +45 -0
  7. package/docs/migration/parser.md +60 -0
  8. package/docs/migration/renderers.md +515 -0
  9. package/docs/migration/root.md +740 -0
  10. package/docs/migration.md +120 -0
  11. package/docs/model-format.md +175 -57
  12. package/docs/provenance.md +206 -0
  13. package/docs/release-notes-v1.1.0.md +154 -0
  14. package/docs/testing.md +117 -7
  15. package/package.json +31 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +997 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +623 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -0,0 +1,649 @@
1
+ import { ArchiveEntryPath } from './ArchiveEntryPath.mjs'
2
+ import { ArchiveLimits } from './ArchiveLimits.mjs'
3
+ import { BinaryDataSnapshot } from './context/BinaryDataSnapshot.mjs'
4
+ import { ToolkitError } from './contracts/ToolkitError.mjs'
5
+
6
+ const CENTRAL_SIGNATURE = 0x02014b50
7
+ const END_SIGNATURE = 0x06054b50
8
+ const LOCAL_SIGNATURE = 0x04034b50
9
+ const END_FIXED_BYTES = 22
10
+ const CENTRAL_FIXED_BYTES = 46
11
+ const LOCAL_FIXED_BYTES = 30
12
+ const MAX_END_SEARCH_BYTES = 65_557
13
+ const ZIP64_UINT16 = 0xffff
14
+ const ZIP64_UINT32 = 0xffffffff
15
+ const DATA_DESCRIPTOR_FLAG = 0x0008
16
+ const TEXT_DECODER = new TextDecoder()
17
+ const TEXT_DECODER_DECODE = TextDecoder.prototype.decode
18
+ const CRC32_TABLE = new Uint32Array(256)
19
+ for (let index = 0; index < CRC32_TABLE.length; index += 1) {
20
+ let value = index
21
+ for (let bit = 0; bit < 8; bit += 1) {
22
+ value = (value >>> 1) ^ (0xedb88320 & -(value & 1))
23
+ }
24
+ CRC32_TABLE[index] = value >>> 0
25
+ }
26
+
27
+ /** Inspects ZIP central-directory metadata before any payload is inflated. */
28
+ export class ZipArchiveInspector {
29
+ /**
30
+ * Validates archive metadata against normalized shared limits.
31
+ * @param {ArrayBuffer | Uint8Array | DataView} data ZIP bytes.
32
+ * @param {{ archiveName?: string, archiveDepth?: number, baseEntryCount?: number, baseTotalBytes?: number, limits?: Record<string, number> }} [options] Inspection options.
33
+ * @returns {{ schema: string, archiveName: string, archiveDepth: number, entryCount: number, totalCompressedBytes: number, totalUncompressedBytes: number, entries: ReadonlyArray<Readonly<Record<string, any>>> }} Frozen preflight report.
34
+ */
35
+ static inspect(data, options = {}) {
36
+ const normalized = ZipArchiveInspector.#options(options)
37
+ const bytes = ZipArchiveInspector.#bytes(data)
38
+ ZipArchiveInspector.#assertLimit(
39
+ 'maxArchiveDepth',
40
+ normalized.limits.maxArchiveDepth,
41
+ normalized.archiveDepth,
42
+ normalized.archiveName
43
+ )
44
+ const end = ZipArchiveInspector.#endRecord(bytes)
45
+ const view = new DataView(
46
+ bytes.buffer,
47
+ bytes.byteOffset,
48
+ bytes.byteLength
49
+ )
50
+ const entries = ZipArchiveInspector.#centralEntries(
51
+ bytes,
52
+ view,
53
+ end,
54
+ normalized
55
+ )
56
+ const totalCompressedBytes = entries.reduce(
57
+ (total, entry) => total + entry.compressedSize,
58
+ 0
59
+ )
60
+ const totalUncompressedBytes = entries.reduce(
61
+ (total, entry) => total + entry.uncompressedSize,
62
+ 0
63
+ )
64
+ ZipArchiveInspector.#assertLimit(
65
+ 'maxEntries',
66
+ normalized.limits.maxEntries,
67
+ normalized.baseEntryCount + entries.length,
68
+ normalized.archiveName
69
+ )
70
+ ZipArchiveInspector.#assertLimit(
71
+ 'maxTotalBytes',
72
+ normalized.limits.maxTotalBytes,
73
+ normalized.baseTotalBytes + totalUncompressedBytes,
74
+ normalized.archiveName
75
+ )
76
+ const ratio =
77
+ totalCompressedBytes === 0
78
+ ? totalUncompressedBytes === 0
79
+ ? 0
80
+ : Number.POSITIVE_INFINITY
81
+ : totalUncompressedBytes / totalCompressedBytes
82
+ ZipArchiveInspector.#assertLimit(
83
+ 'maxCompressionRatio',
84
+ normalized.limits.maxCompressionRatio,
85
+ ratio,
86
+ normalized.archiveName
87
+ )
88
+ const uniqueNames = ArchiveEntryPath.unique(
89
+ entries
90
+ .filter((entry) => !entry.directory)
91
+ .map((entry) => entry.name)
92
+ )
93
+ let fileIndex = 0
94
+ const frozenEntries = entries.map((entry) =>
95
+ Object.freeze({
96
+ ...entry,
97
+ name: entry.directory ? entry.name : uniqueNames[fileIndex++]
98
+ })
99
+ )
100
+ return Object.freeze({
101
+ schema: 'ecad-toolkit.zip-preflight.v1',
102
+ archiveName: normalized.archiveName,
103
+ archiveDepth: normalized.archiveDepth,
104
+ entryCount: entries.length,
105
+ totalCompressedBytes,
106
+ totalUncompressedBytes,
107
+ entries: Object.freeze(frozenEntries)
108
+ })
109
+ }
110
+
111
+ /**
112
+ * Verifies bytes produced by an inflater against inspected ZIP metadata.
113
+ * @param {{ name: string, uncompressedSize: number, crc32: number }} entry Frozen entry from `inspect()`.
114
+ * @param {ArrayBuffer | Uint8Array | DataView} data Extracted entry bytes.
115
+ * @returns {true} True when size and CRC32 match.
116
+ */
117
+ static verifyExtractedBytes(entry, data) {
118
+ const expected = ZipArchiveInspector.#integrityEntry(entry)
119
+ const bytes = ZipArchiveInspector.#bytes(data)
120
+ if (bytes.byteLength !== expected.uncompressedSize) {
121
+ throw ZipArchiveInspector.#integrityInvalid(
122
+ 'ZIP extracted byte length differs from its central directory.',
123
+ expected,
124
+ { actualSize: bytes.byteLength }
125
+ )
126
+ }
127
+ const actualCrc32 = ZipArchiveInspector.#crc32(bytes)
128
+ if (actualCrc32 !== expected.crc32) {
129
+ throw ZipArchiveInspector.#integrityInvalid(
130
+ 'ZIP extracted bytes fail CRC32 verification.',
131
+ expected,
132
+ { actualCrc32 }
133
+ )
134
+ }
135
+ return true
136
+ }
137
+
138
+ /**
139
+ * Reads and validates every central-directory record.
140
+ * @param {Uint8Array} bytes Archive bytes.
141
+ * @param {DataView} view Archive view.
142
+ * @param {{ entryCount: number, centralOffset: number, centralSize: number, offset: number }} end End record.
143
+ * @param {Record<string, any>} options Normalized options.
144
+ * @returns {Record<string, any>[]} Entry metadata.
145
+ */
146
+ static #centralEntries(bytes, view, end, options) {
147
+ ZipArchiveInspector.#assertLimit(
148
+ 'maxEntries',
149
+ options.limits.maxEntries,
150
+ options.baseEntryCount + end.entryCount,
151
+ options.archiveName
152
+ )
153
+ const centralEnd = end.centralOffset + end.centralSize
154
+ if (
155
+ end.centralOffset < 0 ||
156
+ end.centralSize < 0 ||
157
+ centralEnd > end.offset ||
158
+ centralEnd > bytes.byteLength
159
+ ) {
160
+ throw ZipArchiveInspector.#invalid(
161
+ 'ZIP central directory points outside the archive.',
162
+ options.archiveName
163
+ )
164
+ }
165
+ const entries = []
166
+ let offset = end.centralOffset
167
+ for (let index = 0; index < end.entryCount; index += 1) {
168
+ if (
169
+ offset + CENTRAL_FIXED_BYTES > centralEnd ||
170
+ view.getUint32(offset, true) !== CENTRAL_SIGNATURE
171
+ ) {
172
+ throw ZipArchiveInspector.#invalid(
173
+ 'ZIP central directory entry is malformed.',
174
+ options.archiveName
175
+ )
176
+ }
177
+ const flags = view.getUint16(offset + 8, true)
178
+ const method = view.getUint16(offset + 10, true)
179
+ const crc32 = view.getUint32(offset + 16, true)
180
+ const compressedSize = view.getUint32(offset + 20, true)
181
+ const uncompressedSize = view.getUint32(offset + 24, true)
182
+ const nameLength = view.getUint16(offset + 28, true)
183
+ const extraLength = view.getUint16(offset + 30, true)
184
+ const commentLength = view.getUint16(offset + 32, true)
185
+ const diskStart = view.getUint16(offset + 34, true)
186
+ const localOffset = view.getUint32(offset + 42, true)
187
+ if (
188
+ compressedSize === ZIP64_UINT32 ||
189
+ uncompressedSize === ZIP64_UINT32 ||
190
+ localOffset === ZIP64_UINT32 ||
191
+ diskStart === ZIP64_UINT16
192
+ ) {
193
+ throw ZipArchiveInspector.#unsupported(
194
+ 'ZIP64 archives are not supported by this bounded loader.',
195
+ options.archiveName
196
+ )
197
+ }
198
+ if (diskStart !== 0) {
199
+ throw ZipArchiveInspector.#unsupported(
200
+ 'Multi-disk ZIP archives are not supported.',
201
+ options.archiveName
202
+ )
203
+ }
204
+ if ((flags & 1) !== 0) {
205
+ throw ZipArchiveInspector.#unsupported(
206
+ 'Encrypted ZIP entries are not supported.',
207
+ options.archiveName
208
+ )
209
+ }
210
+ if (![0, 8].includes(method)) {
211
+ throw ZipArchiveInspector.#unsupported(
212
+ `ZIP compression method ${method} is not supported.`,
213
+ options.archiveName
214
+ )
215
+ }
216
+ const recordEnd =
217
+ offset +
218
+ CENTRAL_FIXED_BYTES +
219
+ nameLength +
220
+ extraLength +
221
+ commentLength
222
+ if (recordEnd > centralEnd) {
223
+ throw ZipArchiveInspector.#invalid(
224
+ 'ZIP central directory entry exceeds its directory.',
225
+ options.archiveName
226
+ )
227
+ }
228
+ const rawNameBytes = bytes.subarray(
229
+ offset + CENTRAL_FIXED_BYTES,
230
+ offset + CENTRAL_FIXED_BYTES + nameLength
231
+ )
232
+ const rawName = Reflect.apply(TEXT_DECODER_DECODE, TEXT_DECODER, [
233
+ rawNameBytes
234
+ ])
235
+ const directory = rawName.endsWith('/')
236
+ const path = directory ? rawName.slice(0, -1) : rawName
237
+ const name = ArchiveEntryPath.normalize(path)
238
+ ZipArchiveInspector.#assertLimit(
239
+ 'maxEntryBytes',
240
+ options.limits.maxEntryBytes,
241
+ uncompressedSize,
242
+ name
243
+ )
244
+ const payloadOffset = ZipArchiveInspector.#localRecord(
245
+ bytes,
246
+ view,
247
+ end.centralOffset,
248
+ localOffset,
249
+ rawNameBytes,
250
+ compressedSize,
251
+ uncompressedSize,
252
+ crc32,
253
+ flags,
254
+ method,
255
+ options.archiveName
256
+ )
257
+ entries.push({
258
+ name,
259
+ directory,
260
+ flags,
261
+ method,
262
+ crc32,
263
+ compressedSize,
264
+ uncompressedSize,
265
+ localOffset,
266
+ payloadOffset
267
+ })
268
+ offset = recordEnd
269
+ }
270
+ if (offset !== centralEnd) {
271
+ throw ZipArchiveInspector.#invalid(
272
+ 'ZIP central directory size is inconsistent.',
273
+ options.archiveName
274
+ )
275
+ }
276
+ return entries
277
+ }
278
+
279
+ /**
280
+ * Validates the local record and compressed payload window.
281
+ * @param {Uint8Array} bytes Archive bytes.
282
+ * @param {DataView} view Archive view.
283
+ * @param {number} centralOffset Central-directory offset.
284
+ * @param {number} offset Local record offset.
285
+ * @param {Uint8Array} centralNameBytes Central-directory filename bytes.
286
+ * @param {number} compressedSize Compressed payload bytes.
287
+ * @param {number} uncompressedSize Extracted payload bytes.
288
+ * @param {number} crc32 Expected payload CRC32.
289
+ * @param {number} flags Central flags.
290
+ * @param {number} method Central compression method.
291
+ * @param {string} source Archive name.
292
+ * @returns {number} Compressed payload offset.
293
+ */
294
+ static #localRecord(
295
+ bytes,
296
+ view,
297
+ centralOffset,
298
+ offset,
299
+ centralNameBytes,
300
+ compressedSize,
301
+ uncompressedSize,
302
+ crc32,
303
+ flags,
304
+ method,
305
+ source
306
+ ) {
307
+ if (
308
+ offset + LOCAL_FIXED_BYTES > centralOffset ||
309
+ view.getUint32(offset, true) !== LOCAL_SIGNATURE
310
+ ) {
311
+ throw ZipArchiveInspector.#invalid(
312
+ 'ZIP local entry is malformed.',
313
+ source
314
+ )
315
+ }
316
+ const localFlags = view.getUint16(offset + 6, true)
317
+ const localMethod = view.getUint16(offset + 8, true)
318
+ const localCrc32 = view.getUint32(offset + 14, true)
319
+ const localCompressedSize = view.getUint32(offset + 18, true)
320
+ const localUncompressedSize = view.getUint32(offset + 22, true)
321
+ const nameLength = view.getUint16(offset + 26, true)
322
+ const extraLength = view.getUint16(offset + 28, true)
323
+ const nameOffset = offset + LOCAL_FIXED_BYTES
324
+ const payloadOffset = nameOffset + nameLength + extraLength
325
+ let localNameMatches = nameLength === centralNameBytes.byteLength
326
+ for (
327
+ let index = 0;
328
+ localNameMatches && index < nameLength;
329
+ index += 1
330
+ ) {
331
+ localNameMatches =
332
+ bytes[nameOffset + index] === centralNameBytes[index]
333
+ }
334
+ const usesDataDescriptor = (flags & DATA_DESCRIPTOR_FLAG) !== 0
335
+ const descriptorMetadataValid =
336
+ usesDataDescriptor &&
337
+ [
338
+ [localCrc32, crc32],
339
+ [localCompressedSize, compressedSize],
340
+ [localUncompressedSize, uncompressedSize]
341
+ ].every(([local, central]) => local === 0 || local === central)
342
+ const fixedMetadataValid =
343
+ !usesDataDescriptor &&
344
+ localCrc32 === crc32 &&
345
+ localCompressedSize === compressedSize &&
346
+ localUncompressedSize === uncompressedSize
347
+ if (
348
+ localFlags !== flags ||
349
+ localMethod !== method ||
350
+ !localNameMatches ||
351
+ (!descriptorMetadataValid && !fixedMetadataValid) ||
352
+ payloadOffset + compressedSize > centralOffset
353
+ ) {
354
+ throw ZipArchiveInspector.#invalid(
355
+ 'ZIP local entry metadata is inconsistent.',
356
+ source
357
+ )
358
+ }
359
+ return payloadOffset
360
+ }
361
+
362
+ /**
363
+ * Captures trusted integrity fields without invoking accessors.
364
+ * @param {unknown} entry Inspected entry candidate.
365
+ * @returns {{ name: string, uncompressedSize: number, crc32: number }}
366
+ */
367
+ static #integrityEntry(entry) {
368
+ let prototype
369
+ let descriptors
370
+ try {
371
+ prototype = Object.getPrototypeOf(entry)
372
+ descriptors = Object.getOwnPropertyDescriptors(entry)
373
+ } catch {
374
+ throw ZipArchiveInspector.#invalid(
375
+ 'ZIP integrity metadata must be a plain object.'
376
+ )
377
+ }
378
+ if (
379
+ (prototype !== Object.prototype && prototype !== null) ||
380
+ Object.values(descriptors).some(
381
+ (descriptor) => !Object.hasOwn(descriptor, 'value')
382
+ )
383
+ ) {
384
+ throw ZipArchiveInspector.#invalid(
385
+ 'ZIP integrity metadata must contain plain data properties.'
386
+ )
387
+ }
388
+ const name = descriptors.name?.value
389
+ const uncompressedSize = descriptors.uncompressedSize?.value
390
+ const crc32 = descriptors.crc32?.value
391
+ if (
392
+ typeof name !== 'string' ||
393
+ !Number.isSafeInteger(uncompressedSize) ||
394
+ uncompressedSize < 0 ||
395
+ !Number.isInteger(crc32) ||
396
+ crc32 < 0 ||
397
+ crc32 > ZIP64_UINT32
398
+ ) {
399
+ throw ZipArchiveInspector.#invalid(
400
+ 'ZIP integrity metadata is incomplete or invalid.'
401
+ )
402
+ }
403
+ return { name, uncompressedSize, crc32 }
404
+ }
405
+
406
+ /**
407
+ * Calculates an unsigned CRC32 with one reusable lookup table.
408
+ * @param {Uint8Array} bytes Source bytes.
409
+ * @returns {number} Unsigned CRC32.
410
+ */
411
+ static #crc32(bytes) {
412
+ let crc = 0xffffffff
413
+ for (let index = 0; index < bytes.byteLength; index += 1) {
414
+ crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8)
415
+ }
416
+ return (crc ^ 0xffffffff) >>> 0
417
+ }
418
+
419
+ /**
420
+ * Locates and validates the single-disk end record.
421
+ * @param {Uint8Array} bytes Archive bytes.
422
+ * @returns {{ entryCount: number, centralOffset: number, centralSize: number, offset: number }} End record.
423
+ */
424
+ static #endRecord(bytes) {
425
+ if (bytes.byteLength < END_FIXED_BYTES) {
426
+ throw ZipArchiveInspector.#invalid('ZIP archive is truncated.')
427
+ }
428
+ const view = new DataView(
429
+ bytes.buffer,
430
+ bytes.byteOffset,
431
+ bytes.byteLength
432
+ )
433
+ const start = Math.max(0, bytes.byteLength - MAX_END_SEARCH_BYTES)
434
+ for (
435
+ let offset = bytes.byteLength - END_FIXED_BYTES;
436
+ offset >= start;
437
+ offset -= 1
438
+ ) {
439
+ if (view.getUint32(offset, true) !== END_SIGNATURE) continue
440
+ const commentLength = view.getUint16(offset + 20, true)
441
+ if (offset + END_FIXED_BYTES + commentLength !== bytes.byteLength) {
442
+ continue
443
+ }
444
+ const disk = view.getUint16(offset + 4, true)
445
+ const centralDisk = view.getUint16(offset + 6, true)
446
+ const diskEntries = view.getUint16(offset + 8, true)
447
+ const entryCount = view.getUint16(offset + 10, true)
448
+ const centralSize = view.getUint32(offset + 12, true)
449
+ const centralOffset = view.getUint32(offset + 16, true)
450
+ if (
451
+ disk === ZIP64_UINT16 ||
452
+ centralDisk === ZIP64_UINT16 ||
453
+ entryCount === ZIP64_UINT16 ||
454
+ centralSize === ZIP64_UINT32 ||
455
+ centralOffset === ZIP64_UINT32
456
+ ) {
457
+ throw ZipArchiveInspector.#unsupported(
458
+ 'ZIP64 archives are not supported by this bounded loader.'
459
+ )
460
+ }
461
+ if (disk !== 0 || centralDisk !== 0 || diskEntries !== entryCount) {
462
+ throw ZipArchiveInspector.#unsupported(
463
+ 'Multi-disk ZIP archives are not supported.'
464
+ )
465
+ }
466
+ return { entryCount, centralOffset, centralSize, offset }
467
+ }
468
+ throw ZipArchiveInspector.#invalid(
469
+ 'ZIP end-of-central-directory record was not found.'
470
+ )
471
+ }
472
+
473
+ /**
474
+ * Returns an exact byte view, copying shared memory to prevent races.
475
+ * @param {unknown} data Binary archive data.
476
+ * @returns {Uint8Array} Archive bytes.
477
+ */
478
+ static #bytes(data) {
479
+ const range = BinaryDataSnapshot.describe(data)
480
+ if (!range) {
481
+ throw ZipArchiveInspector.#invalid(
482
+ 'ZIP archive data must be binary.'
483
+ )
484
+ }
485
+ if (
486
+ typeof SharedArrayBuffer === 'function' &&
487
+ range.buffer instanceof SharedArrayBuffer
488
+ ) {
489
+ return BinaryDataSnapshot.copyBytes(data, range)
490
+ }
491
+ try {
492
+ return new Uint8Array(
493
+ range.buffer,
494
+ range.byteOffset,
495
+ range.byteLength
496
+ )
497
+ } catch {
498
+ throw ZipArchiveInspector.#invalid(
499
+ 'ZIP archive data changed during inspection.'
500
+ )
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Captures plain inspection options.
506
+ * @param {unknown} options Options candidate.
507
+ * @returns {Record<string, any>} Normalized options.
508
+ */
509
+ static #options(options) {
510
+ let prototype
511
+ let descriptors
512
+ try {
513
+ prototype = Object.getPrototypeOf(options)
514
+ descriptors = Object.getOwnPropertyDescriptors(options)
515
+ } catch {
516
+ throw ZipArchiveInspector.#invalid(
517
+ 'ZIP inspection options must be a plain object.'
518
+ )
519
+ }
520
+ if (
521
+ (prototype !== Object.prototype && prototype !== null) ||
522
+ Object.values(descriptors).some(
523
+ (descriptor) => !Object.hasOwn(descriptor, 'value')
524
+ )
525
+ ) {
526
+ throw ZipArchiveInspector.#invalid(
527
+ 'ZIP inspection options must contain plain data properties.'
528
+ )
529
+ }
530
+ const known = new Set([
531
+ 'archiveName',
532
+ 'archiveDepth',
533
+ 'baseEntryCount',
534
+ 'baseTotalBytes',
535
+ 'limits'
536
+ ])
537
+ if (Object.keys(descriptors).some((key) => !known.has(key))) {
538
+ throw ZipArchiveInspector.#invalid(
539
+ 'ZIP inspection options contain an unknown field.'
540
+ )
541
+ }
542
+ return {
543
+ archiveName: ZipArchiveInspector.#string(
544
+ descriptors.archiveName?.value,
545
+ ''
546
+ ),
547
+ archiveDepth: ZipArchiveInspector.#integer(
548
+ descriptors.archiveDepth?.value,
549
+ 1,
550
+ 'archiveDepth'
551
+ ),
552
+ baseEntryCount: ZipArchiveInspector.#integer(
553
+ descriptors.baseEntryCount?.value,
554
+ 0,
555
+ 'baseEntryCount'
556
+ ),
557
+ baseTotalBytes: ZipArchiveInspector.#integer(
558
+ descriptors.baseTotalBytes?.value,
559
+ 0,
560
+ 'baseTotalBytes'
561
+ ),
562
+ limits: ArchiveLimits.normalize(descriptors.limits?.value || {})
563
+ }
564
+ }
565
+
566
+ /** @param {unknown} value Value. @param {string} fallback Fallback. @returns {string} String. */
567
+ static #string(value, fallback) {
568
+ if (value === undefined) return fallback
569
+ if (typeof value !== 'string') {
570
+ throw ZipArchiveInspector.#invalid(
571
+ 'ZIP archiveName must be a string.'
572
+ )
573
+ }
574
+ return value
575
+ }
576
+
577
+ /** @param {unknown} value Value. @param {number} fallback Fallback. @param {string} name Field name. @returns {number} Integer. */
578
+ static #integer(value, fallback, name) {
579
+ if (value === undefined) return fallback
580
+ if (!Number.isSafeInteger(value) || value < 0) {
581
+ throw ZipArchiveInspector.#invalid(
582
+ `ZIP ${name} must be a nonnegative safe integer.`
583
+ )
584
+ }
585
+ return value
586
+ }
587
+
588
+ /** @param {string} name Limit name. @param {number} limit Limit. @param {number} actual Actual. @param {string} source Source. @returns {void} */
589
+ static #assertLimit(name, limit, actual, source) {
590
+ if (actual <= limit) return
591
+ throw new ToolkitError(`Archive ${name} exceeded.`, {
592
+ code: 'ERR_ARCHIVE_LIMIT_EXCEEDED',
593
+ category: 'validation',
594
+ format: 'archive',
595
+ source,
596
+ details: {
597
+ limit: name,
598
+ maximum: limit,
599
+ actual,
600
+ entryName: source
601
+ }
602
+ })
603
+ }
604
+
605
+ /** @param {string} message Message. @param {string} [source] Source. @returns {ToolkitError} Error. */
606
+ static #invalid(message, source = '') {
607
+ return new ToolkitError(message, {
608
+ code: 'ERR_ARCHIVE_INVALID',
609
+ category: 'validation',
610
+ format: 'archive',
611
+ source
612
+ })
613
+ }
614
+
615
+ /**
616
+ * Builds an archive-integrity error with exact expected and actual fields.
617
+ * @param {string} message Error message.
618
+ * @param {{ name: string, uncompressedSize: number, crc32: number }} expected Expected entry metadata.
619
+ * @param {Record<string, number>} actual Actual integrity fields.
620
+ * @returns {ToolkitError}
621
+ */
622
+ static #integrityInvalid(message, expected, actual) {
623
+ return new ToolkitError(message, {
624
+ code: 'ERR_ARCHIVE_INVALID',
625
+ category: 'validation',
626
+ format: 'archive',
627
+ source: expected.name,
628
+ details: {
629
+ entryName: expected.name,
630
+ expectedSize: expected.uncompressedSize,
631
+ expectedCrc32: expected.crc32,
632
+ ...actual
633
+ }
634
+ })
635
+ }
636
+
637
+ /** @param {string} message Message. @param {string} [source] Source. @returns {ToolkitError} Error. */
638
+ static #unsupported(message, source = '') {
639
+ return new ToolkitError(message, {
640
+ code: 'ERR_ARCHIVE_UNSUPPORTED',
641
+ category: 'unsupported',
642
+ format: 'archive',
643
+ source
644
+ })
645
+ }
646
+ }
647
+
648
+ Object.freeze(ZipArchiveInspector.prototype)
649
+ Object.freeze(ZipArchiveInspector)